【譯】使用 Shadow DOM 封裝樣式和結構

徐二斤發表於2019-04-15

該系列由 5 篇文章構成,對 Web Components 規範進行了討論,這是其中的第四部分。在第一部分中,我們對於 Web Components 的規範和具體做的事情進行了全面的介紹。在第二部分中我們開始構建一個自定義的模態框,並且建立了 HTML 模版,這在第三部分中將演變為我們的自定義 HTML 元素。

系列文章:

  1. Web Components 簡介
  2. 編寫可以複用的 HTML 模板
  3. 從 0 開始建立自定義元素
  4. 使用 Shadow DOM 封裝樣式和結構(本文
  5. Web Components 的高階工具

在開始閱讀本文之前,我們建議你先閱讀該系列文章中的前三篇,因為本文的工作是以它們為基礎構建的。

我們在上文中實現的對話方塊元件具有特定的外形,結構和行為,但是它在很大程度上依賴於外層的 DOM,它要求使用者必須理解它的基本外形和結構,更不用說允許使用者編寫他們自己的樣式(最終將修改文件的全域性樣式)。因為我們的對話方塊依賴於 id 為 “one-dialog” 的模板元素的內容,所以每個文件只能有一個模態框的例項。

目前對於我們的對話方塊元件的限制不一定是壞的。熟悉對話方塊內部工作原理的使用者可以通過建立自己的 <template> 元素,並定義他們希望使用的內容和樣式(甚至依賴於其他地方定義的全域性樣式)來輕鬆地使用對話方塊。但是,我們希望在元素上提供更具體的設計和結構約束以適應最佳實踐,因此在本文中,我們將在元素中使用 shadow DOM。

什麼是 shadow DOM ?

介紹文章中我們說到,shadow DOM ”能夠隔離 CSS 和 JavaScript,和 <iframe> 非常相似“。在 shadow DOM 中選擇器和樣式不會作用於 shadow root 以外,shadow root 以外的樣式也不會影響 shadow DOM 內部。不過有一些特例,像是 font family 或者 font sizes(例如:rem)可以在內部重寫覆蓋。

但是不同於 <iframe>,所有的 shadow root 仍然存在於同一份檔案當中,因此所有的程式碼都可以在指定的上下文中編寫,而不必擔心和其他樣式或者選擇器衝突。

在我們的對話方塊中新增 shadow DOM

為了新增一個 shadow root(shadow 樹的基本節點/文件片段),我們需要呼叫元素的 attachShadow 方法:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
}
複製程式碼

通過呼叫 attachShadow 方法並設定引數 mode: 'open',我們在元素的 element.shadowRoot 屬性中儲存一份對 shadow root 的引用。attachShadow 方法將始終返回一個 shadow root 的引用,但是在這裡我們不會用到它。

如果我們呼叫 attachShadow 方法並設定引數 mode: 'closed',元素上將不會儲存任何引用,我們必須通過使用 WeakMap 或者 Object 來實現儲存和檢索,將節點自身設定為鍵,shadow root 設定為值。

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}
複製程式碼

我們還可以在元素自身上儲存對 shadow root 的引用,通過使用 Symbol 或者其他的鍵來設定 shadow root 為私有屬性。

通常,有一些原生元素(例如:<audio> 或者 <video>),它們會在自身的實現中使用 shadow DOM,shadow root 的關閉模式就是為了這些元素而存在的。此外,基於庫的架構方式,在元素的單元測試中,我們可能無法獲取 shadowRoots 物件,導致我們無法定位到元素內部的更改。

對於使用者主動使用關閉模式下的 shadow root 可能存在一些合理的用例,但是數量很少而且目的各不相同,所以我們將在我們的對話方塊中堅持使用 shadow root 的開啟模式。

在實現新的開啟模式下的 shadow root 之後,你可能注意到現在當我們嘗試執行時,我們的元素已經完全無法使用了:

CodePen 中檢視對話方塊示例:使用模板以及 shadow root

這是因為我們之前擁有的所有內容都被新增在傳統 DOM(我們稱之為light DOM)中,並在其中被操作。既然現在我們的元素上繫結了一個 shadow DOM,那麼就沒有一個 light DOM 可以渲染的出口。我們可以通過將內容放到 shadow DOM 中來解決這個問題:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
    }
  }
  
  close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);
複製程式碼

到目前為止,我們對話方塊的主要變化實際上相對較小,但它們帶來了很大的影響。首先,我們所有的選擇器(包括我們的樣式定義)都在內部作用域內。例如,我們的對話方塊模板內部只有一個按鈕,因此我們的 CSS 只針對 button {...},而且這些樣式不會影響到 light DOM。

