2022 年 9 月 27 日

Shadow DOM 插槽、組合

許多類型的元件,例如標籤、選單、圖片庫等,需要內容才能呈現。

就像內建瀏覽器 <select> 預期 <option> 項目,我們的 <custom-tabs> 可能預期傳遞實際的標籤內容。而 <custom-menu> 可能預期選單項目。

使用 <custom-menu> 的程式碼可能如下所示

<custom-menu>
  <title>Candy menu</title>
  <item>Lollipop</item>
  <item>Fruit Toast</item>
  <item>Cup Cake</item>
</custom-menu>

…然後我們的組件應該正確地呈現它,作為一個具有給定標題和項目、處理選單事件等的漂亮選單。

如何實作它?

我們可以嘗試分析元素內容並動態複製重新排列 DOM 節點。這是有可能的,但如果我們將元素移到 Shadow DOM,則文件中的 CSS 樣式不適用於其中,因此視覺樣式可能會遺失。這也需要一些編碼。

幸運的是,我們不必這麼做。Shadow DOM 支援 <slot> 元素,這些元素會自動由 Light DOM 中的內容填滿。

命名插槽

讓我們看看插槽如何在一個簡單的範例中運作。

在這裡,<user-card> Shadow DOM 提供兩個插槽,由 Light DOM 填滿

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});
</script>

<user-card>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

在 Shadow DOM 中,<slot name="X"> 定義一個「插入點」,一個具有 slot="X" 的元素會被呈現的地方。

然後瀏覽器執行「組合」:它從 Light DOM 中取得元素並將它們呈現到 Shadow DOM 的對應插槽中。最後,我們得到了我們想要的,一個可以填入資料的組件。

以下是腳本後的 DOM 結構,不考慮組合

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username"></slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

我們建立了 Shadow DOM,所以它在這裡,位於 #shadow-root 下方。現在元素同時具有 Light DOM 和 Shadow DOM。

為了呈現目的,對於 Shadow DOM 中的每個 <slot name="...">,瀏覽器會在 Light DOM 中尋找具有相同名稱的 slot="..."。這些元素會在插槽內部呈現

結果稱為「扁平化」DOM

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <!-- slotted element is inserted into the slot -->
        <span slot="username">John Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
</user-card>

…但扁平化 DOM 僅存在於呈現和事件處理目的。它有點像是「虛擬的」。這就是事物顯示的方式。但文件中的節點實際上並沒有被移動!

如果我們執行 querySelectorAll,可以輕鬆地檢查出來:節點仍然在它們的位置。

// light DOM <span> nodes are still at the same place, under `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2

因此,扁平化 DOM 是透過插入插槽從 Shadow DOM 衍生的。瀏覽器會呈現它並用於樣式繼承、事件傳播(稍後會詳細說明)。但 JavaScript 仍然會「照原本的樣子」看到文件,在扁平化之前。

只有頂層子層級可以具有 slot="…" 屬性

slot="..." 屬性僅對 Shadow 主機的直接子層級有效(在我們的範例中,為 <user-card> 元素)。對於巢狀元素,它會被忽略。

例如,這裡的第二個 <span> 會被忽略(因為它不是 <user-card> 的頂層子層級)

<user-card>
  <span slot="username">John Smith</span>
  <div>
    <!-- invalid slot, must be direct child of user-card -->
    <span slot="birthday">01.01.2001</span>
  </div>
</user-card>

如果 Light DOM 中有多個具有相同插槽名稱的元素,它們會一個接一個地附加到插槽中。

例如,這個

<user-card>
  <span slot="username">John</span>
  <span slot="username">Smith</span>
</user-card>

會產生這個扁平化 DOM,其中 <slot name="username"> 中有兩個元素

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <span slot="username">John</span>
        <span slot="username">Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
</user-card>

備用插槽內容

如果我們在 <slot> 中放入一些內容,它就會變成備用「預設」內容。如果 light DOM 中沒有對應的填補內容,瀏覽器就會顯示此內容。

例如,在此段 shadow DOM 中,如果 light DOM 中沒有 slot="username",就會顯示 Anonymous

<div>Name:
  <slot name="username">Anonymous</slot>
</div>

