JavaScript 是如何工做: Shadow DOM 的內部結構+如何編寫獨立的組件!

這是專門探索 JavaScript 及其所構建的組件的系列文章的第 17 篇。html

想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等着你!前端

若是你錯過了前面的章節,能夠在這裏找到它們:node

  1. JavaScript 是如何工做的:引擎,運行時和調用堆棧的概述!
  2. JavaScript 是如何工做的:深刻V8引擎&編寫優化代碼的5個技巧!
  3. JavaScript 是如何工做的:內存管理+如何處理4個常見的內存泄漏!
  4. JavaScript 是如何工做的:事件循環和異步編程的崛起+ 5種使用 async/await 更好地編碼方式!
  5. JavaScript 是如何工做的:深刻探索 websocket 和HTTP/2與SSE +如何選擇正確的路徑!
  6. JavaScript 是如何工做的:與 WebAssembly比較 及其使用場景!
  7. JavaScript 是如何工做的:Web Workers的構建塊+ 5個使用他們的場景!
  8. JavaScript 是如何工做的:Service Worker 的生命週期及使用場景!
  9. JavaScript 是如何工做的:Web 推送通知的機制!
  10. JavaScript是如何工做的:使用 MutationObserver 跟蹤 DOM 的變化!
  11. JavaScript是如何工做的:渲染引擎和優化其性能的技巧!
  12. JavaScript是如何工做的:深刻網絡層 + 如何優化性能和安全!
  13. JavaScript是如何工做的:CSS 和 JS 動畫底層原理及如何優化它們的性能!
  14. JavaScript的如何工做的:解析、抽象語法樹(AST)+ 提高編譯速度5個技巧!
  15. JavaScript是如何工做的:深刻類和繼承內部原理+Babel和 TypeScript 之間轉換!
  16. JavaScript是如何工做的:存儲引擎+如何選擇合適的存儲API!

圖片描述

概述

Web Components 是一套不一樣的技術,容許你建立可重用的定製元素,它們的功能封裝在你的代碼以外,你能夠在 Web 應用中使用它們。git

Web組件由四部分組成:github

  • Shadow DOM(影子DOM)
  • HTML templates(HTML模板)
  • Custom elements(自定義元素)
  • HTML Imports(HTML導入)

在本文中主要講解 Shadow DOM(影子DOM)web

Shadow DOM 這款工具旨在構建基於組件的應用。所以,可爲網絡開發中的常見問題提供解決方案:編程

  • 隔離 DOM:組件的 DOM 是獨立的(例如,document.querySelector() 不會返回組件 shadow DOM 中的節點)。
  • 做用域 CSS:shadow DOM 內部定義的 CSS 在其做用域內。樣式規則不會泄漏,頁面樣式也不會滲入。
  • 組合:爲組件設計一個聲明性、基於標記的 API。
  • 簡化 CSS - 做用域 DOM 意味着您可使用簡單的 CSS 選擇器,更通用的 id/類名稱,而無需擔憂命名衝突。

Shadow DOM

本文假設你已經熟悉 DOM 及其它的 Api 的概念。若是不熟悉,能夠在這裏閱讀關於它的詳細文章—— https://developer.mozilla.org...segmentfault

陰影 DOM 只是一個普通的 DOM,除了兩個區別:數組

  • 建立/使用的方式
  • 與頁面其餘部分有關的行爲方式

    一般,你建立 DOM 節點並將其附加至其餘元素做爲子項。 藉助於 shadow DOM,您能夠建立做用域 DOM 樹,該 DOM 樹附加至該元素上,但與其自身真正的子項分離開來。這一做用域子樹稱爲影子樹。被附着的元素稱爲影子宿主。 您在影子中添加的任何項均將成爲宿主元素的本地項,包括 <style>。 這就是 shadow DOM 實現 CSS 樣式做用域的方式瀏覽器

