Shadow DOM 內部構造及如何構建獨立元件

tristan發表於2019-03-01

原文請查閱這裡,略有刪減,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland

這是 JavaScript 工作原理的第十七章。

Shadow DOM 內部構造及如何構建獨立元件

概述

網頁元件指的是允許開發者使用一系列不同的技術來建立可複用的自定義元素,元件內的功能不影響其它程式碼,以便於開發者在網頁程式中使用。

有四種網頁元件標準:

  • Shadow DOM
  • HTML 模板
  • 自定義元素
  • HTML Imports

本章主要討論 Shadow DOM

Shadow DOM 是一個被設計用來構建基於元件(積木式)的網頁程式的工具。它為開發者可能經常遇到過的問題提供瞭解決方案:

  • 隔離的 DOM:元件的 DOM 是獨立的(比如 document.querySelector() 無法檢索到元件 shadow DOM 下的f元素節點)。這樣就可以簡化網頁程式中的 CSS 選擇器,因為 DOM 元件是互不影響,這樣就允許開發者可以隨心所欲地使用更加通用的 id/class 命名而不用擔心命名衝突。
  • 區域性樣式: shadow DOM 內定義的樣式不會汙染 shadow DOM 之外的元素。Style 樣式規則不會洩漏且頁面樣式也不會汙染 shadow DOM 內的元素樣式。
  • 組合:為開發者的元件設計一個宣告式,基於標籤的介面。

Shadow DOM

本篇文章假設開發者已經對 DOM 及其 API 熟拈於心。否則,可以閱讀一下這方面的詳細資料

與一般的 DOM 元素相比,Shadow DOM 有兩處不同的地方:

  • 與一般建立和使用 DOM 的方式相比,開發者如何建立及使用 Shadow DOM 及其與頁面上的其它元素的關係
  • 其展現形式與頁面上的其它元素的關係

一般情況下,開發者建立 DOM 節點,然後將其作為子元素掛載到其它元素下。對於 shadow DOM,開發者建立一個獨立 DOM 樹掛載到目標元素下而該樹和其實際子元素是分離的。該獨立子樹稱為 shadow 樹。shadow 樹的掛載元素稱為 shadow 宿主。包括 <style> 在內的所有在 shadow 樹下建立的任何標籤都只作用於宿主元素內部。此即 shadow DOM 如何實現 CSS 區域性樣式化的原理。

建立 Shadow DOM

一個 shadow 根 即是一段掛載到 "宿主" 元素下的文件碎片。掛載了 shadow 根即表示宿主元素包含 shadow DOM。呼叫 element.attachShadow() 方法來為元素建立 shadow DOM:

var header = document.createElement('header');
var shadowRoot = header.attachShadow({mode: 'open'});
var paragraphElement = document.createElement('p');

paragraphElement.innerText = 'Shadow DOM';
shadowRoot.appendChild(paragraphElement);
複製程式碼

規範定義了不能夠建立 shadow 樹的元素列表。

Shadow DOM 組合功能

組合元素是 Shadow DOM 最重要的功能之一。

當書寫 HTML 的時候,組合元素構建網頁程式。開發者組合及巢狀諸如 <div><header><form> 及其它不同的構建模組來構建網頁程式所需的介面。其中某些標籤甚至可以互相相容。

元素組合定義了諸如為何 <select><form><video> 及其它元素是可擴充套件的且接受特定的 HTML 元素作為子元素以便用來對這些元素進行特殊處理。

比如,<select> 元素知道如何把 <option> 元素渲染成為帶有預定義選項的下拉框元件。

Shadow DOM 引入如下功能,可以用來組合元素。

Light DOM

此即元件的書寫標記。該 DOM 存在於元件的 shadow DOM 之外。它是元素的實際子元素。假設開發者建立了一個名為 <better-button> 的自定義元件,擴充套件原生 button 標籤及想在元件內部新增一個圖片和一些文字。大概如下:

<extended-button>
  <!-- image 和 span 即為擴充套件 button 的 light DOM -->
  <img src="boot.png" slot="image">
  <span>Launch</span>
</extended-button>
複製程式碼

「擴充套件 button」即開發者自定義元件,而其中的 HTML 即為 Light DOM 且是使用元件的使用者所新增的。

這裡的 Shadow DOM 即開發者建立的元件(「擴充套件 button」)。Shadow DOM 僅存在於元件內部且在其中定義其內部結構,區域性樣式及封裝了元件實現詳情。

扁平 DOM 樹

瀏覽器分發 light DOM 的結果即,由使用者在 Shadow DOM 內部建立的 HTML 內容,這些 HTML 內容構成了自定義元件的結構,渲染出最後的產品介面。扁平樹即開發者在開發者工具中看到的內容和頁面的渲染結果。

<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>
複製程式碼

模板

當開發者不得不在網頁上覆用相同的標記結構的時候,最好使用某種模板而不是重複書寫相同的頁面結構。以前是可以實現的,但是現在可以使用 <template> (現代瀏覽器均相容)元素輕易地實現該功能。該元素及其內容不會在 DOM 中渲染,但是可以使用 JavaScript 來引用其中的內容。