預設插槽:第一個未命名

shadow DOM 中第一個沒有名稱的 <slot> 是「預設」插槽。它會取得 light DOM 中所有未在其他地方插入插槽的節點。

例如,讓我們在 <user-card> 中新增預設插槽,以顯示所有未插入插槽的使用者資訊

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
    <div>Name:
      <slot name="username"></slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
    <fieldset>
      <legend>Other information</legend>
      <slot></slot>
    </fieldset>
    `;
  }
});
</script>

<user-card>
  <div>I like to swim.</div>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
  <div>...And play volleyball too!</div>
</user-card>

所有未插入插槽的 light DOM 內容都會進入「其他資訊」欄位組。

元素會一個接一個地附加到插槽,因此兩段未插入插槽的資訊會一起出現在預設插槽中。

扁平化 DOM 如下所示

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <span slot="username">John Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
    <fieldset>
      <legend>Other information</legend>
      <slot>
        <div>I like to swim.</div>
        <div>...And play volleyball too!</div>
      </slot>
    </fieldset>
</user-card>

選單範例

現在讓我們回到本章節開頭提到的 <custom-menu>

我們可以使用插槽來分配元素。

以下是 <custom-menu> 的標記

<custom-menu>
  <span slot="title">Candy menu</span>
  <li slot="item">Lollipop</li>
  <li slot="item">Fruit Toast</li>
  <li slot="item">Cup Cake</li>
</custom-menu>

包含適當插槽的 shadow DOM 範本

<template id="tmpl">
  <style> /* menu styles */ </style>
  <div class="menu">
    <slot name="title"></slot>
    <ul><slot name="item"></slot></ul>
  </div>
</template>
  1. <span slot="title"> 會進入 <slot name="title">
  2. <custom-menu> 中有很多 <li slot="item">,但範本中只有一個 <slot name="item">。因此,所有這些 <li slot="item"> 會一個接一個地附加到 <slot name="item">,從而形成清單。

扁平化 DOM 變成

<custom-menu>
  #shadow-root
    <style> /* menu styles */ </style>
    <div class="menu">
      <slot name="title">
        <span slot="title">Candy menu</span>
      </slot>
      <ul>
        <slot name="item">
          <li slot="item">Lollipop</li>
          <li slot="item">Fruit Toast</li>
          <li slot="item">Cup Cake</li>
        </slot>
      </ul>
    </div>
</custom-menu>

有人可能會注意到,在有效的 DOM 中,<li> 必須是 <ul> 的直接子元素。但那是扁平化 DOM,它描述元件是如何呈現的,這種情況自然會發生。

我們只需要新增一個 click 處理常式來開啟/關閉清單,<custom-menu> 就完成了

customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});

    // tmpl is the shadow DOM template (above)
    this.shadowRoot.append( tmpl.content.cloneNode(true) );

    // we can't select light DOM nodes, so let's handle clicks on the slot
    this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
      // open/close the menu
      this.shadowRoot.querySelector('.menu').classList.toggle('closed');
    };
  }
});

以下是完整的示範

當然,我們可以為其新增更多功能:事件、方法等。

更新插槽

如果外部程式碼想要動態新增/移除選單項目,該怎麼辦?

瀏覽器會監控插槽,並在新增/移除插入插槽的元素時更新呈現。

此外,由於 light DOM 節點不會被複製,而只是在插槽中呈現,因此它們內部的變更會立即顯示出來。

因此,我們不必執行任何動作來更新呈現。但如果元件程式碼想要知道插槽變更,則可以使用 slotchange 事件。

例如,在此範例中,選單項目會在 1 秒後動態插入,而標題會在 2 秒後變更

<custom-menu id="menu">
  <span slot="title">Candy menu</span>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // shadowRoot can't have event handlers, so using the first child
    this.shadowRoot.firstElementChild.addEventListener('slotchange',
      e => alert("slotchange: " + e.target.name)
    );
  }
});

setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>')
}, 1000);

setTimeout(() => {
  menu.querySelector('[slot="title"]').innerHTML = "New menu";
}, 2000);
</script>

選單呈現會在每次變更時自動更新,而不需要我們介入。

這裡有兩個 slotchange 事件

  1. 在初始化時

    slotchange: title 立即觸發,因為 light DOM 中的 slot="title" 進入對應的插槽。

  2. 1 秒後

    當新的 <li slot="item"> 被加入時,slotchange: item 觸發。

請注意:當 slot="title" 的內容被修改時,2 秒後沒有 slotchange 事件。這是因為沒有插槽變更。我們修改了插槽元素內的內容,那是另一回事。

如果我們想從 JavaScript 追蹤 light DOM 的內部修改,也可以使用更通用的機制:MutationObserver

插槽 API

最後,讓我們提到與插槽相關的 JavaScript 方法。

正如我們之前看到的,JavaScript 查看「真實」DOM,而不展平。但是,如果影子樹有 {mode: 'open'},那麼我們可以找出哪些元素被指定到一個插槽,反之亦然,通過它裡面的元素找到插槽

  • node.assignedSlot – 傳回 node 被指定到的 <slot> 元素。
  • slot.assignedNodes({flatten: true/false}) – 指定到插槽的 DOM 節點。預設 flatten 選項為 false。如果明確設定為 true,那麼它會更深入地查看展平的 DOM,在嵌套元件的情況下傳回嵌套的插槽,如果沒有指定節點,則傳回備用內容。
  • slot.assignedElements({flatten: true/false}) – 指定到插槽的 DOM 元素(與上述相同,但僅限元素節點)。

當我們不只是顯示插槽內容,還想在 JavaScript 中追蹤它時,這些方法很有用。

例如,如果 <custom-menu> 元件想知道它顯示什麼,那麼它可以追蹤 slotchange 並從 slot.assignedElements 取得項目

<custom-menu id="menu">
  <span slot="title">Candy menu</span>
  <li slot="item">Lollipop</li>
  <li slot="item">Fruit Toast</li>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  items = []

  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // triggers when slot content changes
    this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
      let slot = e.target;
      if (slot.name == 'item') {
        this.items = slot.assignedElements().map(elem => elem.textContent);
        alert("Items: " + this.items);
      }
    });
  }
});

// items update after 1 second
setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>

摘要

通常,如果一個元素有影子 DOM,那麼它的 light DOM 就不會顯示。插槽允許在影子 DOM 的指定位置顯示 light DOM 中的元素。

有兩種插槽

  • 命名插槽:<slot name="X">...</slot> – 取得具有 slot="X" 的 light 子項。
  • 預設插槽:第一個沒有名稱的 <slot>(後續的未命名插槽會被忽略)– 取得未插槽的 light 子項。
  • 如果同一個插槽有多個元素,它們會一個接一個附加。
  • <slot> 元素的內容用作備用。如果插槽沒有 light 子項,則會顯示備用內容。

將插槽元素呈現在它們的插槽中的過程稱為「組合」。結果稱為「展平 DOM」。

組合並不會真正移動節點,從 JavaScript 的角度來看,DOM 仍然相同。

JavaScript 可以使用以下方法存取插槽

  • slot.assignedNodes/Elements() – 傳回 slot 內部的節點/元素。
  • node.assignedSlot – 反向屬性,傳回節點的插槽。

如果我們想了解我們正在顯示什麼,我們可以使用以下方式追蹤插槽內容

  • slotchange 事件 - 在插槽第一次被填滿時觸發,以及在插槽元素的任何新增/移除/替換操作上觸發,但不包括其子元素。插槽是 event.target
  • MutationObserver 深入插槽內容,觀察其內部的變更。

現在,由於我們知道如何從 light DOM 中顯示元素到 shadow DOM 中,讓我們看看如何適當地為它們設定樣式。基本規則是 shadow 元素在內部設定樣式,而 light 元素在外部設定樣式,但有一些值得注意的例外。

我們將在下一章節中看到詳細資訊。

教學課程地圖

留言

在留言前先閱讀這段內容…
  • 如果你有建議要改進 - 請 提交 GitHub 問題 或提交 pull request,而不是留言。
  • 如果你無法理解文章中的某些內容 - 請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行 - 將它們包覆在 <pre> 標籤中,對於超過 10 行 - 使用沙盒 (plnkrjsbincodepen…)