一般,建立 DOM 節點並將它們做爲子元素追加到另外一個元素中。藉助於 shadow DOM,建立一個做用域 DOM 樹,附該 DOM 樹附加到元素上,但它與實際的子元素是分離的。這個做用域的子樹稱爲 影子樹,被附着的元素稱爲影子宿主。向影子樹添加的任何內容都將成爲宿主元素的本地元素,包括 <style>,這就是 影子DOM 實現 CSS 樣式做用域的方式。

建立 shadow DOM

影子根是附加到「宿主」元素的文檔片斷,元素經過附加影子根來獲取其 shadow DOM。要爲元素建立陰影 DOM,調用 element.attachShadow() :

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

paragraphElement.innerText = 'Shadow DOM';
shadowRoot.appendChild(paragraphElement);

規範定義了元素列表,這些元素沒法託管影子樹,元素之因此在所選之列,其緣由以下:

  • 瀏覽器已爲該元素託管其自身的內部 shadow DOM(<textarea><input>)。
  • 讓元素託管 shadow DOM 毫無心義 (<img>)。

例如,如下方法行不通:

document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.

Light DOM

這是組件用戶寫入的標記。該 DOM 不在組件 shadow DOM 以內,它是元素的實際孩子。假設已經建立了一個名爲<extended-button> 的定製組件,它擴展了原生 HTML 按鈕組件,此時但願在其中添加圖像和一些文本。代碼以下:

<extended-button>
  <!-- the image and span are extended-button's light DOM -->
  <img src="boot.png" slot="image">
  <span>Launch</span>
</extended-button>

「extension -button」 是定義的定製組件,其中的 HTML 稱爲 Light DOM,該組件由用戶本身添加。

這裏的 Shadow DOM 是你建立的組件 extension-button。Shadow DOM是 組件的本地組件,它定義了組件的內部結構、做用域 CSS 和 封裝實現細節。

扁平 DOM 樹

瀏覽器將用戶建立的 Light DOM 分發到 Shadow DOM,並對最終產品進行渲染。扁平樹是最終在 DevTools 中看到的以及頁面上呈渲染的對象。

<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>

模板 (Templates)

若是須要 Web 頁面上重複使用相同的標籤結構時,最好使用某種類型的模板,而不是一遍又一遍地重複相同的結構。這在之前也是能夠實現,可是 HTML <template> 元素(在現代瀏覽器中獲得了很好的支持)使它變得容易得多。此元素及其內容不在 DOM 中渲染,但可使用 JavaScript 引用它。

一個簡單的例子:

<template id="my-paragraph">
  <p> Paragraph content. </p>
</template>

這不會出如今頁面中,直到使用 JavaScrip t引用它,而後使用以下方式將其追加到 DOM 中:

var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);

到目前爲止,已經有其餘技術能夠實現相似的行爲,可是,正如前面提到的,將其原生封裝起來是很是好的,Templates 也有至關不錯的瀏覽器支持:

圖片描述

模板自己是有用的,但它們與自定義元素配合會更好。 能夠 customElement Api 能定義一個自定義元素,而且告知 HTML 解析器如何正確地構造一個元素,以及在該元素的屬性變化時執行相應的處理。

讓咱們定義一個 Web 組件名爲 <my-paragraph>,該組件使用以前模板做爲它的 Shadow DOM 的內容:

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 DOM 中,因此能夠在模板中使用 <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>

<slot> 元素

模板有一些缺點,主要是靜態內容,它不容許咱們渲染變量/數據,好可讓咱們按照通常使用的標準 HTML 模板的習慣來編寫代碼。Slot 是組件內部的佔位符,用戶可使用本身的標記來填充。讓咱們看看上面的模板怎麼使用 slot

<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>

能夠插入插槽的元素稱爲 Slotable; 當一個元素插入一個插槽時,它被稱爲開槽 (slotted)。

