HCT Picker

Monochrome
Neutral
Tonal Spot
Vibrant
Expressive
Fidelity
Content
Rainbow
Fruit Salad
2021
2025
OK
The blog picture shows the text deep dive lit3 decorators runtime and microtasks.

Overview

Overview is generated by AI and may contain errors.

本文專注於破除 Web Components 與 Lit 3 開發中的「黑盒時序盲區」。全文以一個顛覆直覺的「時序探針實驗」為起點,展示了連續修改屬性時,為什麼原生 queueMicrotask 依然會讀取到舊 DOM,進而從 V8 引擎視角拆解 Lit 內部 _enqueueUpdate 的多輪次微任務(Multi-tick)執行鏈。 隨後,文章聚焦於三大掌控 DOM 邊界與生命週期時序的高階裝飾器: @queryAsync:剖析其 Promise-based Getter 機制,並解決父子組件微任務交錯導致的「瀑布式」時序崩潰。 @queryAssignedElements:揭露其「純同步惰性 Getter」本質,展示如何配合 @slotchange 事件為 Light DOM 投影構建響應式時序閉環,並指出 flatten: true 的性能代價。 @provide & @consume:詳解 W3C Context Protocol 底層的同步 DOM 自定義事件冒泡機制,破解 DOM 樹「子組件先於父組件掛載」的逆向時序難題。 最後提供「Lit 3 裝飾器運行時確定性矩陣」,幫助中高級前端工程師擺脫盲目使用 setTimeout 的脆弱代碼,構建具備確定性時序的高性能 Web Components 組件庫。

Expand
Copy Link

深入 Lit 3 裝飾器底層:微任務調度、時序陷阱

多數前端工程師對 Web Components 和 Lit 框架的理解,往往停留在「帶有響應式狀態的 Custom Elements」層面。我們習慣於在類屬性前加上 @property()@state(),在需要 DOM 時寫下 @query(),一旦遇到時序問題,便本能地套上一層 setTimeoutawait this.updateComplete

然而,「能把 API 跑通」與「理解它在 V8 引擎、微任務隊列與 DOM 渲染管線中的精確納秒級時序」是普通開發者與架構師之間的分水嶺。

本文將從一個極具爭議的「時序探針實驗」切入,逐層拆解 Lit 3 異步調度核心,並深度剖析 3 個被嚴重低估但極具工程價值的冷門裝飾器:@queryAsync@queryAssignedElements 以及 @provide / @consume


1. 基準真相:Lit 3 的異步更新循環與微任務多輪次陷阱

為了看清 Lit 的運行時本質,我們先執行這段看似簡單的時序測試代碼:

import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement('app-root')
export class AppRoot extends LitElement {
    @property({ type: Number })
    public count: number = 0;

    render() {
        console.log(`[Render Stage] 執行 render(), 當前 count: ${this.count}`);
        return html`
            <span class="value">${this.count}</span>
            <button @click=${this.runTest}>Run Test</button>
        `;
    }

    runTest() {
        // 1. 同步連續三次觸發 setter
        this.count++;
        this.count++;
        this.count++;

        // 2. 同步讀取 DOM
        const domValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
        console.log(`1. 同步代碼讀取 DOM 值: ${domValue}`);

        // 3. 原生微任務
        queueMicrotask(() => {
            const microDomValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
            console.log(`2. queueMicrotask 微任務讀取 DOM 值: ${microDomValue}`);
        });

        // 4. Promise 微任務
        Promise.resolve().then(() => {
            const promiseDomValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
            console.log(`3. Promise 微任務讀取 DOM 值: ${promiseDomValue}`);
        });

        // 5. Lit 官方生命週期完成信號
        this.updateComplete.then(() => {
            const updateCompleteDomValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
            console.log(`4. updateComplete 讀取 DOM 值: ${updateCompleteDomValue}`);
        });

        // 6. 瀏覽器渲染幀回調
        requestAnimationFrame(() => {
            const rAFDomValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
            console.log(`5. rAF 讀取 DOM 值: ${rAFDomValue}`);
        });

        // 7. 宏任務
        setTimeout(() => {
            const setTimeoutDomValue = this.shadowRoot?.querySelector(`span.value`)?.textContent;
            console.log(`6. setTimeout 宏任務讀取 DOM 值: ${setTimeoutDomValue}`);
        }, 0);
    }
}