來看一個簡單示例:

<template id="my-paragraph">
  <p> Paragraph content. </p>
</template>
複製程式碼

上面的內容不會在頁面中渲染,除非使用 JavaScript 來引用其中的內容,然後使用類似如下的程式碼來掛載到 DOM 中:

var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);
複製程式碼

迄今為止,可以使用其它技術來實現類似的功能,但是正如之前所提到的,儘量使用原生功能來實現可能會更酷些。另外,相容性也蠻好。

Shadow DOM 內部構造及如何構建獨立元件

本身模板就很好用,但是若和自定義元素配合使用會更好哦。我們將會另外的文章中介紹自定義元素,當下開發者只需瞭解 customElement 介面允許開發者自定義標籤內容的渲染。

讓我們定義一個使用模板作為其 shadow DOM 渲染內容的網頁元件。且稱其為 <my-paragraph>:

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 根下。

另外,由於把模板的內容掛載到 shadow DOM 中,開發者可以在模板中使用 <style> 元素包含一些樣式資訊,該 <style> 元素隨後會被封裝進自定義元素裡面。如果直接把模板掛載到標準 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> 就派上用場了。

可以把插槽看成是允許開發者在模板中放置自定義 HTML 的佔位符的功能。這樣開發者就可以建立能用的 HTML 模板並且通過引入插槽來自定義渲染內容。

讓我們看一下以上模板新增一個插槽的程式碼如下:

<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>
複製程式碼

所有可以被插入插槽的元素被稱為可插入元素;已插入插槽元素稱為插槽元素。

注意以上示例中插入的 <span> 元素即是插槽元素。它擁有一個 slot 屬性,屬性值和模板中插槽定義的 name 屬性值相等。

瀏覽器渲染之後,以上程式碼會建立如下扁平 DOM 樹:

<my-paragraph>
  #shadow-root
  <p>
    <slot name="my-text">
      Default text
    </slot>
  </p>
  <span slot="my-text">Let's have some different text!</span>
</my-paragraph>
複製程式碼

這裡原文有誤,有改動。

注意 #shadow-root 元素只是表示存在 Shadow DOM 而已。

樣式化

可以在主頁面樣式化含有 shadow DOM 的元件,可以定義元件樣式或者提供 CSS 自定義屬性的形式讓使用者覆蓋掉預設樣式值。

元件定義的樣式

區域性樣式 是 Shadow DOM 極好的功能之一:

  • 主頁面上的 CSS 選擇器不會影響到元件內部元素的樣式。
  • 元件內部定義的樣式不會影響頁面上的其它元素樣式。它們只作用於宿主元素。

Shadow DOM 中的 CSS 選擇器隻影響元件內部的元素。實際上,這意味著開發者可以重複使用通用的 id/class 名稱而不用擔心和主頁面上的其它樣式發生衝突。簡單的 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 裡面使用 元素來引入樣式表,也只作用於 #shadow-root 內部。

:host 偽類

:host 偽類允許開發者選擇和樣式化包含 shadow 樹的宿主元素:

<style>
  :host {
    display: block; /* 預設情況下, 自定義元素是內聯元素 */
  }
</style>
複製程式碼

只有一個地方需要注意即若主頁面上定義的宿主元素樣式優先順序比元素裡面定義的 :host 樣式規則要高。這樣就允許開發者從外部覆蓋掉元件內部定義的頂級樣式。

即當在主頁面上定義瞭如下的樣式:

my-paragraph {
  marbin-bottom: 40px;
}

<template id="my-paragraph">
	<style>
		:host {
      margin-bottom: 30px;/* 將不起作用,因為會被前面父頁面已定義的樣式覆蓋 */
		}
	</style>
  <p> 
    <slot name="my-text">Default text</slot> 
  </p>
</template>
複製程式碼

同理,:host 只在shadow 根的上下文中起作用,因此開發者不能夠在 Shadow DOM 外面使用。

:host(<selector>) 這樣的功能樣式允許開發者只樣式化匹配 <selector> 的宿主元素。這是一個絕佳的方式,開發者可以在元件內部封裝響應使用者互動或者狀態的行為,然後基於宿主元素來樣式化內部節點。

<style>
  :host {
    opacity: 0.4;
  }
  
  :host(:hover) {
    opacity: 1;
  }
  
  :host([disabled]) { /* 宿主元素擁有 disabled 屬性的樣式. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
  }
  
  :host(.pink) > #tabs {
    color: pink; /* 當宿主元素含有 pink 類時的選項卡樣式. */
  }
</style>
複製程式碼

使用 :host-context() 偽類來定製化元素樣式

:host-context(<selector>) 偽類找出宿主元素或者宿主元素任意的祖先元素匹配 <selector>

常用於定製化。例如,開發者通過為 <html> 或者 <body> 新增類來進行定製化:

<body class="lightheme">
  <custom-container>
  …
  </custom-container>
