許多類型的元件,例如標籤、選單、圖片庫等,需要內容才能呈現。
就像內建瀏覽器 <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="..."
屬性僅對 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>
<span slot="title">
會進入<slot name="title">
。<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
事件
-
在初始化時
slotchange: title
立即觸發,因為 light DOM 中的slot="title"
進入對應的插槽。 -
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 元素在外部設定樣式,但有一些值得注意的例外。
我們將在下一章節中看到詳細資訊。
留言
<code>
標籤,對於多行 - 將它們包覆在<pre>
標籤中,對於超過 10 行 - 使用沙盒 (plnkr、jsbin、codepen…)