【Web技術】400- 淺談Shadow DOM
- 2019 年 11 月 5 日
- 筆記
為什麼會有Shadow DOM
你在實際的開發中很可能遇到過這樣的需求:實現一個可以拖拽的滑塊,以實現範圍選擇、音量控制等需求。
除了直接用組件庫,聰明的你肯定已經想到了多種解決辦法。如在數據驅動框架React/Vue/Angular下,你可能會找到或編寫對應的組件,通過相應數據狀態的變更,完成相對複雜的交互;如在小快靈的項目下,用jQuery的Widget也是一個不錯的選擇;再或者,你可以點開你的HTML+JavaScript+CSS技能樹,純手工打造一個。這都是不難完成的任務。
當然,在完成之後,你可能會考慮對組件做一些提煉,下次再遇到同樣的需求,你就可以氣定神閑地「開箱即用」。
這裡1是Clair組件庫對這個需求的封裝。
我們不妨從這個層面再多想一步。其實由於HTML和CSS默認都是全局可見的,因此,尤其是純手工打造的組件,其樣式是很容易受到所在環境的干擾的;由於選擇器在組件層沒有統一的保護手段,也會造成撰寫時候的規則可以被隨意修改;事件的捕獲和冒泡過程會和所在環境密切相關,也可能會引起事件管理的混亂。
根據一般意義上「封裝」的概念,我們希望相對組件來講,DOM和CSS有一定的隱藏性;如非必要,外部的變化對於內部的有一定的隔離;同時,外界可以通過且僅可以通過一些可控的方法來影響內部,反之亦然。
針對這些問題,其實瀏覽器提供了一種名叫Shadow DOM的解決方案。這個方案目前與 Custom Elements、HTML Templates、CSS changes和JSON, CSS, HTML Modules並列為Web Components標準2。
Shadow DOM的概念
我們仍以上面的滑塊作為例子。在最新的Chrome瀏覽器上,你可以輸入如下程式碼來實現上面的功能:
<input type="range" disabled min="20" max="100" defaultValue="30"/>
請打開DevTools中的「show user agent shadow DOM」:

在DevTools的Elements標籤中,我們可以看到這個「組件」的實現細節。

上面的input range,可以看作是瀏覽器內置的一個組件。它是利用Shadow DOM來完成的一個組件。類似的,還有Audio、Video等組件。讀者可以做類似的實驗。
為了搞清Shadow DOM的機制,我們需要先理清幾個概念:
- Shadow DOM: 是一種依附於文檔原有節點的子 DOM,具有封裝性。
- Light DOM: 指原生的DOM節點,可以通過常規的API訪問。Light DOM和Shadow DOM常常一起出現。這也是很有意思的一個比喻。一明一暗,燈下有影子。
- Shadow Trees:Shadow DOM的樹形結構。一般地,在Shadow Trees的節點不能直接被外部JavaScript的API和選擇器訪問到,但是瀏覽器會對這些節點做渲染。
- Shadow Host:Shadow DOM所依附的DOM節點。
- Shadow Root:Shadow Trees的根節點。外部JavaScript如果希望對Shadow Dom進行訪問,通常會藉助Shadow Root。
- Shadow Boundary:Shadow Tree的邊界,是JavaScript訪問、CSS選擇器訪問的分界點。
- content:指原本存在於Light DOM 結構中,被標籤添加到影子 DOM 中的節點。自Chrome 53以後,content標籤被棄用,轉而使用template和slot標籤。
- distributed nodes:指原本位於Light DOM,但被content或template+slot添加到Shadow DOM 中的節點。
- template:一致標籤。類似我們經常用的<script type='tpl'>,它不會被解析為dom樹的一部分,template的內容可以被塞入到Shadow DOM中並且反覆利用,在template中可以設置style,但只對這個template中的元素有效。
- slot:與template合用的標籤,用於在template中預留顯示坑位。如:
<div id="con"> 我是基礎文字 <span slot="main1"> 佔位1 </span> <span slot="main2"> 佔位2 </span> 我還是基礎文字 </div> <template id="tpl"> 我是模版 <slot name="main1"> </slot> <slot name="main2"> </slot> 我還是模版 </template> <script> let host = document.querySelector('#con'); let root = host.attachShadow({mode:'open'}); let con = document.getElementById("tpl").content.cloneNode(true); root.appendChild(con); </script>
下面這幅圖,展示了上述概念的相互關係:

Shadow DOM的特性
了解了Shadow DOM相關的概念,我們來了解一下相關的特性,以便更好地使用Shadow DOM:
- DOM 的封裝性:在不同的 Shadow Trees中無法選擇另外 Shadow Tree 中的元素,只有獲取對應的 Shadow Tree 才能對其中的元素進行操作。
- 樣式的封裝性:原則上,在Shadow Boundary外的樣式,無法影響Shadow DOM的樣式;而對於Shadow Tree內部的樣式,可以由自身的style標籤或樣式指定;不同的Shadow Tree元素樣式之間,也不會相互影響。對於需要影響的、以Shadow Boundary分離的樣式,需要由特殊的方案顯示指定,如::host選擇器,:host-context()選擇器、::content()選擇器等等。
- JavaScript事件捕獲與冒泡:傳統的JavaScript事件捕獲與冒泡,由於Shadow Boundary的存在,與一般的事件模型有一定的差異。 在捕獲階段,當事件發生在Shadow Boundary以上,Shadow Boundary上層可以捕獲事件,而Shadow Boundary下層無法捕獲事件。在冒泡階段,當事件發生在Shadow Boundary以下,Shadow Boundary上層會以Shadow Host作為事件發生的源對象,而Shadow Boundary下層可以獲取到源對象。 事件abort、 error、 select 、change 、load 、reset 、resize 、scroll 、selectstart不會進行重定向而是直接被幹掉。 讀者可以從這個例子3里感受一下。
- 多個Shadow Tree同時共用一個Shadow Host,只會展示最後一個Shadow Tree。
如何使用Shadow DOM
了解了上述基礎知識之後,我們可以試著利用Shadow DOM做些事情了。
1. 創建Shadow DOM
const div = document.createElement('div'); const sr = div.attachShadow({mode: 'open'}); sr.innerHTML = '<h1>Hello Shadow DOM</h1>';
這裡注意下{mode: 'open'},此後通過div.shadowRoot即可拿到sr的實例。sr可以使用一般的JavaScript API來做相關的操作。
如果這裡採用{mode: 'closed'},則此時div.shadowRoot為null。外部不可能再拿到sr的實例。此時外部很難操作到sr下的Shadow DOM,僅可以依靠Shadow內部的元素來進行操作。
2. 在Shadow DOM內部來操作Shadow Host的樣式
:host 允許你選擇並樣式化 Shadow Tree所寄宿的元素
<button class="red">My Button</button> <script> var button = document.querySelector('button'); var root = button.createShadowRoot(); root.innerHTML = '<style>' + ':host { text-transform: uppercase;font-size:30px; }' + '</style>' + '<content></content>'; </script>
3. 跨越Shadow Boundary的樣式::part()
對於::part,在允許樣式的Shadow DOM,給屬性part賦值,樣式選擇器可以使用::part(屬性值)即可實現指定樣式。需要注意的是,在::part()選擇器後,子代選擇器無效。如你不能使用::part(foo) span。
<style> c-e::part(innerspan) { color: red; } </style> <template id="c-e-outer-template"> <c-e-inner exportparts="innerspan: textspan"></c-e-inner> </template> <template id="c-e-inner-template"> <span part="innerspan"> This text will be red because the containing shadow host forwards innerspan to the document as "textspan" and the document style matches it. </span> <span part="textspan"> This text will not be red because textspan in the document style cannot match against the part inside the inner custom element if it is not forwarded. </span> </template> <c-e></c-e> <script> // Add template as custom elements c-e-inner, c-e-outer let host = document.querySelector('c-e'); let root = host.attachShadow({mode:'open'}); let con = document.getElementById("c-e-inner-template").content.cloneNode(true); root.appendChild(con); </script>
::part()選擇器自Chrome73開始支援。之前的版本,可以考慮^和^^選擇器,^和^^選擇Shadow DOM在最新版本已經無效。
4. 定義一個組件
class FlagIcon extends HTMLElement { constructor() { super(); this._countryCode = null; } static get observedAttributes() { return ["country"]; } attributeChangedCallback(name, oldValue, newValue) { // name will always be "country" due to observedAttributes this._countryCode = newValue; this._updateRendering(); } connectedCallback() { this._updateRendering(); } get country() { return this._countryCode; } set country(v) { this.setAttribute("country", v); } disconnectedCallback() { console.log('disconnected!'); } _updateRendering() { // Left as an exercise for the reader. But, you'll probably want to // check this.ownerDocument.defaultView to see if we've been // inserted into a document with a browsing context, and avoid // doing any work if not. } } customElements.define("flag-icon", FlagIcon); const flagIcon = new FlagIcon() flagIcon.country = "zh" document.body.appendChild(flagIcon)自定義的組件,都需繼承自HTMLElement。然後調用customElements.define方法,將組件引入過來。之後,就可以在程式碼中使用了。
組件生命周期大致經過以下幾個階段:
- constructor 會在元素創建後而尚未被附加到文檔上之前被調用。我們用 constructor 來設置初始狀態、事件監聽以及 shadow DOM。
- connectCallback 會在元素被添加到 DOM 中後被調用。此時非常適合執行初始化程式碼,比如獲取數據或是設置默認屬性。
- disconnectedCallback() 會在元素從 DOM 中被移除後調用。可以利用 disconnectedCallback 來移除事件監聽器或取消定時循環事件。
- attributeChangedCallback 會在元素的受監控的屬性變動時被調用。
兼容性
目前Shadow dom有兩個主流的標準,V0和V1,V0已經被廢棄,當前的版本為V1。以下是當前(2019年10月)的主流瀏覽器支援情況:

小結
本文介紹了Shadow DOM的標準內容。這裡或多或少的涉及到了WebComponents標準的其他內容,我們會在後面的文章,詳細介紹其他相關標準的內容。在翻閱Shadow DOM歷史資料的過程中,發現很多標準中定義的方法發生了變化甚至廢棄,建議大家以官方最新的標準4為準。
參考資料
- https://meowni.ca/posts/part-theme-explainer/
- https://www.html5rocks.com/zh/tutorials/webcomponents/shadowdom-201/
- https://drafts.csswg.org/css-shadow-parts/
- https://www.cnblogs.com/yangguoe/p/8486046.html
- https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements
文內鏈接
- https://clair-design.github.io/component/slider
- https://github.com/w3c/webcomponents
- https://jsbin.com/kiqatolede/1/edit?html,console,output
- https://dom.spec.whatwg.org/#shadow-trees