點擊按鈕後的真實 Console 輸出:

1. 同步代碼讀取 DOM 值: 0
2. queueMicrotask 微任務讀取 DOM 值: 0
[Render Stage] 執行 render(), 當前 count: 3
3. Promise 微任務讀取 DOM 值: 3
4. updateComplete 讀取 DOM 值: 3
5. rAF 讀取 DOM 值: 3
6. setTimeout 宏任務讀取 DOM 值: 3

深度剖析:為什麼 queueMicrotask 拿到的依然是 0

「既然 Lit 是透過微任務進行異步批處理更新,那我手動推入微任務隊列的 queueMicrotask 應該能拿到渲染後的 3。」

事實證明:queueMicrotask 拿到了舊值 0,而緊隨其後的 Promise.then 卻拿到了 3

這揭示了 V8 引擎中 「微任務多輪次調度(Multi-tick Microtask Loop)」 的底層細節。

[ JavaScript Call Stack (同步任務) ]
│  this.count++ (三次變更) ──> 觸發 ReactiveElement._enqueueUpdate()
│  queueMicrotask(...)   ──> 註冊 Microtask A (用戶隊列)
│  Promise.resolve()     ──> 註冊 Microtask B (用戶隊列)

════════════════════════ Microtask Queue: Tick 1 ════════════════════════
├─▶ [Lit 內部 Microtask] 執行 _enqueueUpdate()
│    └─ 遇到內部的 `await this._updatePromise`
│    └─ 暫停執行!將後續渲染邏輯「再次排隊」至 Microtask Tick 2

├─▶ [用戶 Microtask A: queueMicrotask] 執行!
│    └─ 此時 Lit 尚未調用 render(),DOM 尚未 Commit!讀取結果:0
════════════════════════ Microtask Queue: Tick 2 ════════════════════════
├─▶ [Lit 內部恢復] performUpdate() ──> render() ──> DOM Commit 完成 (Count 變為 3)
│    └─ 觸發 this.updateComplete.resolve()

├─▶ [用戶 Microtask B: Promise.then] 執行!
│    └─ 讀取結果:3
├─▶ [Lit updateComplete.then] 執行!
│    └─ 讀取結果:3
════════════════════════ Rendering Pipeline (幀刷新前夕) ════════════════════
└─▶ [requestAnimationFrame] 瀏覽器即將計算 Layout/Paint ──> 讀取結果:3

源碼級歸因:

Lit 在 @lit/reactive-element 內部的調度器是一個 async 函數(_enqueueUpdate)。當它執行到 await this._updatePromise 時,JavaScript 引擎會讓出當前執行權,將剩餘代碼丟入下一輪微任務(Tick 2)。

而我在同步代碼中直接調用的 queueMicrotask 排在 Tick 1,此時 Lit 根本還沒進入 performUpdate()

結論: 絕對不要試圖用原生 queueMicrotask 來預判 Lit 的渲染完成時機。Lit 的更新管線是多輪微任務交織的狀態機,只有lit提供的 this.updateComplete 才是微任務隊列真正清空、DOM 物理提交的唯一可信信號。


2. @queryAsync:終結微任務競態與父子瀑布陷阱

理解了微任務的多輪次特性後,就能明白為什麼標準的 @query 在條件渲染或異步 DOM 中頻繁返回 null

@queryAsync(selector) 的本質,是在屬性 Getter 中封裝了一條微任務等待鏈:

// Lit @queryAsync 底層偽代碼實現
function queryAsync(selector: string) {
  return (proto: any, name: PropertyKey) => {
    Object.defineProperty(proto, name, {
      async get() {
        // 核心:主動等待當前組件的所有微任務更新隊列清空
        await this.updateComplete;
        return this.renderRoot?.querySelector(selector) ?? null;
      },
      enumerable: true,
      configurable: true,
    });
  };
}

致命陷阱:父子組件 Microtask 瀑布

考慮以下場景:父組件動態渲染一個子組件 <complex-chart>,並試圖調用子組件的內部方法:

// 危險寫法:對象存在,但內部尚未 Ready
@customElement('dashboard-view')
export class DashboardView extends LitElement {
  @queryAsync('complex-chart')
  private chart!: Promise<ComplexChart | null>;

  async initChart() {
    const chartElement = await this.chart;
    // 崩潰!此時 chartElement 節點雖然已掛載,
    // 但子組件自身的 updateComplete 還在後續微任務隊列中!
    // 其內部的 canvas 節點可能依舊為 null。
    chartElement?.drawGraph();
  }

  render() {
    return html`<complex-chart .data=${this.data}></complex-chart>`;
  }
}
// 正確實踐:雙重微任務守衛
@customElement('dashboard-view')
export class DashboardView extends LitElement {
  @queryAsync('complex-chart')
  private chartAsync!: Promise<ComplexChart | null>;

  async initChartSafe() {
    const chart = await this.chartAsync;
    if (chart) {
      // 關鍵:顯式等待子組件自身的微任務渲染完成
      await chart.updateComplete;
      chart.drawGraph(); // 100% 時序安全
    }
  }
}

3. @queryAssignedElements:打破 Light DOM 投影的「靜態幻覺」

在 Web Components 架構中,<slot> 負責組裝 Light DOM 與 Shadow DOM。許多開發者誤以為 @queryAssignedElements 是一個響應式裝飾器。

真相是:@queryAssignedElements 是一個純粹的同步惰性 Getter(Lazy Getter),它本身不包含任何響應式訂閱。

@customElement('custom-list')
export class CustomList extends LitElement {
  // 僅在調用 getter 時,底層執行 slot.assignedElements(options)
  @queryAssignedElements({ slot: 'items', flatten: true })
  private listItems!: Array<HTMLElement>;

  connectedCallback() {
    super.connectedCallback();
    // 陷阱:輸出 0!
    // 此時瀏覽器 DOM 解析器尚未分發(Slotting)子節點
    console.log(this.listItems.length);
  }
}

如何構建真正的響應式 Slot 監控?

由於裝飾器本身不觸發 requestUpdate,必須配合 @slotchange 事件構建時序閉環:

import { LitElement, html } from 'lit';
import { customElement, queryAssignedElements } from 'lit/decorators.js';

@customElement('custom-tabs')
export class CustomTabs extends LitElement {
  // 強類型約束 Light DOM 投影的元素類型
  @queryAssignedElements({ slot: 'tab-panel', flatten: true })
  private panels!: Array<HTMLElement>;

  private handleSlotChange() {
    // 當外部動態增刪 Light DOM 時,手動同步狀態
    console.log(`檢測到 Light DOM 投影變更,當前 Panel 數量: ${this.panels.length}`);
    this.requestUpdate(); // 觸發響應式重繪
  }

  render() {
    // 建立 DOM Mutation 到 Lit 響應式系統的連接點
    return html`
      <div class="tabs-header">...</div>
      <slot name="tab-panel" @slotchange=${this.handleSlotChange}></slot>
    `;
  }
}

性能警示(flatten: true 的代價): 當開啟 flatten: true 時,若存在多層嵌套組件的 Slot 轉發,瀏覽器會遞歸遍歷整棵分發樹(Distribution Tree)。在包含上千節點的虛擬滾動或大型樹狀組件中,應盡量避免高頻觸發帶有 flatten: true 的查詢。


