深刻理解Shadow DOM v1

翻譯:瘋狂的技術宅
https://blog.logrocket.com/un...

本文首發微信公衆號:前端先鋒
歡迎關注,天天都給你推送新鮮的前端技術文章javascript


shadow DOM不是超級英雄電影中的惡棍,也不是DOM的黑暗面。 shadow DOM只是一種解決文檔對象模型(或簡稱DOM)中缺乏的樹封裝方法。css

網頁一般使用來自外部源的數據和小部件,若是它們沒有封裝,那麼樣式可能會影響HTML中沒必要要的部分,迫使開發人員使用特定的選擇器和!important 規則來避免樣式衝突。html

儘管如此,在編寫大型程序時,這些努力彷佛並非那麼有效,而且大量的時間被浪費在防止CSS和JavaScript的衝突上。 Shadow DOM API旨在經過提供封裝DOM樹的機制來解決這些問題。前端

Shadow DOM是用於建立Web組件的主要技術之一,另外兩個是自定義元素和HTML模板。 Web 組件的規範最初是由Google提出的,用於簡化Web小部件的開發。java

雖然這三種技術旨在協同工做,不過你能夠自由地分別使用每種技術。本教程的範圍僅限於shadow DOM。node

什麼是DOM?

在深刻研究如何建立shadow DOM以前,瞭解DOM是什麼很是重要。 W3C文檔對象模型(DOM)提供了一個平臺和語言無關的應用程序編程接口(API),用於表示和操做存儲在HTML和XML文檔中的信息。git

經過使用DOM,程序員能夠訪問、添加、刪除或更改元素和內容。 DOM將網頁視爲樹結構,每一個分支以節點結束,每一個節點包含一個對象,可使用JavaScript等腳本語言對其進行修改。請考慮如下HTML文檔:程序員

<html>
  <head>
    <title>Sample document</title>
  </head>
  <body>
    <h1>Heading</h1>
    <a href="https://example.com">Link</a>
  </body>
</html>

此HTML的DOM表示以下:github

clipboard.png

此圖中全部的框都是節點。web

用於描述DOM部分的術語相似於現實世界中的家譜樹:

  • 給定節點上一級節點是該節點的父節點
  • 給定節點下一級節點是該節點的子節點
  • 具備相同父級的節點是兄弟節點
  • 給定節點上方的全部節點(包括父節點和祖父節點)都稱爲該節點的祖先
  • 最後,給定節點下全部的節點都被稱爲該節點的後代

節點的類型取決於它所表明的HTML元素的類型。 HTML標記被稱爲元素節點。嵌套標籤造成一個元素樹。元素中的文本稱爲文本節點。文本節點可能沒有子節點,你能夠把它想象成是一棵樹的葉子。

爲了訪問樹,DOM提供了一組方法,程序員能夠用這些方法修改文檔的內容和結構。例如當你寫下document.createElement('p');時,就在使用DOM提供的方法。沒有DOM,JavaScript就沒法理解HTML和XML文檔的結構。

下面的JavaScript代碼顯示瞭如何使用DOM方法建立兩個HTML元素,將一個嵌套在另外一個內部並設置文本內容,最後把它們附加到文檔正文:

const section = document.createElement('section');
const p = document.createElement('p');
p.textContent = 'Hello!';
section.appendChild(p);
document.body.appendChild(section);

這是運行這段JavaScript代碼後生成的DOM結構:

<body>
  <section>
    <p>Hello!</p>
  </section>
</body>

什麼是 shadow DOM?

封裝是面向對象編程的基本特性,它使程序員可以限制對某些對象組件的未受權訪問。

在此定義下,對象以公共訪問方法的形式提供接口做爲與其數據交互的方式。這樣對象的內部表示不能直接被對象的外部訪問。

Shadow DOM將此概念引入HTML。它容許你將隱藏的,分離的DOM連接到元素,這意味着你可使用HTML和CSS的本地範圍。如今能夠用更通用的CSS選擇器而沒必要擔憂命名衝突,而且樣式再也不泄漏或被應用於不恰當的元素。

實際上,Shadow DOM API正是庫和小部件開發人員將HTML結構、樣式和行爲與代碼的其餘部分分開所需的東西。

Shadow root 是 shadow 樹中最頂層的節點,是在建立 shadow DOM 時被附加到常規DOM節點的內容。具備與之關聯的shadow root的節點稱爲shadow host。