但是,我們仍然依賴於元素外部的模板。讓我們通過從模板中刪除這些標記並將它們放入 shadow root 的 innerHTML 中來改變它。

CodePen 中檢視對話方塊示例:僅使用 shadow root

渲染來自 light DOM 的內容

shadow DOM 規範包括了一種允許在我們的自定義元素內,渲染 shadow root 外部的內容的方法。它和 AngularJS 中的 ng-transclude 概念以及在 React 中使用 props.children 都很相似。在 Web Components 中,我們可以通過使用 <slot> 元素實現。

這裡有一個簡單的例子:

<div>
  <span>world <!-- this would be inserted into the slot element below --></span>
  <#shadow-root><!-- pseudo code -->
    <p>Hello <slot></slot></p>
  </#shadow-root>
</div>
複製程式碼

一個給定的 shadow root 可以擁有任意數量的 slot 元素,可以用 name 屬性來區分。Shadow root 中沒有名稱的第一個 slot 將是預設 slot,未分配的所有內容將在該節點內按文件流(從左到右,從上到下)顯示。我們的對話方塊確實需要兩個 slot:標題和一些內容(我們將設定為預設 slot)。

CodePen 中檢視對話方塊示例:使用 shadow root 以及 slot

繼續更改對話方塊的 HTML 部分並檢視結果。Light DOM 內部的任何內容都被放入到分配給它的 slot 中。被插入的內容依舊保留在 light DOM 中,儘管它被渲染的好像在 shadow DOM 中一樣。這意味著這些元素的內容和樣式都可以由使用者定義。

Shadow root 的使用者通過 CSS ::slotted() 偽選擇器,可以有限度地定義 light DOM 中內容的樣式;然而,slot 中的 DOM 樹是摺疊的,所以只有簡單的選擇器可以工作。換句話說,在前面示例的扁平的 DOM 樹中,我們無法設定在 <p> 元素內部的 <strong> 元素的樣式。

兩全其美的方法

我們的對話方塊目前狀態良好:它具有封裝、語義標記、樣式和行為;然而,一些使用者仍然想要定義他們自己的模板。幸運的是,通過結合兩種我們所學的技術,我們可以允許使用者有選擇地定義外部模板。

為此,我們將允許元件的每個例項引用一個可選的模板 ID。首先,我們需要為元件的 template 定義一個 getter 和 setter。

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}
複製程式碼

在這裡,通過將它直接繫結到相應的屬性上,我們完成了和使用 open 屬性時非常類似的事情。但是在底部,我們為我們的元件引入了一個新的方法:render。現在我們可以使用 render 方法插入 shadow DOM 的內容,並從 connectedCallback 中移除行為;相反,我們將在連線元素時呼叫 render 方法:

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = '';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else {
    shadowRoot.innerHTML = `<!-- template text -->`;
  }
  shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}
複製程式碼

現在我們的對話方塊不僅擁有了一些非常基本的樣式,而且可以允許使用者為每個例項定義一個新模板。我們甚至可以基於它當前指向的模板使用 attributeChangedCallback 更新此元件:

static get observedAttributes() { return ['open', 'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if (newValue !== oldValue) {
    switch (attrName) {
      /** Boolean attributes */
      case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Value attributes */
      case 'template':
        this[attrName] = newValue;
        break;
    }
  }
}
複製程式碼

CodePen 中檢視對話方塊示例:使用 shadow root、插槽以及模板

在上面的示例中,改變 <one-dialog> 元素的 template 屬性將改變元素渲染時使用的設計。

Shadow DOM 樣式策略

目前,定義一個 shadow DOM 節點樣式的唯一方法就是在 shadow root 的內部 HTML 中新增一個 <style> 元素。這種方法幾乎在所有情況下都能正常工作,因為瀏覽器會在可能的情況下對這些元件中的樣式表進行重寫。這個確實會增加一些記憶體開銷,但通常不足以引起關注。

在這些樣式標籤內部,我們可以使用 CSS 自定義屬性為定義元件樣式提供 API。自定義屬性可以穿透 shadow 的邊界並影響 shadow 節點內的內容。

你可能會問:“我們可以在 shadow root 內部使用 <link> 元素嗎”?事實上,我們確實可以。但是當嘗試在多個應用之間重用這個元件時可能會出現問題,因為在所有應用中 CSS 檔案可能無法儲存在同一個位置。但是,如果我們確定了元素樣式表的位置,那麼我們就可以使用 <link> 元素。在樣式標籤中包含 @import 規則也是如此。

值得一提的是,不是所有的元件都需要像這樣定義樣式。使用 CSS 的 :host:host-context 選擇器,我們可以簡單地定義更多初級的元件為塊級元素,並且允許使用者以提供類名的方式定義樣式,如背景色,字型設定等。