4. @provide & @consume:逆向掛載時序與 Context 協議握手

在跨層級傳遞數據時,許多開發者在 Web Components 中盲目引入 Redux 或全局 EventBus,這破壞了組件的自包含封裝性。

Lit 官方基於 W3C Community Context Protocol 推出了 @lit/context,提供了 @provide@consume 裝飾器。

很多架構師困惑的是:DOM 樹在掛載時,子節點的 connectedCallback 比父節點更早執行,為什麼 @consume 能抓到父組件的數據?

[ DOM 樹掛載逆向時序 (Bottom-Up) ]
1. <child-consumer> 解析完成 ──> 觸發 connectedCallback()

   ├─▶ @consume 同步分發 CustomEvent('context-request', { bubbles: true, composed: true })

2. 事件沿 DOM 樹向上冒泡 (Synchronous Event Dispatch)

3. <parent-provider> 捕獲事件 (即使父組件尚未執行 connectedCallback)

   ├─▶ Provider 調用事件攜帶的 callback(currentValue, unsubscribeFn)

4. <child-consumer> 同步接收到值,完成依賴注入握手!

實戰:強類型 Context 架構

// context-key.ts (定義全局唯一的類型安全 Key)
import { createContext } from '@lit/context';

export interface UserSession {
  id: string;
  name: string;
  roles: string[];
}

// 嚴格利用 Symbol 和 TS 泛型收窄 Context 類型
export const userContext = createContext<UserSession>(Symbol('user-session'));
// app-provider.ts (父級狀態提供者)
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { provide } from '@lit/context';
import { userContext, UserSession } from './context-key.js';

@customElement('app-provider')
export class AppProvider extends LitElement {
  @provide({ context: userContext })
  @property({ attribute: false })
  public user: UserSession = { id: '001', name: 'Alice', roles: ['admin'] };

  render() {
    return html`<slot></slot>`;
  }
}
// user-avatar.ts (深層子組件)
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { consume } from '@lit/context';
import { userContext, UserSession } from './context-key.js';

@customElement('user-avatar')
export class UserAvatar extends LitElement {
  // subscribe: true 保證當 Provider 的 user 變更時,Consumer 自動觸發 requestUpdate
  @consume({ context: userContext, subscribe: true })
  private userData?: UserSession;

  render() {
    if (!this.userData) {
      return html`<span>Loading...</span>`;
    }
    return html`<div>User: ${this.userData.name} (${this.userData.roles.join(',')})</div>`;
  }
}

5. Lit 3 裝飾器運行時確定性矩陣

裝飾器 介入時機 執行環境 / 機制 是否觸發更新? 核心風險與時序邊界
@property / @state 屬性賦值(Setter) 調度 _enqueueUpdate 入隊 Microtask (異步批處理) 連續同步修改會被合併,不可依賴同步 DOM
@queryAsync 訪問屬性(Getter) 返回等待 updateComplete 的 Promise 父子組件瀑布:需額外 await child.updateComplete
@queryAssignedElements 訪問屬性(Getter) 同步調用 slot.assignedElements() 無響應性:必須配合 @slotchange 事件監聽
@provide / @consume connectedCallback DOM context-request 同步冒泡握手 subscribe: true 需妥善管理 disconnectedCallback 註銷防洩漏

總結

  • 牢記 Setter 同步調度 -> 微任務多輪次推進 -> DOM 提交 -> Compositor 合成 的四階管線。
  • 遇到異步 DOM,摒棄脆弱的 setTimeout,利用 @queryAsync 與 updateComplete 狀態機構建防禦性時序鏈。
  • 遇到 Slot 投影與跨組件通信,採用 @queryAssignedElements + @slotchange 與 @lit/context 協議,回歸宣告式元編程。

把時序的控制權從「瀏覽器運氣」收回至「代碼確定性」,這正是高階 TypeScript 架構設計的魅力所在。