</body>
複製程式碼

或者

<custom-container class="lightheme">
  …
</custom-container>
複製程式碼

當宿主元素的祖先元素包含有 .lightheme 類 :host-context(.lightheme) 將會樣式化 <fancy-tabs>

:host-context(.lightheme) {
  color: black;
  background: white;
}
複製程式碼

可以使用 :host-context() 來進行定製化主題樣式,但是更好的方法即通過 CSS 自定義屬性來建立樣式鉤子。

從外部樣式化元件宿主元素

開發者可以從外部通過把標籤名作為選擇器來樣式化元件宿主元素,如下:

custom-container {
  color: red;
}
複製程式碼

外部樣式比 Shadow DOM 中定義的樣式擁有更高的優先順序。

例如,假設使用者書寫如下選擇器:

custom-container {
  width: 500px;
}
複製程式碼

將會覆蓋如下元件樣式規則 :

:host {
  width: 300px;
}
複製程式碼

元件自身樣式化只能做到這麼多。但如果想要樣式化元件內部屬性呢?這就需要 CSS 自定義屬性。

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

若元件作者使用 CSS 自定義屬性提供樣式鉤子,使用者可以用來更改內部樣式。

這和 <slot> 思路類似只是應用到了樣式。

讓我們看如下示例:

<!-- 主頁面 -->
<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;
}
複製程式碼

該示例中,因為使用者提供了該背景顏色值,所以元件將會把黑色作為背景顏色值。否則,預設為 #CECECE

作為元件作者,需要讓開發者知道可以使用的 CSS 自定義屬性。可以把自定義屬性看作元件的公共介面。

插槽 JavaScript 介面

Shadow DOM API 可能用來操作插槽。

slotchange 事件

當一個插槽的分發元素節點發生變化的時候觸發 slotchange 事件。例如,當使用者從 light DOM 中新增/刪除子節點。

var slot = this.shadowRoot.querySelector('#some_slot');
slot.addEventListener('slotchange', function(e) {
  console.log('Light DOM change');
});
複製程式碼

可以在元素的建構函式中建立 MutationObserver 來監聽 light DOM 的其它型別的修改事件。前面文章中有介紹過 MutationObserver 的內部構造及使用指南

assignedNodes() 方法

瞭解哪些元素是和插槽有關是很有用處的。呼叫 slot.assignedNodes() 可以找出哪些元素是由插槽渲染的。flatten: true} 選項會返回插槽的預設內容(若沒有分發任何節點)。

看一下如下示例:

<slot name='slot1'><p>Default content</p></slot>

假設以上內容包含在一個叫做 <my-container> 的元件內部。

讓我們檢視一下該元件的不同用法,然後呼叫 assignedNodes() 輸出不同的結果:

第一例中,我們將往插槽中新增內容:

<my-container>
  <span slot="slot1"> container text </span>
</my-container>
複製程式碼

呼叫 assignedNodes() 將會返回 [<span slot="slot1"> container text </span>]。注意結果為一個節點陣列。

第二例中,將不新增內容:

<my-container> </my-container>

呼叫 assignedNodes() 將會返回空陣列 []

但是,假設新增 {flatten: true} 引數將會返回預設內容:[<p>Default content</p>]

同理,為了查詢插槽中的元素,開發者可以呼叫 assignedNodes() 來找出元素被掛載到哪個元件插槽中。

事件模型

Shadow DOM 中的事件冒泡的經過是值得注意的。

事件目標被調整為維護 Shadow DOM 的封閉性。當事件被重新定位,看起來是由元件自身產生而不是元件的 Shadow DOM 內部元素。

這裡有傳播出 Shadow DOM 的事件列表(還有一些只能在 Shadow DOM 內傳播):

  • Focus 事件:blur, focus, focusin, focusout
  • 滑鼠事件:click, dblclick, mousedown, mouseenter, mousemove 等.
  • 滾輪事件: wheel
  • 輸入事件: beforeinput, input
  • 鍵盤事件: keydown, keyup
  • 組合事件: compositionstart, compositionupdate, compositionend
  • 拖拽事件: dragstart, drag, dragend, drop 等.

自定義事件

預設情況下,自定義事件不會傳播出 Shadow DOM。開發者若想要分派自定義事件且想要傳播出 Shadow DOM,需要新增 bubbles: truecomposed: true 選項引數。

讓我們瞧瞧類似這樣的事件分派:

var container = this.shadowRoot.querySelector('#container');
container.dispatchEvent(new Event('containerchanged', {bubbles: true, composed: true}));
複製程式碼

瀏覽器相容情況

可以通過檢查 attachShadow 來檢查是否支援 Shadow DOM 功能:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
複製程式碼

Shadow DOM 內部構造及如何構建獨立元件

參考資料:

  • https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM
  • https://developers.google.com/web/fundamentals/web-components/shadowdom
  • https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots
  • https://www.html5rocks.com/en/tutorials/webcomponents/shadowdom-201/#toc-style-host

招賢納士

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章