注意,在上面的例子中,插入了一個 <span> 元素,它是一個開槽元素,它有一個屬性 slot,它等於 my-text,與模板中的 slot 定義中的 name 屬性的值相同。

在瀏覽器中渲染後,上面的代碼將構建如下扁平 DOM 樹:

<my-paragraph>
  #shadow-root
  <p>
    <slot name="my-text">
      <span slot="my-text">Let's have some different text!</span>
    </slot>
  </p>
</my-paragraph>

設定樣式

使用 shadow DOM 的組件可經過主頁來設定樣式,定義其本身的樣式或提供鉤子(以 CSS 自定義屬性的形式)讓用戶替換默認值。

組件定義的樣式

做用域 CSS 是 Shadow DOM 最大的特性之一:

  • 外部頁面的 CSS 選擇器不該用於組件內部
  • 組件內定義的樣式不會影響頁面的其餘元素,它們的做用域是宿主元素

shadow DOM 內部使用的 CSS 選擇器在本地應用於組件實際上,這意味着咱們能夠再次使用公共vid/類名,而不用擔憂頁面上其餘地方的衝突,最佳作法是在 Shadow DOM 內使用更簡單的 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的本地樣式。使用<link>元素在#shadow-root中引入樣式表,這些樣式表也都屬於本地的。

:host 僞類選擇器

使用 :host 僞類選擇器,用來選擇組件宿主元素中的元素 (相對於組件模板內部的元素)。

<style>
  :host {
    display: block; /* by default, custom elements are display: inline */
  }
</style>

當涉及到 :host 選擇器時,應該當心一件事:父頁面中的規則具備比元素中定義的 :host 規則具備更高的優先級,這容許用戶從外部覆蓋頂級樣式。並且 :host 只在影子根目錄下工做,因此你不能在Shadow DOM 以外使用它。

若是 :host(<selector>) 的函數形式與 <selector> 匹配,你能夠指定宿主,對於你的組件而言,這是一個很好的方法,它可以讓你基於宿主將對用戶互動或狀態的反應行爲進行封裝,或對內部節點進行樣式設定:

<style>
  :host {
    opacity: 0.4;
  }
  
  :host(:hover) {
    opacity: 1;
  }
  
  :host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
  }
  
  :host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
  }
</style>

:host-context(<selector>)

:host-context(<selector>) 或其任意父級與 <selector> 匹配,它將與組件匹配。 例如,在文檔的元素上可能有一個用於表示樣式主題 (theme) 的 CSS 類,而咱們應當基於它來決定組件的樣式。
好比,不少人都經過將類應用到 <html> 或 <body> 進行主題化:

<body class="lightheme">
  <custom-container>
  …
  </custom-container>
</body>

在下面的例子中,只有當某個祖先元素有 CSS 類theme-light時,咱們纔會把background-color樣式應用到組件內部的全部元素中:

:host-context(.theme-light) h2 {
  background-color: #eef;
}

/deep/

組件樣式一般只會做用於組件自身的 HTML 上,咱們可使用 /deep/ 選擇器,來強制一個樣式對各級子組件的視圖也生效,它不但做用於組件的子視圖,也會做用於組件的內容。

在下面例子中,咱們以全部的元素爲目標,從宿主元素到當前元素再到 DOM 中的全部子元素:

:host /deep/ h3 {
  font-style: italic;
}

/deep/ 選擇器還有一個別名 >>>,能夠任意交替使用它們。

/deep/>>> 選擇器只能被用在 仿真 (emulated)模式下。 這種方式是默認值,也是用得最多的方式。

從外部爲組件設定樣式

有幾種方法可從外部爲組件設定樣式:最簡單的方法是使用標記名稱做爲選擇器,以下

custom-container {
  color: red;
}

外部樣式比在 Shadow DOM 中定義的樣式具備更高的優先級。

例如,若是用戶編寫選擇器:

custom-container {
  width: 500px;
}

它將覆蓋組件的樣式:

:host {
  width: 300px;
}

對組件自己進行樣式化只能到此爲止。可是若是人想要對組件的內部進行樣式化,會發生什麼狀況呢?爲此,咱們須要 CSS 自定義屬性。

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

若是組件的開發者經過 CSS 自定義屬性提供樣式鉤子,則用戶可調整內部樣式。其思想相似於<slot>,但適用於樣式。

看看下面的例子:

<!-- main page -->
<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;
}

在本例中,該組件將使用 black 做爲背景值,由於用戶指定了該值,不然,背景顏色將採用默認值 #CECECE

做爲組件的做者,是有責任讓開發人員瞭解他們可使用的 CSS 定製屬性,將其視爲組件的公共接口的一部分。

在 JS 中使用 slot

Shadow DOM API 提供了使用 slot 和分佈式節點的實用程序,這些實用程序在編寫自定義元素時早晚派得上用場。

slotchange 事件

slot 的分佈式節點發生變化時,slotchange 事件將觸發。例如,若是用戶從 light DOM 中添加/刪除子元素。

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

要監視對 light DOM 的其餘類型的更改,能夠在元素的構造函數中使用 MutationObserver。之前討論過 MutationObserver 的內部結構以及如何使用它

assignedNodes() 方法

有時候,瞭解哪些元素與 slot 相關聯很是有用。調用 slot.assignedNodes() 可查看 slot 正在渲染哪些元素。 {flatten: true} 選項將返回 slot 的備用內容(前提是沒有分佈任何節點)。

讓咱們看看下面的例子:

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

假設這是在一個名爲 <my-container> 的組件中。

看看這個組件的不一樣用法,以及調用 assignedNodes() 的結果是什麼:

在第一種狀況下,咱們將向 slot 中添加咱們本身的內容:

<my-container>
  <span slot="slot1"> container text </span>
</my-container>

調用 assignedNodes() 會獲得 [<span slot= " slot1 " > container text </span>],注意,結果是一個節點數組。

在第二種狀況下,將內容置空:

<my-container> </my-container>

調用 assignedNodes() 的結果將返回一個空數組 []

在第三種狀況下,調用 slot.assignedNodes({flatten: true}),獲得結果是: [<p>默認內容</p>]

此外,要訪問 slot 中的元素,能夠調用 assignedNodes() 來查看元素分配給哪一個組件 slot

事件模型

值得注意的是,當發生在 Shadow DOM 中的事件冒泡時,會發生什麼。

當事件從 Shadow DOM 中觸發時,其目標將會調整爲維持 Shadow DOM 提供的封裝。也就是說,事件的目標從新進行了設定,所以這些事件看起來像是來自組件,而不是來自 Shadow DOM 中的內部元素。

下面是從 Shadow DOM 傳播出去的事件列表(有些沒有):

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

自定義事件

默認狀況下,自定義事件不會傳播到 Shadow DOM 以外。若是但願分派自定義事件並使其傳播,則須要添加 bubbles: truecomposed: true 選項。

讓咱們看看派發這樣的事件是什麼樣的:

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

瀏覽器支持

如但願得到 shadow DOM 檢測功能,請查看是否存在 attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

圖片描述

有史以來第一次,咱們擁有了實施適當 CSS 做用域、DOM 做用域的 API 原語,而且有真正意義上的組合。 與自定義元素等其餘網絡組件 API 組合後,shadow DOM 提供了一種編寫真正封裝組件的方法,無需花多大的功夫或使用如 <iframe> 等陳舊的東西。

代碼部署後可能存在的BUG無法實時知道,過後爲了解決這些BUG,花了大量的時間進行log 調試,這邊順便給你們推薦一個好用的BUG監控工具 Fundebug

你的點贊是我持續分享好東西的動力,歡迎點贊!

歡迎加入前端你們庭,裏面會常常分享一些技術資源。

clipboard.png

相關文章
相關標籤/搜索