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

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

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

概述

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

有四種網頁組件標準:java

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

本章主要討論 Shadow DOMgit

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

  • 隔離的 DOM:組件的 DOM 是獨立的(好比 document.querySelector() 沒法檢索到組件 shadow DOM 下的f元素節點)。這樣就能夠簡化網頁程序中的 CSS 選擇器,由於 DOM 組件是互不影響,這樣就容許開發者能夠爲所欲爲地使用更加通用的 id/class 命名而不用擔憂命名衝突。
  • 局部樣式: shadow DOM 內定義的樣式不會污染 shadow DOM 以外的元素。Style 樣式規則不會泄漏且頁面樣式也不會污染 shadow DOM 內的元素樣式。
  • 組合:爲開發者的組件設計一個聲明式,基於標籤的接口。

Shadow DOM

本篇文章假設開發者已經對 DOM 及其 API 熟拈於心。不然,能夠閱讀一下這方面的詳細資料web

與通常的 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:bash

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);
複製代碼

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

自己模板就很好用,可是若和自定義元素配合使用會更好哦。咱們將會另外的文章中介紹自定義元素,當下開發者只需瞭解 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;
複製代碼

參考資料:

  • 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 地址請查閱這裏

相關文章
相關標籤/搜索