[譯] 使用 Shadow DOM 封裝樣式和結構

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

系列文章:

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

在開始閱讀本文以前,咱們建議你先閱讀該系列文章中的前三篇,由於本文的工做是以它們爲基礎構建的。前端

咱們在上文中實現的對話框組件具備特定的外形,結構和行爲,可是它在很大程度上依賴於外層的 DOM,它要求使用者必須理解它的基本外形和結構,更不用說容許使用者編寫他們本身的樣式(最終將修改文檔的全局樣式)。由於咱們的對話框依賴於 id 爲 「one-dialog」 的模板元素的內容,因此每一個文檔只能有一個模態框的實例。node

目前對於咱們的對話框組件的限制不必定是壞的。熟悉對話框內部工做原理的使用者能夠經過建立本身的 <template> 元素,並定義他們但願使用的內容和樣式(甚至依賴於其餘地方定義的全局樣式)來輕鬆地使用對話框。可是,咱們但願在元素上提供更具體的設計和結構約束以適應最佳實踐,所以在本文中,咱們將在元素中使用 shadow DOM。android

什麼是 shadow DOM ?

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

可是不一樣於 <iframe>,全部的 shadow root 仍然存在於同一份文件當中,所以全部的代碼均可以在指定的上下文中編寫,而沒必要擔憂和其餘樣式或者選擇器衝突。git

在咱們的對話框中添加 shadow DOM

爲了添加一個 shadow root(shadow 樹的基本節點/文檔片斷),咱們須要調用元素的 attachShadow 方法:github

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 的引用,可是在這裏咱們不會用到它。web

若是咱們調用 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索