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

Fundebug發表於2019-01-29

摘要: 深入JS系列17。

Fundebug經授權轉載,版權歸原作者所有。

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

如果你錯過了前面的章節,可以在這裡找到它們:

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

概述

Web Components 是一套不同的技術,允許你建立可重用的定製元素,它們的功能封裝在你的程式碼之外,你可以在 Web 應用中使用它們。

Web元件由四部分組成:

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

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

Shadow DOM 這款工具旨在構建基於元件的應用。因此,可為網路開發中的常見問題提供解決方案:

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

Shadow DOM

本文假設你已經熟悉 DOM 及其它的 Api 的概念。如果不熟悉,可以在這裡閱讀關於它的詳細文章—— developer.mozilla.org...

陰影 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 也有相當不錯的瀏覽器支援:

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

模板本身是有用的,但它們與自定義元素配合會更好。 可以 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 中,所以可以在模板中使用 元素的形式包含一些樣式資訊,然後將其封裝在自定義元素中。如果只是將其追加到標準 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 是元件內部的佔位符,使用者可以使用自己的標記來填充。讓我們看看上面的模板怎麼使用 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 自定義屬性的形式)讓使用者替換預設值。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

元件定義的樣式

作用域 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的本地樣式。使用元素在#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()

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

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

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

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

關於Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用

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

相關文章