自訂元素

網頁功能: 自主自訂元素

自訂元素是透過 customElements.define() 註冊至標記名稱的類別。註冊和基本生命週期很容易理解,以下規則針對的是經常實作錯誤的部分。

生命週期:實作錯誤的部分

回呼本身有完善的文件說明,以下是導致錯誤的誤解:

  • connectedCallback 可執行多次。每次插入元素時都會觸發,因此移動元素會重新執行。讓設定具有冪等性,不要假設只會執行一次。
  • connectedMoveCallback 會在移動時略過中斷連線/重新連線配對。使用 Node.moveBefore() 重新定位元素時,系統會觸發這個回呼,而非 disconnectedCallback → connectedCallback,因此狀態、焦點和 iframe/媒體播放會在移動後保留。如果設定或拆除作業耗費大量資源,或明顯重新執行,請定義這個方法。
  • 在 disconnectedCallback 中拆解。移除全域事件監聽器、計時器和觀察器,否則會發生洩漏;但元素可能會重新插入,因此請勿將拆除視為永久。
  • 請先在建構函式中呼叫 super(),且不要依賴完整的 DOM。在輕觸 this 後呼叫 super() 會擲回 ReferenceError。屬性通常會存在 (但並非一律如此);長長的子項清單可能尚未完成剖析。使用 connectedCallback 進行一次性初始化、使用 slotchange 監控直接子項,以及使用 attributeChangedCallback 監控屬性。connectedCallback 仍可能在所有子項剖析完畢前觸發,因此請勿忽略後續的 slotchange/attributeChangedCallback 呼叫。
  • attributeChangedCallback 只會針對 observedAttributes 觸發,且對於不影響輸出的屬性,應保持低成本。
  • 生命週期掛鉤和 observedAttributes 會在註冊時建立快照。customElements.define() 會讀取兩者一次;在呼叫「之後」,在類別上新增掛鉤或擴充 observedAttributes 會遭到忽略,且不會顯示任何訊息。具體來說,除非 observedAttributes 在 define() 時間已存在,否則 attributeChangedCallback 絕不會觸發。

升級時間和:defined

元素可以在其類別註冊「之前」存在於 DOM 中 (在伺服器轉譯的 HTML 中,或指令碼載入延遲時)。在定義之前,它是惰性的 HTMLElement,不含任何行為;當 customElements.define() 執行時,瀏覽器會升級文件中已有的每個相符元素,執行其建構函式,然後執行 connectedCallback。

模型通常會忽略兩種後果:

  1. 當建構函式/connectedCallback 在升級時執行,屬性和子項可能已經存在。讀取 connectedCallback 中的現有屬性,同步初始狀態,且一律不假設元素一開始是空的。
  2. 即使在 connectedCallback 中,子項仍可能遺失,因為 HTML 剖析器可能會在剖析長子項清單時產生,因此回呼有時會在每個子項附加之前執行。請參閱上方「生命週期」中的建構函式項目,瞭解模式 (slotchange + attributeChangedCallback)。

隱藏未定義的元素,避免未套用樣式的內容閃爍,但失敗時仍會開啟,因此如果指令碼未載入,內容仍可存取。使用延遲為 opacity 加上動畫,而不是使用 visibility: hidden 進行硬隱藏,這樣即使元素從未升級,也會在逾時後顯示:

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

請將這些防護措施限定在需要防護的元素,或透過 :has(:not(:defined)) 限定在特定子樹,而非整個 body。一個緩慢、非關鍵的小工具 (例如分享按鈕) 不應延遲整個網頁的繪製。

屬性 ⇄ 屬性反映

請讓 HTML 屬性和 JS 屬性保持同步,這樣元素就能同時用於標記和指令碼。屬性值一律為字串,請強制轉型。

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

價值所在

上述範例將 DOM 保留為可靠資料來源,每個 getter 都會讀取 getAttribute。對於少數簡單的字串屬性,這是正確的預設值:狀態只能存在於一個位置,因此不會取消同步。

如要使用更豐富的元件,請將可靠資料來源保留在 JS 欄位中,並僅在其他項目 (CSS 指定目標或讀取該屬性的消費者) 必須查看時,才向外反映至屬性。讀取欄位比每次存取時剖析和強制轉型屬性便宜得多,而且非字串值絕不會透過序列化來回傳遞。這就是為什麼 Lit 等程式庫會在 JS 中保留狀態,並選擇性地反映,而不是將屬性視為儲存空間:只反映外部世界必須觀察的內容,而不是每個屬性。

陷阱:

  • observedAttributes 規範:僅列出變更時必須重新算繪或觸發副作用的屬性。觀察所有項目會為每個變動新增回呼,但沒有任何好處。
  • 反射迴圈:寫入屬性的 setter 會重新進入 attributeChangedCallback。如上所述,使用 oldVal !== newVal 檢查進行防護。
  • 僅限樣式狀態:對於僅用於驅動樣式 (載入、啟用、展開) 的內部狀態,建議使用自訂狀態,而非反映合成屬性,請參閱下方的「自訂狀態」。只有在消費者必須能從標記設定狀態時,才反映至屬性。

自訂狀態

自訂狀態是元素公開自身的布林值旗標,供 CSS 指定,透過 ElementInternals 設定,並與 :state() 虛擬類別比對。請將這些狀態用於內部執行階段 UI 狀態 (busy、expanded、invalid),而不是反映合成屬性:這些狀態不會與消費者的屬性衝突、絕不會出現在 DOM 中,且從外部只能讀取 (消費者可以設定 :state() 的樣式,但無法偽造)。

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet 是一組集合,因此獨立狀態會組合 (busy 加上 error),而不會建立合併的 data-status 列舉。

自訂狀態僅限執行階段。它不會序列化至 DOM,因此在重新載入、重新建立元素或伺服器轉譯時,不會保留。如果必須在標記中以宣告方式設定狀態,或在載入期間保留狀態,則應使用屬性 (請參閱「值所在位置」),並在 connectedCallback 中從可靠資料來源還原狀態。經驗法則:自訂狀態用於元件在執行階段衍生和擁有的內容,且只需要樣式;屬性用於必須從標記或序列化設定的內容。

如要設定這些狀態的樣式 (包括元件內部和消費者樣式表),請參閱「設定網頁元件樣式」。

命名慣例

  • True-private 狀態:原生 # 欄位 (#count) 用於硬封裝;只有在子類別仍須存取時,才使用 _ 前置字元。請注意,# 欄位不會沿用,子類別無法查看基礎類別的私人欄位。
  • 符號鍵屬性是折衷方案:一般列舉會隱藏這類屬性,但任何含有符號的程式碼都能存取 (匯出符號,或將符號公開為類別的 static 欄位)。由於每個符號都是不重複的,因此兩個符號共用說明時不會發生衝突,當子類別或同層需要 # 欄位無法提供的受控存取權時,這項功能就非常實用。
  • 請勿使用自己的屬性遮蓋全域屬性 (style、class、id、slot、part、title、lang、dir),否則會破壞其內建行為。請注意,disabled 僅適用於表單控制項,其他元素則必須自行實作效果。

為新的 API 介面命名時,請遵循平台本身的慣例:自訂元素標記名稱使用小寫 kebab case,並至少包含一個連字號 (例如 my-toggle)、屬性和方法使用 lowerCamelCase,以及小寫屬性名稱與對應屬性同步。