Shadow DOM v1 簡介

  • 2019 年 11 月 5 日
  • 筆記

什麼是 Shadow DOM

Shadow DOM 是 Web Components 定義的四大標準之一。Shadow DOM 解決了構建網路應用的脆弱性問題。脆弱性是由 HTML、CSS 和 JS 的全局性引起的。

Shadow DOM 為網路開發中的常見問題提供解決方案:

  • 隔離 DOM:組件的 DOM 是獨立的(例如,document.querySelector() 不會返回組件 Shadow DOM 中的節點)。
  • 作用域 CSS:Shadow DOM 內部定義的 CSS 在其作用域內。樣式規則不會泄漏,頁面樣式也不會滲入。
  • 組合:為組件設計一個聲明性、基於標記的 API。
  • 簡化 CSS: 作用域 DOM 意味著您可以使用簡單的 CSS 選擇器,更通用的 id/class 名稱,而無需擔心命名衝突。
  • 效率:將應用看成是多個 DOM 塊,而不是一個大的(全局性)頁面。

(圖片來源 —— MDN Shadow DOM)

Shadow DOM vs DOM

HTML 因其易於使用的特點驅動著網路的發展。通過聲明幾個標記,即可在幾秒內編寫一個帶有圖文資訊和結構的頁面。 但是,HTML 自身的功能並不強大。 對於我們人類而言,理解基於文本語言很容易,但是機器需要更多幫助才能理解。 因此,文檔對象模型(DOM) 應運而生。

Shadow DOM 與普通 DOM 相同,但有兩點區別:

1) 創建/使用的方式;

2) 與頁面其他部分有關的行為方式。

這裡以 「創建/使用的方式」 為例:

創建 DOM

const header = document.createElement('header');  const h1 = document.createElement('h1');  h1.textContent = 'Hello world!';  header.appendChild(h1);  document.body.appendChild(header);

創建 Shadow DOM

const header = document.createElement('header');  const shadowRoot = header.attachShadow({mode: 'open'});  shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

需要注意的是,並不是所有的元素都可以掛載 Shadow DOM,其主要原因是:

  • 瀏覽器已為該元素託管其自身的內部 shadow DOM(比如 textareainput)。
  • 讓元素託管 shadow DOM 毫無意義 (比如 img)。

所以以下方法是行不通的:

document.createElement('input').attachShadow({mode: 'open'});

另外使用 Shadow DOM 時,有以下的注意事項:

  • Shadow DOM,一旦創建就無法刪除,它只能用新的替換。
  • 要查看瀏覽器如何為 input 或 textarea 等元素實現 shadow DOM,對 Chrome 用戶來說可以按照: DevTools > Settings > Preferences > Elements -> [x] Show user agent shadow DOM路徑啟用對應的選項。

Shadow DOM vs Light DOM

Light DOM

組件用戶編寫的標記,該 DOM 不在組件 shadow DOM 之內,它是元素實際的子項。

<button is="better-button">    <img src="gear.svg" slot="icon">    <span>Settings</span>  </button>

Shadow DOM

該 DOM 是由組件的作者編寫。Shadow DOM 對於組件而言是本地的,它定義內部結構、作用域 CSS 並封裝實現詳情。它還可定義如何渲染由組件使用者編寫的標記。

#shadow-root    <style>...</style>    <slot name="icon"></slot>    <span id="wrapper">      <slot>Button</slot>    </span>

如何創建 Shadow DOM

<div class="dom"></div>    <script>      let el = document.querySelector('.dom');      el.attachShadow({ mode: 'open' });      el.shadowRoot.innerHTML = 'Hi I am shadowed!';        // 創建新的元素      let hello = document.createElement('span');      hello.textContent = 'Hi I am shadowed but wrapped in span';      el.shadowRoot.appendChild(hello);  </script>

什麼是 Shadow Root

ShadowRoot 是 Shadow DOM 的根,從技術上講,它是一個非元素節點,是一種特殊的文檔片段。你可以通過 ShadowRoot 對象上的 appendChildquerySelectorAll 等方法去操作整個 Shadow DOM 樹。

對於一個普通的元素,比如 <div>,你可以通過調用該對象上的 attachShadow 方法來創建一個ShadowRoot,attachShadow 接受一個對象進行初始化,這個對象有一個 mode 屬性,它有兩個取值:'open''closed',這個屬性是在創造 ShadowRoot 的時候需要初始化提供的,並在創建 ShadowRoot 之後成為一個只讀屬性。

那麼 mode: 'open'mode: 'closed' 有什麼區別呢?

let $element = document.createElement("div");  $element.attachShadow({ mode: "open" });  $element.shadowRoot

在調用 attachShadow 創建 ShadowRoot 之後,attachShdow 方法會返回 ShadowRoot 對象實例,你可以通過這個返回的對象去構造整個 Shadow DOM。

當 mode 為 'open' 時,在用於創建 ShadowRoot 的外部普通節點(比如 <div>)上,會有一個 shadowRoot 屬性,這個屬性也就是創造出來的那個 ShadowRoot,也就是說,你可以通過這個屬性獲取 ShadowRoot,進而對它進行操作。

