Javascript Shadow DOM 插槽,組成

2023-02-17 10:58 更新

許多類型的組件,例如標(biāo)簽、菜單、照片庫等等,需要內(nèi)容去渲染。

就像瀏覽器內(nèi)建的 <select> 需要 <option> 子項(xiàng),我們的 <custom-tabs> 可能需要實(shí)際的標(biāo)簽內(nèi)容來起作用。并且一個(gè) <custom-menu> 可能需要菜單子項(xiàng)。

使用了 <custom-menu> 的代碼如下所示:

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

……之后,我們的組件應(yīng)該正確地渲染成具有給定標(biāo)題和項(xiàng)目、處理菜單事件等的漂亮菜單。

如何實(shí)現(xiàn)呢?

我們可以嘗試分析元素內(nèi)容并動(dòng)態(tài)復(fù)制重新排列 DOM 節(jié)點(diǎn)。這是可能的,但是如果我們要將元素移動(dòng)到 Shadow DOM,那么文檔的 CSS 樣式不能在那里應(yīng)用,因此文檔的視覺樣式可能會(huì)丟失。看起來還需要做一些事情。

幸運(yùn)的是我們不需要去做。Shadow DOM 支持 <slot> 元素,由 light DOM 中的內(nèi)容自動(dòng)填充。

具名插槽

讓我們通過一個(gè)簡(jiǎn)單的例子看下插槽是如何工作的。

在這里 <user-card> shadow DOM 提供兩個(gè)插槽, 從 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"> 定義了一個(gè)“插入點(diǎn)”,一個(gè)帶有 slot="X" 的元素被渲染的地方。

然后瀏覽器執(zhí)行”組合“:它從 light DOM 中獲取元素并且渲染到 shadow DOM 中的對(duì)應(yīng)插槽中。最后,正是我們想要的 —— 一個(gè)能被填充數(shù)據(jù)的通用組件。

這是編譯后,不考慮組合的 DOM 結(jié)構(gòu):

<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>

我們創(chuàng)建了 shadow DOM,所以它當(dāng)然就存在了,位于 #shadow-root 之下?,F(xiàn)在元素同時(shí)擁有 light DOM 和 shadow DOM。

為了渲染 shadow DOM 中的每一個(gè) <slot name="..."> 元素,瀏覽器在 light DOM 中尋找相同名字的 slot="..."。這些元素在插槽內(nèi)被渲染:


結(jié)果被叫做扁平化(flattened)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>

……但是 “flattened” DOM 僅僅被創(chuàng)建用來渲染和事件處理,是“虛擬”的。雖然是渲染出來了,但文檔中的節(jié)點(diǎn)事實(shí)上并沒有到處移動(dòng)!

如果我們調(diào)用 querySelectorAll 那就很容易驗(yàn)證:節(jié)點(diǎn)仍在它們的位置。

// light DOM <span> 節(jié)點(diǎn)位置依然不變,在 `<user-card>` 里
alert( document.querySelectorAll('user-card span').length ); // 2

因此,扁平化 DOM 是通過插入插槽從 shadow DOM 派生出來的。瀏覽器渲染它并且用于樣式繼承、事件傳播。但是 JavaScript 在扁平前仍按原樣看到文檔。

僅頂層子元素可以設(shè)置 slot="…" 特性

slot="..." 屬性僅僅對(duì) shadow host 的直接子代 (在我們的例子中的 <user-card> 元素) 有效。對(duì)于嵌套元素它將被忽略。

例如,這里的第二個(gè) <span> 被忽略了(因?yàn)樗皇?nbsp;<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 里有多個(gè)相同插槽名的元素,那么它們會(huì)被一個(gè)接一個(gè)地添加到插槽中。

例如這樣:

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

給這個(gè)扁平化 DOM 兩個(gè)元素,插入到 <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>

插槽后備內(nèi)容

如果我們?cè)谝粋€(gè) <slot> 內(nèi)部放點(diǎn)什么,它將成為后備內(nèi)容。如果 light DOM 中沒有相應(yīng)填充物的話瀏覽器就展示它。

例如,在這里的 shadow DOM 中,如果 light DOM 中沒有 slot="username" 的話 Anonymous 就被渲染。

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

默認(rèn)插槽:第一個(gè)不具名的插槽

shadow DOM 中第一個(gè)沒有名字的 <slot> 是一個(gè)默認(rèn)插槽。它從 light DOM 中獲取沒有放置在其他位置的所有節(jié)點(diǎn)。

例如,讓我們把默認(rèn)插槽添加到 <user-card>,該位置可以收集有關(guān)用戶的所有未開槽(unslotted)的信息:

<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 內(nèi)容進(jìn)入 “其他信息” 字段集。

元素一個(gè)接一個(gè)的附加到插槽中,因此這兩個(gè)未插入插槽的信息都在默認(rèn)插槽中。

扁平化的 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>About me</legend>
      <slot>
        <div>Hello</div>
        <div>I am John!</div>
      </slot>
    </fieldset>
</user-card>

Menu example

現(xiàn)在讓我們回到在本章開頭提到的 <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>

帶有適當(dāng)插槽的 shadow DOM 模版:

  1. ?<span slot="title">? 進(jìn)入 ?<slot name="title">?。
  2. 模版中有許多 ?<li slot="item">?,但是只有一個(gè) ?<slot name="item">?。因此所有帶有 ?slot="item"? 的元素都一個(gè)接一個(gè)地附加到 ?<slot name="item">? 上,從而形成列表。

扁平化的 DOM 變?yōu)椋?

<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>