你能夠像使用普通DOM同樣將元素附加到shadow root。連接到shadow root的節點造成 shadow 樹。經過圖表應該可以表達的更清楚:

clipboard.png

術語light DOM一般用於區分正常DOM和shadow DOM。shadow DOM和light DOM被並稱爲邏輯DOM。light DOM與shadow DOM分離的點被稱爲陰影邊界。 DOM查詢和CSS規則不能到達陰影邊界的另外一側,從而建立封裝。

建立一個shadow DOM

要建立shadow DOM,須要用Element.attachShadow()方法將shadow root附加到元素:

var shadowroot = element.attachShadow(shadowRootInit);

來看一個簡單的例子:

<div id="host"><p>Default text</p></div>
    
<script>
  const elem = document.querySelector('#host');
     
  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
     
  // create a <p> element
  const p = document.createElement('p');
     
  // add <p> to the shadow DOM
  shadowRoot.appendChild(p);
     
  // add text to <p> 
  p.textContent = 'Hello!';
</script>

此代碼將一個shadow DOM樹附加到div元素,其idhost。這個樹與div的實際子元素是分開的,添加到它之上的任何東西都將是託管元素的本地元素。

clipboard.png

Chrome DevTools中的 Shadow root。

注意#host中的現有元素是如何被shadow root替換的。不支持shadow DOM的瀏覽器將使用默認內容。

如今,在將CSS添加到主文檔時,樣式規則不會影響shadow DOM:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  // set the HTML contained within the shadow root
  shadowRoot.innerHTML = '<p>Shadow DOM</p>';
</script>
 
<style>
  p {color: red}
</style>

在light DOM中定義的樣式不能越過shadow邊界。所以,只有light DOM中的段落纔會變爲紅色。

clipboard.png

