【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的機制,我們需要先理清幾個概念:

  1. Shadow DOM: 是一種依附於文檔原有節點的子 DOM,具有封裝性。
  2. Light DOM: 指原生的DOM節點,可以通過常規的API訪問。Light DOM和Shadow DOM常常一起出現。這也是很有意思的一個比喻。一明一暗,燈下有影子。
  3. Shadow Trees:Shadow DOM的樹形結構。一般地,在Shadow Trees的節點不能直接被外部JavaScript的API和選擇器訪問到,但是瀏覽器會對這些節點做渲染。
  4. Shadow Host:Shadow DOM所依附的DOM節點。
  5. Shadow Root:Shadow Trees的根節點。外部JavaScript如果希望對Shadow Dom進行訪問,通常會藉助Shadow Root。
  6. Shadow Boundary:Shadow Tree的邊界,是JavaScript訪問、CSS選擇器訪問的分界點。
  7. content:指原本存在於Light DOM 結構中,被標籤添加到影子 DOM 中的節點。自Chrome 53以後,content標籤被棄用,轉而使用template和slot標籤。
  8. distributed nodes:指原本位於Light DOM,但被content或template+slot添加到Shadow DOM 中的節點。
  9. template:一致標籤。類似我們經常用的<script type='tpl'>,它不會被解析為dom樹的一部分,template的內容可以被塞入到Shadow DOM中並且反覆利用,在template中可以設置style,但只對這個template中的元素有效。
  10. 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:

  1. DOM 的封裝性:在不同的 Shadow Trees中無法選擇另外 Shadow Tree 中的元素,只有獲取對應的 Shadow Tree 才能對其中的元素進行操作。
  2. 樣式的封裝性:原則上,在Shadow Boundary外的樣式,無法影響Shadow DOM的樣式;而對於Shadow Tree內部的樣式,可以由自身的style標籤或樣式指定;不同的Shadow Tree元素樣式之間,也不會相互影響。對於需要影響的、以Shadow Boundary分離的樣式,需要由特殊的方案顯示指定,如::host選擇器,:host-context()選擇器、::content()選擇器等等。
  3. 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里感受一下。
  4. 多個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方法,將組件引入過來。之後,就可以在程式碼中使用了。

組件生命周期大致經過以下幾個階段:

  1. constructor 會在元素創建後而尚未被附加到文檔上之前被調用。我們用 constructor 來設置初始狀態、事件監聽以及 shadow DOM。
  2. connectCallback 會在元素被添加到 DOM 中後被調用。此時非常適合執行初始化程式碼,比如獲取數據或是設置默認屬性。
  3. disconnectedCallback() 會在元素從 DOM 中被移除後調用。可以利用 disconnectedCallback 來移除事件監聽器或取消定時循環事件。
  4. attributeChangedCallback 會在元素的受監控的屬性變動時被調用。

兼容性

目前Shadow dom有兩個主流的標準,V0和V1,V0已經被廢棄,當前的版本為V1。以下是當前(2019年10月)的主流瀏覽器支援情況:

小結

本文介紹了Shadow DOM的標準內容。這裡或多或少的涉及到了WebComponents標準的其他內容,我們會在後面的文章,詳細介紹其他相關標準的內容。在翻閱Shadow DOM歷史資料的過程中,發現很多標準中定義的方法發生了變化甚至廢棄,建議大家以官方最新的標準4為準。

參考資料

  1. https://meowni.ca/posts/part-theme-explainer/
  2. https://www.html5rocks.com/zh/tutorials/webcomponents/shadowdom-201/
  3. https://drafts.csswg.org/css-shadow-parts/
  4. https://www.cnblogs.com/yangguoe/p/8486046.html
  5. https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements

文內鏈接

  1. https://clair-design.github.io/component/slider
  2. https://github.com/w3c/webcomponents
  3. https://jsbin.com/kiqatolede/1/edit?html,console,output
  4. https://dom.spec.whatwg.org/#shadow-trees