另一方面,不同於只可以作為原生元素組合來展示的列表框(由標籤和核取方塊組成),我們的對話方塊相當複雜。這與樣式策略一樣有效,因為樣式更明確(比如設計系統的目的,其中所有核取方塊可能看起來都是一樣的)。這在很大程度上取決於你的使用場景。

CSS 自定義屬性

使用 CSS 自定義屬性(也被稱為 CSS 變數)的一個好處是它們可以傳入 shadow DOM 內。在設計上,為元件使用者提供了一個介面,允許他們從外部定義元件的主題和樣式。然而,值得注意的是,因為 CSS 級聯的緣故,在 shadow root 內部對於自定義樣式的更改不會迴流。

CodePen 中檢視CSS 自定義樣式以及 shadow DOM

繼續註釋或刪除上面示例中的 CSS 皮膚裡設定的變數,看看它是如何影響渲染內容的。你可以看一下 shadow DOM 的 innerHTML 中的樣式,不管 shadow DOM 如何定義它自己的屬性,都不會影響到 light DOM。

可構造的樣式表

在撰寫本文的時候,有一項提議的 web 功能,它允許使用可構造的樣式表對 shadow DOM 和 light DOM 的樣式進行更多地模組化定義。這個功能已經登陸 Chrome 73,並且從 Mozilla 得到了很多積極的訊息。

此功能允許使用者在其 JavaScript 檔案中定義樣式表,類似於編寫普通 CSS 並在多個節點之間共享這些樣式的方式。因此,單個樣式表可以新增到多個 shadow root 內,也可以新增到文件內。

const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; }');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`;
  }
}
複製程式碼

在上面的示例中,everythingTomato 樣式表可以同時應用到 shadow root 以及文件的 body 內。對於那些想要建立可以被多個應用和框架共享的設計系統和元件的團隊來說非常有用。

在下一個示例中,我們可以看到一個非常基礎的例子,展示了可構造樣式表的使用方法以及它提供的強大功能。

CodePen 中檢視可構造的樣式表示例

在這個示例中,我們構造了兩個樣式表,並將它們新增到文件和自定義元素上。三秒鐘後,我們從 shadow root 中刪除一個樣式表。但是,對於這三秒鐘,文件和 shadow DOM 共享相同的樣式表。使用該示例中包含的 polyfill,實際上存在兩個樣式元素,但 Chrome 執行的很自然。

該示例還包括一個表單,用於顯示如何根據需要非同步有效地更改工作表的規則。對於那些想要為他們的網站提供主題的使用者,或者那些想要建立跨越多個框架或網址的設計系統的使用者來說,Web 平臺的這一新增功能可以成為一個強大的盟友。

這裡還有一個關於 CSS 模組的提議,最終可以和 adoptStyleSheets 功能一起使用。如果以當前形式實現,該提議將允許把 CSS 作為模組匯入,就像 ECMAScript 模組一樣:

import styles './styles.css';

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [styles];
  }
}
複製程式碼

部分和主題

用於樣式化 Web 元件的另一個特性是 ::part()::theme() 偽選擇器。::part() 規範允許使用者可以定義他們的部分自定義元素,提供了下面的樣式定義介面:

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);
複製程式碼

在我們的全域性 CSS 中,我們可以通過呼叫 CSS 的 ::part() 選擇器來定位任何 part 屬性值為 description 的元素。

other-component::part(description) {
  color: tomato;
}
複製程式碼

在上面的示例中,<h1> 標籤的主要訊息與描述部分的顏色不同,對於那些自定義元素的使用者,讓他們可以暴露自己元件的樣式 API,並保持對他們想要保持控制的部分的控制。

::part()::theme() 的區別在於 ::part() 必須作用於特定的選擇器上,::theme() 可以巢狀在任何層級上。下面的示例和上面 CSS 程式碼有著相同的效果,但也適用於在整個文件樹中包含 part="description" 的任何其他元素。

:root::theme(description) {
  color: tomato;
}
複製程式碼

和可構造的樣式表一樣,::part() 已經可以在 Chrome 73 中使用。

總結

我們的對話方塊元件現在已經完成。它具有自己的標記,樣式(沒有任何外部依賴)和行為。此元件現在可以被包含在使用任何當前或未來框架的專案中,因為它們是根據瀏覽器規範而不是第三方 API 構建的。

一些核心控制元件有點冗長,並且或多或少依賴於對 DOM 工作原理一些知識。在我們的最後一篇文章中,我們將討論更高階別的工具以及如何與流行的框架結合使用。

系列文章:

  1. Web Components 簡介
  2. 編寫可以複用的 HTML 模板
  3. 從 0 開始建立自定義元素
  4. 使用 Shadow DOM 封裝樣式和結構(本文
  5. Web Components 的高階工具

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章