相反,你添加到shadow DOM的CSS對於hosting元素來講是本地的,不會影響DOM中的其餘元素:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {color: red}</style>`;
 
</script>

clipboard.png

你還能夠將樣式規則放在外部樣式表中,以下所示:

shadowRoot.innerHTML = `
  <p>Shadow DOM</p>
  <link rel="stylesheet" href="style.css">`;

要獲取 shadowRoot 附加到的元素的引用,使用host屬性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  console.log(shadowRoot.host);    // => <div id="host"></div>
</script>

要執行相反操做並獲取對元素託管的shadow root的引用,能夠用元素的shadowRoot屬性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  console.log(elem.shadowRoot);    // => #shadow-root (open)
</script>

shadowRoot mod

當調用Element.attachShadow()方法來附加shadow root時,必須經過傳遞一個對象做爲參數來指定shadow DOM樹的封裝模式,不然將會拋出一個TypeError。該對象必須具備mode屬性,其值爲 openclosed

打開的shadow root容許你使用host元素的shadowRoot屬性從root外部訪問shadow root的元素,以下例所示:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach an open shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
  // Nodes of an open shadow DOM are accessible
  // from outside the shadow root
  elem.shadowRoot.querySelector('p').innerText = 'Changed from outside the shadow root';
  elem.shadowRoot.querySelector('p').style.color = 'red';
</script>

clipboard.png

可是若是mode屬性的值爲「closed」,則嘗試從root外部用JavaScript訪問shadow root的元素時會拋出一個TypeError

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach a closed shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'closed'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
 
  elem.shadowRoot.querySelector('p').innerText = 'Now nodes cannot be accessed from outside';
  // => TypeError: Cannot read property 'querySelector' of null 
</script>

當mode設置爲closed時,shadowRoot屬性返回null。由於null值沒有任何屬性或方法,因此在它上面調用querySelector()會致使TypeError。瀏覽器一般用關閉的 shadow roo 來使某些元素的實現內部不可訪問,並且不可從JavaScript更改。

要肯定shadow DOM是處於open仍是closed模式,你能夠參考shadow root的mode屬性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'closed'});
 
  console.log(shadowRoot.mode);    // => closed
</script>

從表面上看,對於不但願公開其組件的shadow root 的Web組件做者來講,封閉的shadow DOM看起來很是方便,然而在實踐中繞過封閉的shadow DOM並不難。一般徹底隱藏shadow DOM所需的工做量超過了它的價值。

並不是全部HTML元素均可以託管shadow DOM

只有一組有限的元素能夠託管shadow DOM。下表列出了支持的元素:

+----------------+----------------+----------------+
|    article     |      aside     |   blockquote   |
+----------------+----------------+----------------+
|     body       |       div      |     footer     |
+----------------+----------------+----------------+
|      h1        |       h2       |       h3       |
+----------------+----------------+----------------+
|      h4        |       h5       |       h6       |
+----------------+----------------+----------------+
|    header      |      main      |      nav       |
+----------------+----------------+----------------+
|      p         |     section    |      span      |
+----------------+----------------+----------------+

嘗試將shadow DOM樹附加到其餘元素將會致使「DOMException」錯誤。例如:

document.createElement('img').attachShadow({mode: 'open'});    
// => DOMException

<img>元素做爲shadow host是不合理的,所以這段代碼拋出錯誤並不奇怪。你可能會收到DOMException錯誤的另外一個緣由是瀏覽器已經用該元素託管了shadow DOM。

瀏覽器自動將shadow DOM附加到某些元素

Shadow DOM已存在很長一段時間了,瀏覽器一直用它來隱藏元素的內部結構,好比<input><textarea><video>

當你在HTML中使用<video>元素時,瀏覽器會自動將shadow DOM附加到包含默認瀏覽器控件的元素。但DOM中惟一可見的是<video>元素自己:

clipboard.png

要在Chrome中顯示此類元素的shadow root,請打開Chrome DevTools設置(按F1),而後在「elements」部分下方選中「Show user agent shadow DOM」:

clipboard.png

選中「Show user agent shadow DOM」選項後,shadow root節點及其子節點將變爲可見。如下是啓用此選項後相同代碼的顯示方式:

clipboard.png

在自定義元素上託管shadow DOM

Custom Elements API 建立的自定義元素能夠像其餘元素同樣託管shadow DOM。請看如下示例:

<my-element></my-element>
<script>
  class MyElement extends HTMLElement {
    constructor() {
 
      // must be called before the this keyword
      super();
 
      // attach a shadow root to <my-element>
      const shadowRoot = this.attachShadow({mode: 'open'});
 
      shadowRoot.innerHTML = `
        <style>p {color: red}</style>
        <p>Hello</p>`;
    }
  }
 
  // register a custom element on the page
  customElements.define('my-element', MyElement);
</script>

此代碼了建立一個託管shadow DOM的自定義元素。它調用了customElements.define()方法,元素名稱做爲第一個參數,類對象做爲第二個參數。該類擴展了HTMLElement並定義了元素的行爲。

在構造函數中,super()用於創建原型鏈,而且把Shadow root附加到自定義元素。當你在頁面上使用<my-element>時,它會建立本身的shadow DOM:

clipboard.png

請記住,有效的自定義元素不能是單個單詞,而且名稱中必須包含連字符( - )。例如,myelement不能用做自定義元素的名稱,並會拋出 DOMException 錯誤。

樣式化host元素

一般,要設置host元素的樣式,你須要將CSS添加到light DOM,由於這是host元素所在的位置。可是若是你須要在shadow DOM中設置host元素的樣式呢?

這就是host()僞類函數的用武之地。這個選擇器容許你從shadow root中的任何地方訪問shadow host。這是一個例子:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host {
        display: inline-block;
        border: solid 3px #ccc;
        padding: 0 15px;
      }
    </style>`;
</script>

值得注意的是:host僅在shadow root中有效。還要記住,在shadow root以外定義的樣式規則比:host中定義的規則具備更高的特殊性。

例如,#host { font-size: 16px; } 的優先級高於 shadow DOM的 :host { font-size: 20px; }。實際上這頗有用,這容許你爲組件定義默認樣式,並讓組件的用戶覆蓋你的樣式。惟一的例外是!important規則,它在shadow DOM中具備特殊性。

你還能夠將選擇器做爲參數傳遞給:host(),這容許你僅在host與指定選擇器匹配時纔會定位host。換句話說,它容許你定位同一host的不一樣狀態:

<style>
  :host(:focus) {
    /* style host only if it has received focus */
  }
 
  :host(.blue) {
    /* style host only if has a blue class */
  }
 
  :host([disabled]) {
    /* style host only if it's disabled */
  }
</style>

基於上下文的樣式

要選擇特定祖先內部的shadow root host ,能夠用:host-context()僞類函數。例如:

:host-context(.main) {
  font-weight: bold;
}

只有當它是.main的後代時,此CSS代碼纔會選擇shadow host :

