摘要: 深入JS系列17。
Fundebug經授權轉載,版權歸原作者所有。
這是專門探索 JavaScript 及其所構建的元件的系列文章的第 17 篇。
如果你錯過了前面的章節,可以在這裡找到它們:
- JavaScript 是如何工作的:引擎,執行時和呼叫堆疊的概述!
- JavaScript 是如何工作的:深入V8引擎&編寫優化程式碼的5個技巧!
- JavaScript 是如何工作的:記憶體管理+如何處理4個常見的記憶體洩漏!
- JavaScript 是如何工作的:事件迴圈和非同步程式設計的崛起+ 5種使用 async/await 更好地編碼方式!
- JavaScript 是如何工作的:深入探索 websocket 和HTTP/2與SSE +如何選擇正確的路徑!
- JavaScript 是如何工作的:與 WebAssembly比較 及其使用場景!
- JavaScript 是如何工作的:Web Workers的構建塊+ 5個使用他們的場景!
- JavaScript 是如何工作的:Service Worker 的生命週期及使用場景!
- JavaScript 是如何工作的:Web 推送通知的機制!
- JavaScript是如何工作的:使用 MutationObserver 跟蹤 DOM 的變化!
- JavaScript是如何工作的:渲染引擎和優化其效能的技巧!
- JavaScript是如何工作的:深入網路層 + 如何優化效能和安全!
- JavaScript是如何工作的:CSS 和 JS 動畫底層原理及如何優化它們的效能!
- JavaScript的如何工作的:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧!
- JavaScript是如何工作的:深入類和繼承內部原理+Babel和 TypeScript 之間轉換!
- JavaScript是如何工作的:儲存引擎+如何選擇合適的儲存API!
概述
Web Components 是一套不同的技術,允許你建立可重用的定製元素,它們的功能封裝在你的程式碼之外,你可以在 Web 應用中使用它們。
Web元件由四部分組成:
- Shadow DOM(影子DOM)
- HTML templates(HTML模板)
- Custom elements(自定義元素)
- HTML Imports(HTML匯入)
在本文中主要講解 Shadow DOM(影子DOM)
Shadow DOM 這款工具旨在構建基於元件的應用。因此,可為網路開發中的常見問題提供解決方案:
- 隔離 DOM:元件的 DOM 是獨立的(例如,document.querySelector() 不會返回元件 shadow DOM 中的節點)。
- 作用域 CSS:shadow DOM 內部定義的 CSS 在其作用域內。樣式規則不會洩漏,頁面樣式也不會滲入。
- 組合:為元件設計一個宣告性、基於標記的 API。
- 簡化 CSS - 作用域 DOM 意味著您可以使用簡單的 CSS 選擇器,更通用的 id/類名稱,而無需擔心命名衝突。
Shadow DOM
本文假設你已經熟悉 DOM 及其它的 Api 的概念。如果不熟悉,可以在這裡閱讀關於它的詳細文章—— developer.mozilla.org...。
陰影 DOM 只是一個普通的 DOM,除了兩個區別:
-
建立/使用的方式
-
與頁面其他部分有關的行為方式
通常,你建立 DOM 節點並將其附加至其他元素作為子項。 藉助於 shadow DOM,您可以建立作用域 DOM 樹,該 DOM 樹附加至該元素上,但與其自身真正的子項分離開來。這一作用域子樹稱為影子樹。被附著的元素稱為影子宿主。 您在影子中新增的任何項均將成為宿主元素的本地項,包括
<style>
。 這就是 shadow DOM 實現 CSS 樣式作用域的方式
通常,建立 DOM 節點並將它們作為子元素追加到另一個元素中。藉助於 shadow DOM,建立一個作用域 DOM 樹,附該 DOM 樹附加到元素上,但它與實際的子元素是分離的。這個作用域的子樹稱為 影子樹,被附著的元素稱為影子宿主。向影子樹新增的任何內容都將成為宿主元素的本地元素,包括 <style>
,這就是 影子DOM 實現 CSS 樣式作用域的方式。
建立 shadow DOM
影子根是附加到“宿主”元素的文件片段,元素通過附加影子根來獲取其 shadow DOM。要為元素建立陰影 DOM,呼叫 element.attachShadow()
:
var header = document.createElement('header');
var shadowRoot = header.attachShadow({mode: 'open'});
var paragraphElement = document.createElement('p');
paragraphElement.innerText = 'Shadow DOM';
shadowRoot.appendChild(paragraphElement);
複製程式碼
規範定義了元素列表,這些元素無法託管影子樹,元素之所以在所選之列,其原因如下:
- 瀏覽器已為該元素託管其自身的內部 shadow DOM(
<textarea>
、<input>
)。 - 讓元素託管 shadow DOM 毫無意義 (
<img>
)。
例如,以下方法行不通:
document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.
複製程式碼
Light DOM
這是元件使用者寫入的標記。該 DOM 不在元件 shadow DOM 之內,它是元素的實際孩子。假設已經建立了一個名為<extended-button>
的定製元件,它擴充套件了原生 HTML 按鈕元件,此時希望在其中新增影象和一些文字。程式碼如下:
<extended-button>
<!-- the image and span are extended-button's light DOM -->
<img src="boot.png" slot="image">
<span>Launch</span>
</extended-button>
複製程式碼
“extension -button” 是定義的定製元件,其中的 HTML 稱為 Light DOM,該元件由使用者自己新增。
這裡的 Shadow DOM 是你建立的元件 extension-button
。Shadow DOM是 元件的本地元件,它定義了元件的內部結構、作用域 CSS 和 封裝實現細節。
扁平 DOM 樹
瀏覽器將使用者建立的 Light DOM 分發到 Shadow DOM,並對最終產品進行渲染。扁平樹是最終在 DevTools 中看到的以及頁面上呈渲染的物件。
<extended-button>
#shadow-root
<style>…</style>
<slot name="image">
<img src="boot.png" slot="image">
</slot>
<span id="container">
<slot>
<span>Launch</span>
</slot>
</span>
</extended-button>
複製程式碼
模板 (Templates)
如果需要 Web 頁面上重複使用相同的標籤結構時,最好使用某種型別的模板,而不是一遍又一遍地重複相同的結構。這在以前也是可以實現,但是 HTML <template>
元素(在現代瀏覽器中得到了很好的支援)使它變得容易得多。此元素及其內容不在 DOM 中渲染,但可以使用 JavaScript 引用它。
一個簡單的例子:
<template id="my-paragraph">
<p> Paragraph content. </p>
</template>
複製程式碼
這不會出現在頁面中,直到使用 JavaScrip t引用它,然後使用如下方式將其追加到 DOM 中:
var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);
複製程式碼
到目前為止,已經有其他技術可以實現類似的行為,但是,正如前面提到的,將其原生封裝起來是非常好的,Templates 也有相當不錯的瀏覽器支援:
模板本身是有用的,但它們與自定義元素配合會更好。 可以 customElement
Api 能定義一個自定義元素,並且告知 HTML 解析器如何正確地構造一個元素,以及在該元素的屬性變化時執行相應的處理。
讓我們定義一個 Web 元件名為 <my-paragraph>
,該元件使用之前模板作為它的 Shadow DOM 的內容:
customElements.define('my-paragraph',
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(templateContent.cloneNode(true));
}
});
複製程式碼
這裡需要注意的關鍵點是,我們向影子根新增了模板內容的克隆,影子根是使用 Node.cloneNode() 方法建立的。
因為將其內容追加到一個 Shadow DOM 中,所以可以在模板中使用 元素的形式包含一些樣式資訊,然後將其封裝在自定義元素中。如果只是將其追加到標準 DOM 中,它是無法工作。
例如,可以將模板更改為:
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>Paragraph content. </p>
</template>
複製程式碼
現在自定義元件可以這樣使用:
<my-paragraph></my-paragraph>
複製程式碼
元素
模板有一些缺點,主要是靜態內容,它不允許我們渲染變數/資料,好可以讓我們按照一般使用的標準 HTML 模板的習慣來編寫程式碼。Slot 是元件內部的佔位符,使用者可以使用自己的標記來填充。讓我們看看上面的模板怎麼使用 slot
:
<template id="my-paragraph">
<p>
<slot name="my-text">Default text</slot>
</p>
</template>
複製程式碼
如果在標記中包含元素時沒有定義插槽的內容,或者瀏覽器不支援插槽,<my-paragraph>
就只展示文字 “Default text”。
為了定義插槽的內容,應該在 <my-paragraph>
元素中包含一個 HTML 結構,其中的 slot 屬性的值為我們定義插槽的名稱:
<my-paragraph>
<span slot="my-text">Let's have some different text!</span>
</my-paragraph>
複製程式碼
可以插入插槽的元素稱為 Slotable; 當一個元素插入一個插槽時,它被稱為開槽 (slotted)。
注意,在上面的例子中,插入了一個 <span>
元素,它是一個開槽元素,它有一個屬性 slot
,它等於 my-text
,與模板中的 slot
定義中的 name
屬性的值相同。
在瀏覽器中渲染後,上面的程式碼將構建以下扁平 DOM 樹:
<my-paragraph>
#shadow-root
<p>
<slot name="my-text">
<span slot="my-text">Let's have some different text!</span>
</slot>
</p>
</my-paragraph>
複製程式碼
設定樣式
使用 shadow DOM 的元件可通過主頁來設定樣式,定義其自己的樣式或提供鉤子(以 CSS 自定義屬性的形式)讓使用者替換預設值。
程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
元件定義的樣式
作用域 CSS 是 Shadow DOM 最大的特性之一:
- 外部頁面的 CSS 選擇器不應用於元件內部
- 元件內定義的樣式不會影響頁面的其他元素,它們的作用域是宿主元素
shadow DOM 內部使用的 CSS 選擇器在本地應用於元件實際上,這意味著我們可以再次使用公共vid/類名,而不用擔心頁面上其他地方的衝突,最佳做法是在 Shadow DOM 內使用更簡單的 CSS 選擇器,它們在效能上也不錯。
看看在 #shadow-root 定義了一些樣式的:
#shadow-root
<style>
#container {
background: white;
}
#container-items {
display: inline-flex;
}
</style>
<div id="container"></div>
<div id="container-items"></div>
複製程式碼
上面例子中的所有樣式都是#shadow-root的本地樣式。使用元素在#shadow-root中引入樣式表,這些樣式表也都屬於本地的。
:host 偽類選擇器
使用 :host
偽類選擇器,用來選擇元件宿主元素中的元素 (相對於元件模板內部的元素)。
<style>
:host {
display: block; /* by default, custom elements are display: inline */
}
</style>
複製程式碼
當涉及到 :host
選擇器時,應該小心一件事:父頁面中的規則具有比元素中定義的 :host
規則具有更高的優先順序,這允許使用者從外部覆蓋頂級樣式。而且 :host
只在影子根目錄下工作,所以你不能在Shadow DOM 之外使用它。
如果 :host(<selector>)
的函式形式與 <selector>
匹配,你可以指定宿主,對於你的元件而言,這是一個很好的方法,它可讓你基於宿主將對使用者互動或狀態的反應行為進行封裝,或對內部節點進行樣式設定:
<style>
:host {
opacity: 0.4;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.pink) > #tabs {
color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>
複製程式碼
:host-context()
:host-context(<selector>)
或其任意父級與 匹配,它將與元件匹配。 例如,在文件的元素上可能有一個用於表示樣式主題 (theme) 的 CSS 類,而我們應當基於它來決定元件的樣式。
比如,很多人都通過將類應用到 或 進行主題化:
<body class="lightheme">
<custom-container>
…
</custom-container>
</body>
複製程式碼
在下面的例子中,只有當某個祖先元素有 CSS 類theme-light時,我們才會把background-color樣式應用到元件內部的所有元素中:
:host-context(.theme-light) h2 {
background-color: #eef;
}
複製程式碼
/deep/
元件樣式通常只會作用於元件自身的 HTML 上,我們可以使用 /deep/
選擇器,來強制一個樣式對各級子元件的檢視也生效,它不但作用於元件的子檢視,也會作用於元件的內容。
在下面例子中,我們以所有的元素為目標,從宿主元素到當前元素再到 DOM 中的所有子元素:
:host /deep/ h3 {
font-style: italic;
}
複製程式碼
/deep/
選擇器還有一個別名 >>>
,可以任意交替使用它們。
/deep/
和>>>
選擇器只能被用在**模擬 (emulated)**模式下。 這種方式是預設值,也是用得最多的方式。
從外部為元件設定樣式
有幾種方法可從外部為元件設定樣式:最簡單的方法是使用標記名稱作為選擇器,如下
custom-container {
color: red;
}
複製程式碼
外部樣式比在 Shadow DOM 中定義的樣式具有更高的優先順序。
例如,如果使用者編寫選擇器:
custom-container {
width: 500px;
}
複製程式碼
它將覆蓋元件的樣式:
:host {
width: 300px;
}
複製程式碼
對元件本身進行樣式化只能到此為止。但是如果人想要對元件的內部進行樣式化,會發生什麼情況呢?為此,我們需要 CSS 自定義屬性。
使用 CSS 自定義屬性建立樣式鉤子
如果元件的開發者通過 CSS 自定義屬性提供樣式鉤子,則使用者可調整內部樣式。其思想類似於<slot>
,但適用於樣式。
看看下面的例子:
<!-- main page -->
<style>
custom-container {
margin-bottom: 60px;
- custom-container-bg: black;
}
</style>
<custom-container background>…</custom-container>
複製程式碼
在其 shadow DOM 內部:
:host([background]) {
background: var( - custom-container-bg, #CECECE);
border-radius: 10px;
padding: 10px;
}
複製程式碼
在本例中,該元件將使用 black 作為背景值,因為使用者指定了該值,否則,背景顏色將採用預設值 #CECECE
。
作為元件的作者,是有責任讓開發人員瞭解他們可以使用的 CSS 定製屬性,將其視為元件的公共介面的一部分。
在 JS 中使用 slot
Shadow DOM API 提供了使用 slot 和分散式節點的實用程式,這些實用程式在編寫自定義元素時遲早派得上用場。
slotchange 事件
當 slot
的分散式節點發生變化時,slotchange
事件將觸發。例如,如果使用者從 light DOM 中新增/刪除子元素。
var slot = this.shadowRoot.querySelector('#some_slot');
slot.addEventListener('slotchange', function(e) {
console.log('Light DOM change');
});
複製程式碼
要監視對 light DOM 的其他型別的更改,可以在元素的建構函式中使用 MutationObserver
。以前討論過 MutationObserver 的內部結構以及如何使用它。
assignedNodes() 方法
有時候,瞭解哪些元素與 slot 相關聯非常有用。呼叫 slot.assignedNodes()
可檢視 slot 正在渲染哪些元素。 {flatten: true}
選項將返回 slot 的備用內容(前提是沒有分佈任何節點)。
讓我們看看下面的例子:
<slot name=’slot1’><p>Default content</p></slot>
複製程式碼
假設這是在一個名為 <my-container>
的元件中。
看看這個元件的不同用法,以及呼叫 assignedNodes()
的結果是什麼:
在第一種情況下,我們將向 slot
中新增我們自己的內容:
<my-container>
<span slot="slot1"> container text </span>
</my-container>
複製程式碼
呼叫 assignedNodes()
會得到 [<span slot= " slot1 " > container text </span>]
,注意,結果是一個節點陣列。
在第二種情況下,將內容置空:
<my-container> </my-container>
複製程式碼
呼叫 assignedNodes()
的結果將返回一個空陣列 []
。
在第三種情況下,呼叫 slot.assignedNodes({flatten: true})
,得到結果是: [<p>預設內容</p>]
。
此外,要訪問 slot
中的元素,可以呼叫 assignedNodes()
來檢視元素分配給哪個元件 slot
。
事件模型
值得注意的是,當發生在 Shadow DOM 中的事件冒泡時,會發生什麼。
當事件從 Shadow DOM 中觸發時,其目標將會調整為維持 Shadow DOM 提供的封裝。也就是說,事件的目標重新進行了設定,因此這些事件看起來像是來自元件,而不是來自 Shadow DOM 中的內部元素。
下面是從 Shadow DOM 傳播出去的事件列表(有些沒有):
- **聚焦事件:**blur、focus、focusin、focusout
- **滑鼠事件:**click、dblclick、mousedown、mouseenter、mousemove,等等
- **滾輪事件:**wheel
- **輸入事件:**beforeinput、input
- **鍵盤事件:**keydown、keyup
- **組合事件:**compositionstart、compositionupdate、compositionend
- **拖放事件:**dragstart、drag、dragend、drop,等等
自定義事件
預設情況下,自定義事件不會傳播到 Shadow DOM 之外。如果希望分派自定義事件並使其傳播,則需要新增 bubbles: true
和 composed: true
選項。
讓我們看看派發這樣的事件是什麼樣的:
var container = this.shadowRoot.querySelector('#container');
container.dispatchEvent(new Event('containerchanged', {bubbles: true, composed: true}));
複製程式碼
瀏覽器支援
如希望獲得 shadow DOM 檢測功能,請檢視是否存在 attachShadow:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
複製程式碼
有史以來第一次,我們擁有了實施適當 CSS 作用域、DOM 作用域的 API 原語,並且有真正意義上的組合。 與自定義元素等其他網路元件 API 組合後,shadow DOM 提供了一種編寫真正封裝元件的方法,無需花多大的功夫或使用如 <iframe>
等陳舊的東西。
關於Fundebug
Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用!