可能會(huì)注意到,在有效的 DOM 中,<li> 必須是 <ul> 的直接子代。但這是扁平化的 DOM,它描述了組件的渲染方式,這樣的事情在這里自然發(fā)生。

我們只需要添加一個(gè) click 事件處理程序來打開/關(guān)閉列表,并且 <custom-menu> 準(zhǔn)備好了:

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');
    };
  }
});

完整示例

當(dāng)然我們可以為它添加更多的功能:事件、方法等。

更新插槽

如果外部代碼想動(dòng)態(tài) 添加/移除 菜單項(xiàng)怎么辦?

如果 添加/刪除 了插槽元素,瀏覽器將監(jiān)視插槽并更新渲染。

另外,由于不復(fù)制 light DOM 節(jié)點(diǎn),而是僅在插槽中進(jìn)行渲染,所以內(nèi)部的變化是立即可見的。

因此我們無需執(zhí)行任何操作即可更新渲染。但是如果組件想知道插槽的更改,那么可以用 slotchange 事件。

例如,這里的菜單項(xiàng)在 1 秒后動(dòng)態(tài)插入,而且標(biāo)題在 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>

菜單每次都會(huì)更新渲染而無需我們干預(yù)。

這里有兩個(gè) slotchange 事件:

  1. 在初始化時(shí):
  2. slotchange: title 立即觸發(fā), 因?yàn)閬碜?light DOM 的 slot="title" 進(jìn)入了相應(yīng)的插槽。

  3. 1 秒后:
  4. slotchange: item 觸發(fā), 當(dāng)一個(gè)新的 <li slot="item"> 被添加。

請(qǐng)注意:2 秒后,如果修改了 slot="title" 的內(nèi)容,則不會(huì)發(fā)生 slotchange 事件。因?yàn)闆]有插槽更改。我們修改了 slotted 元素的內(nèi)容,這是另一回事。

如果我們想通過 JavaScript 跟蹤 light DOM 的內(nèi)部修改,也可以使用更通用的機(jī)制: MutationObserver。

插槽 API

最后讓我們來談?wù)勁c插槽相關(guān)的 JavaScript 方法。

正如我們之前所見,JavaScript 會(huì)查看真實(shí)的 DOM,不展開。但是如果 shadow 樹有 {mode: 'open'} ,那么我們可以找出哪個(gè)元素被放進(jìn)一個(gè)插槽,反之亦然,哪個(gè)插槽分配了給這個(gè)元素:

  • ?node.assignedSlot? – 返回 ?node? 分配給的 ?<slot>? 元素。
  • ?slot.assignedNodes({flatten: true/false})? – 分配給插槽的 DOM 節(jié)點(diǎn)。默認(rèn)情況下,?flatten? 選項(xiàng)為 ?false?。如果顯式地設(shè)置為 ?true?,則它將更深入地查看扁平化 DOM ,如果嵌套了組件,則返回嵌套的插槽,如果未分配節(jié)點(diǎn),則返回備用內(nèi)容。
  • ?slot.assignedElements({flatten: true/false})? – 分配給插槽的 DOM 元素(與上面相同,但僅元素節(jié)點(diǎn))。

當(dāng)我們不僅需要顯示已插入內(nèi)容的內(nèi)容,還需要在 JavaScript 中對(duì)其進(jìn)行跟蹤時(shí),這些方法非常有用。

例如,如果 <custom-menu> 組件想知道它所顯示的內(nèi)容,那么它可以跟蹤 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>`;

    // 插槽能被添加/刪除/代替
    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 在 1 秒后更新
setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>

小結(jié)

通常,如果一個(gè)元素含有 shadow DOM,那么其 light DOM 就不會(huì)被展示出來。插槽允許在 shadow DOM 中顯示 light DOM 子元素。

插槽有兩種:

  • 具名插槽:?<slot name="X">...</slot>? – 使用 ?slot="X?" 獲取 light 子元素。
  • 默認(rèn)插槽:第一個(gè)沒有名字的 ?<slot>?(隨后的未命名插槽將被忽略)- 接受不是插槽的 light 子元素。
  • 如果同一插槽中有很多元素 – 它們會(huì)被一個(gè)接一個(gè)地添加。
  • ?<slot>? 元素的內(nèi)容作為備用。如果插槽沒有 light 型的子元素,就會(huì)顯示。

在其插槽內(nèi)渲染插槽元素的過程稱為“組合”。結(jié)果稱為“扁平化 DOM”。

組合不會(huì)真實(shí)的去移動(dòng)節(jié)點(diǎn),從 JavaScript 的視角看 DOM 仍然是相同的。

JavaScript 可以使用以下的方法訪問插槽:

  • ?slot.assignedNodes/Elements()? – 返回插槽內(nèi)的 節(jié)點(diǎn)/元素。
  • ?node.assignedSlot? – 相反的方法,返回一個(gè)節(jié)點(diǎn)的插槽。

如果我們想知道顯示的內(nèi)容,可以使用以下方法跟蹤插槽位的內(nèi)容:

  • ?slotchange? 事件 – 在插槽第一次填充時(shí)觸發(fā),并且在插槽元素的 添加/刪除/替換 操作(而不是其子元素)時(shí)觸發(fā),插槽是 ?event.target? 。
  • 使用 MutationObserver 來深入了解插槽內(nèi)容,并查看其中的更改。

現(xiàn)在,在 shadow DOM 中有來自 light DOM 的元素時(shí),讓我們看看如何正確的設(shè)置樣式?;疽?guī)則是 shadow 元素在內(nèi)部設(shè)置樣式,light 元素在外部設(shè)置樣式,但是有一些例外。

我們將在下一章中看到詳細(xì)內(nèi)容。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)