<body class="main">
  <div id="host">
  </div>
</body>

:host-context()對主題特別有用,由於它容許做者根據組件使用的上下文對組件進行樣式設置。

樣式鉤子

shadow DOM的一個有趣地方是它可以建立「樣式佔位符」並容許用戶填充它們。這能夠經過使用CSS自定義屬性來完成。咱們來看一個簡單的例子:

<div id="host"></div>
 
<style>
  #host {--size: 20px;}
</style>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {font-size: var(--size, 16px);}</style>`;
 
</script>

這個shadow DOM容許用戶覆蓋其段落的字體大小。使用自定義屬性表示法( — size: 20px)設置該值,而且shadow DOM用var()函數(font-size: var( — size, 16px))檢索該值。在概念方面,這相似於<slot>元素的工做方式。

可繼承的樣式

shadow DOM容許你建立獨立的DOM元素,而不會從外部看到選擇器可見性,但這並不意味着繼承的屬性不會經過shadow邊界。

某些屬性(如colorbackgroundfont-family)會傳遞shadow邊界並應用於shadow樹。所以,與iframe相比,shadow DOM不是一個很是強大的障礙。

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>
 
<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
</script>

解決方法很簡單:經過聲明all: initial將可繼承樣式重置爲其初始值,以下所示:

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>
 
<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host p {
        all: initial;
      }
    </style>`;
</script>

在此例中,元素被強制回到初始狀態,所以穿過shadow邊界的樣式不起做用。

從新定位事件

在shadow DOM內觸發的事件能夠穿過shadow邊界並冒泡到light DOM;可是,Event.target的值會自動更改,所以它看起來好像該事件源自其包含的shadow樹而不是實際元素的host元素。

此更改稱爲事件重定向,其背後的緣由是保留shadow DOM封裝。請參考如下示例:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    <ul>
    `;
 
  document.addEventListener('click', (event) => {
    console.log(event.target);
  }, false);
</script>

當你單擊shadow DOM中的任何位置時,這段代碼會將 <div id =「host」> ... </div> 記錄到控制檯,所以偵聽器沒法看到調度該事件的實際元素。

可是在shadow DOM中不會發生重定目標,你能夠輕鬆找到與事件關聯的實際元素:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    </ul>`;
   
  shadowRoot.querySelector('ul').addEventListener('click', (event) => {
    console.log(event.target);
  }, false);  
</script>

請注意,並不是全部事件都會從shadow DOM傳播出去。那些作的是從新定位,但其餘只是被忽略了。若是你使用自定義事件的話,則須要使用composed:true標誌,不然事件不會從shadow邊界冒出來。

Shadow DOM v0 與 v1

Shadow DOM規範的原始版本在 Chrome 25 中實現,當時稱爲Shadow DOM v0。該規範的新版本改進了Shadow DOM API的許多方面。

例如,一個元素不能再承載多個shadow DOM,而某些元素根本不能託管shadow DOM。違反這些規則會致使錯誤。

此外,Shadow DOM v1提供了一組新功能,例如打開 shadow 模式、後備內容等。你能夠找到由規範做者之一編寫的 v0 和 v1 之間的全面比較(https://hayato.io/2016/shadow...)。能夠在W3C找到Shadow DOM v1的完整描述。

瀏覽器對Shadow DOM v1的支持

在撰寫本文時,Firefox和Chrome已經徹底支持Shadow DOM v1。不幸的是,Edge還沒有實現v1,Safari 只是部分支持。在 Can I use…上提供了支持的瀏覽器的最新列表。

要在不支持Shadow DOM v1的瀏覽器上實現shadow DOM,能夠用shadydomshadycss polyfills。

總結

DOM開發中缺少封裝一直是個問題。 Shadow DOM API爲咱們提供了劃分DOM範圍的能力,從而爲這個問題提供了一個優雅的解決方案。

如今,樣式衝突再也不是一個使人擔心的問題,選擇器也不會失控。 shadow DOM改變了小部件開發的遊戲規則,可以建立從頁面其他部分封裝的小部件,而且不受其餘樣式表和腳本的影響,這是一個巨大的優點。

如前所述,Web 組件由三個主要技術組成,而shadow DOM是其中的關鍵部分。但願在閱讀本文以後,你將更容易理解這三種技術是如何協同構建Web組件的。


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章


歡迎繼續閱讀本專欄其它高贊文章:

相關文章
相關標籤/搜索