而當 mode 為 'closed' 時,你將不能再得到這個屬性,這個屬性會被設置為 null,比如:

let $element = document.createElement("div");  $element.attachShadow({ mode: "closed" });  $element.shadowRoot // null

一般情況下總是使用 open 模式調用 attachShadow 方法,這樣的話可以讓組件作者和用戶都可以根據需要進行相關操作。

如何設定樣式

Shadow DOM 最有用的功能是作用域 CSS:

  • 外部頁面中的 CSS 選擇器不應用於組件內部。
  • 內部定義的樣式也不會滲出,它們的作用域僅限於宿主元素。

設置宿主元素樣式

<style>  :host {    display: block;    contain: content;  }  </style>

使用 :host 的一個問題是,父頁面中的規則較之在元素中定義的 :host 規則具有更高的特異性。 也就是說,外部樣式優先。這可讓用戶從外部替換你已定義的樣式。 此外,:host 僅在影子根範圍內起作用,因此無法在 shadow DOM 之外使用。

除了 :host 之外,還支援 :host(<selector>) 的函數形式,它可讓你基於宿主將對用戶互動或狀態的反應行為進行封裝,或對內部節點進行樣式設定。

<style>  :host {    opacity: 0.4;    will-change: opacity;    transition: opacity 300ms ease-in-out;  }  :host(:hover) {    opacity: 1;  }  :host([disabled]) {    background: grey;    pointer-events: none;    opacity: 0.4;  }  :host(.blue) {    color: blue;  }  :host(.pink) > #tabs {    color: pink;  </style>

基於情景設定樣式

如果 :host-context(<selector>) 或其任意父級與 <selector> 匹配,它將與組件匹配。 一個常見用途是根據組件的環境進行主題化。 例如,很多人都通過將 class 應用到 <html><body> 進行主題化:

<body class="darktheme">    <fancy-tabs>      ...    </fancy-tabs>  </body>

如果 :host-context(.darktheme).darktheme 的子級,它將對 <fancy-tabs> 進行樣式化:

:host-context(.darktheme) {    color: white;    background: black;  }

為分散式節點設定樣式

比如說我們已創建了一個 name badge 組件:

<name-badge>    <h2>Eric Bidelman</h2>    <span class="title">      Digital Jedi, <span class="company">Google</span>    </span>  </name-badge>

組件的 shadow DOM 可為用戶的 <h2>.title 設定樣式:

<style>  ::slotted(h2) {    margin: 0;    font-weight: 300;    color: red;  }  ::slotted(.title) {     color: orange;  }    /* 以下方式不生效,因為::slotted()只支援頂層元素  ::slotted(.company),  ::slotted(.title .company) {    text-transform: uppercase;  }  */  </style>  <slot></slot>

從外部為組件設定樣式

有幾種方法可從外部為組件設定樣式:最簡單的方法是使用標籤名稱作為選擇器:

fancy-tabs {    width: 500px;    color: red;  }    fancy-tabs:hover {    box-shadow: 0 3px 3px #ccc;  }

外部樣式總是優先於在 shadow DOM 中定義的樣式。例如,如果用戶編寫選擇器 fancy-tabs { width: 500px; },它將優先於組件的規則::host { width: 650px;}

使用 CSS 自定義屬性創建樣式鉤子

如果組件的作者通過 CSS 自定義屬性 提供樣式鉤子,則用戶可調整內部樣式。 從概念上看,這與 <slot> 類似。 你創建 「樣式佔位符」 以便用戶進行替換。

比如 <fancy-tabs> 可讓用戶替換背景顏色:

<!-- main page -->  <style>    fancy-tabs {      margin-bottom: 32px;      --fancy-tabs-bg: black;    }  </style>  <fancy-tabs background>...</fancy-tabs>

在其 Shadow DOM 內部:

:host([background]) {    background: var(--fancy-tabs-bg, #9E9E9E);    border-radius: 10px;    padding: 10px;  }

在本例中,該組件將使用 black 作為背景值,因為用戶指定了該值。 否則,背景顏色將採用默認值 #9E9E9E

瀏覽器支援

(圖片來源 —— https://caniuse.com/#feat=shadowdomv1)

由上圖可知 Chrome 53、Opera 40 和 Safari 10 以上的版本是支援 shadow DOM v1 標準。 Edge 也在考慮中,並具有較高的優先順序

你可以通過以下方法來檢測當前瀏覽器是否支援 shadow DOM v1 標準:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

此外對於還不支援 shadow DOM v1 標準的瀏覽器來說,你也可以引入 shadydomshadycss polyfill 來模擬 v1 的標準。Shady DOM 可以模擬 Shadow DOM 的 DOM 作用域,而 shadycss polyfill 則可以模擬原生 API 提供的 CSS 自定義屬性和樣式作用域。具體的使用方式,感興趣的同學,請參考相應的開發文檔,這裡不再進一步說明。

參考資源

  • 深度介紹:你聽說過原生 HTML 組件嗎?
  • Shadow DOM v1:獨立的網路組件