究竟什麼是Shadow DOM?

語言: CN / TW / HK

shadow dom 是什麼?

顧名思義,shadow dom直譯的話就是影子dom,但我更願把它理解為DOM中的DOM。因為他能夠為Web元件中的 DOM和 CSS提供了封裝,實際上是在瀏覽器渲染文件的時候會給指定的DOM結構插入編寫好的DOM元素,但是插入的Shadow DOM 會與主文件的DOM保持分離,也就是說Shadow DOM不存在於主DOM樹上。

並且Shadow DOM封裝出來的DOM元素是獨立的,外部的配置不會影響到內部,內部的配置也不會影響外部。

如果這篇文章有幫助到你,:heart:關注+點贊:heart:鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新文章~

思考

理解完它的概念,我們再來思考一個問題:

為什麼我們用的一些標籤明明就是一個空元素,但他卻能夠渲染出各種複雜的場景?

  • input
  • video
  • audio
  • textarea
  • 等...

可能很多同學都沒想過為什麼這些標籤跟我們常用的 div 標籤不一樣,它們就簡單寫個標籤就能渲染出對應的樣式與功能;

或者有些同學理解成這都是底層渲染的事,我們不必關心。

是的,這些標籤內部的內容確實都是底層渲染的,不過我們也不是看不到它們內部的實現原理。

檢視html原生標籤的Shadow DOM

在html中寫入以下標籤,然後到瀏覽器控制檯去檢視

<input type="text">
<input type="range">
<video src="http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4" controls></video>
<textarea></textarea>

很多人看到的是這樣的,但這和我們寫的沒有任何區別呀?別急,這就帶你看看他們的真實面目~

首先開啟瀏覽器控制檯的設定選項

然後再找到Preference -> Elements,把show user anent shadow dom勾上

這時候我們再來看一下此時的dom元素髮生了什麼變化

我們會發現這些標籤內部都大有乾坤,在這些標籤下面都多了一個 shadow root ,在它裡面才是這些標籤的真實佈局。

既然這些標籤內部都有一些子元素佈局,那麼我們能不能通過JavaScript來訪問到它們呢?

const input = document.querySelector('input')
console.log(input.firstChild)  // null

很明顯,這是不可以的!

因為它為web開發者設定了一個邊界,界定了哪些是你可以訪問的,哪些實現細節是訪問不到的。然而,瀏覽器本身卻可以隨意跨越這個邊界。設定這樣一個邊界之後,它們就可以在你看不見的地方使用熟悉的web技術、同樣的HTML元素去建立更多的功能,而不是像你一樣要在頁面上用div和span來堆。

shadow dom 結構

Shadow DOM允許將隱藏的 DOM 樹附加到常規的 DOM 樹中——它以 shadow root 節點為起始根節點,在這個根節點的下方,可以是任意元素,和普通的 DOM 元素一樣。

就是因為這個特點所以我們才能看到上面那些單個空標籤就能夠渲染出各種各樣的複雜場景。

上面這張圖非常直觀的表現了 shadow dom 的結構以及它與真實 dom 的關係。

shadow host

一個常規 DOM 節點,Shadow DOM 會被附加到這個節點上。

shadow bounday

Shadow DOM 結束的地方,也是常規 DOM 開始的地方。

shadow tree

Shadow DOM 內部的 DOM 樹。

shadow root

Shadow tree 的根節點。

如何使用shadow dom?

建立一個shadow dom

我們可以使用 attachShadow 給指定元素掛載一個 shadow dom ,並且返回對shadow root的引用。

const shadowroot = root.attachShadow({mode: 'open'})
const template = `
  <div>前端南玖</div>
 `
shadowroot.innerHTML = template

shadow dom mode

當呼叫 Element.attachShadow() 方法t時,必須通過傳遞一個物件作為引數來指定shadow DOM樹的封裝模式,否則將會丟擲一個 TypeError 。該物件必須具有 mode 屬性,值為 openclosed

  • open shadow root 元素可以從 js 外部訪問根節點,例如使用 Element.shadowRoot :
element.shadowRoot; // 返回一個 ShadowRoot 物件
  • closed 拒絕從 js 外部訪問關閉的 shadow root 節點
element.shadowRoot; // 返回 null

瀏覽器通常用關閉的 shadow roo 來使某些元素的實現內部不可訪問,而且不可從JavaScript更改。

對於一些不希望公開shadow root 的Web元件來說,封閉的shadow DOM看起來非常方便,然而在實踐中繞過封閉的shadow DOM並不難。但是完全隱藏shadow DOM所需的工作量也大大超過了它的價值。

哪些元素可以掛載shadow dom?

這裡需要注意的是並非所有html元素都可以掛載 shadow dom ,只有以下這些元素可以充當 shadow dom 的 shadow host

article aside blockquote body
div footer h1 h2
h3 h4 h5 h6
header main nav p
section span 任何帶有有效的名稱且可獨立存在的自定義元素

當我們嘗試在其它元素掛在shadow dom時,瀏覽器則會丟擲異常。

const input = document.querySelector('input')
const inputRoot = input.attachShadow({mode: 'open'})

shadow dom的特點

從前面的介紹,我們知道shadow dom是遊離在 DOM 樹之外的節點樹,但是它是基於普通 DOM 元素(非 document)建立的,並且建立後的 Shadow-dom 節點可以從介面上直觀的看到。 最重要的一點是Shadow-dom 具有良好的密封性。

樣式

<style>
  .wx_name {
    color:aqua;
  }
</style>
<body>
    <div class="wx_name">我是真實dom</div>
    <div id="root"></div>

    <script>
        const shadowroot = root.attachShadow({mode: 'open'})
        const template = `
            <div class="wx_name">shadow dom - 前端南玖</div>
        `
        shadowroot.innerHTML = template
    </script>
</body>

它渲染出來是下面這樣的:point_down::

上面我們說了shadow dom是遊離在 DOM 樹之外的節點樹,所以我們文件上的CSS就不會作用在他身上。

樣式化host元素

host 偽類選擇器允許你從shadow root中的任何地方訪問shadow host

const shadowroot = root.attachShadow({mode: 'open'})
const template = `
       <div class="wx_name">shadow dom - 前端南玖</div>
       <style>
          :host {
             border: 1px solid #ccc;
             color: pink;
           }
        </style>
`
shadowroot.innerHTML = template

需要注意的是 :host 僅在shadow root中有效,並且在shadow root之外定義的樣式規則比 :host 中定義的規則具有更高的特殊性。

樣式鉤子

shadow dom還有一個非常重要的一個特點就是可以使用 CSS自定義屬性 來建立樣式佔位符,並允許使用者填充。

<style>
  #root {
    --bg: coral;
    --color: #fff: 
  }
</style>
<div id="root"></div>

<script>

  const shadowroot = root.attachShadow({mode: 'open'})
  const template = `
      <div class="wx_name">shadow dom - 前端南玖</div>
      <style>
          .wx_name {
              background: var(--bg, red);
              color: var(--color, #000)
          }
  </style>
`
  shadowroot.innerHTML = template
</script>

通過CSS訪問shadow

如果我們想要自定義一些原生標籤的樣式應該怎樣做呢,很顯然常規的CSS選擇器並不能獲取到shadow dom內部元素。那我們就一點辦法沒有了嗎?其實這裡我們可以通過一些偽元素來實現,比如:

<input type="range">

它預設長這樣

那我們怎麼去改變他的樣式呢,比如給它換種背景色

直接給input寫背景色能實現嗎?

input{
  background: #ccc;
}

很顯然這是一種大聰明行為,那它就這一個元素,究竟怎樣才能改變它的背景色呢,上面我們不是說了嗎,它內部是有shadow dom的

input[type=range]::-webkit-slider-runnable-track {
  -webkit-appearance: none;
  background-color: chocolate;
}

我們可以通過偽元素來訪問到shadow的內部元素並改變其樣式。

事件

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

此更改稱為事件重定向,其背後的原因是保留shadow DOM封裝。

<div id="root"></div>

<script>
  const shadowroot = root.attachShadow({mode: 'open'})
  const template = `
            <div class="wx_name">shadow dom - aaa</div>
            <div class="wx_name">shadow dom - bbb</div>
            <div class="wx_name">shadow dom - ccc</div>
        `
  shadowroot.innerHTML = template
  document.addEventListener('click', e => {
    console.log(e.target)
  })
</script>

當點選shadow dom中的任何元素時,打印出來的都是 root ,監聽器無法看到排程該事件的真實元素。

自定義元素託管shadow DOM

模擬微信小程式標籤

Custom Elements API 建立的自定義元素可以像其他元素一樣託管shadow DOM。

<body>
    <wx-text>前端南玖</wx-text>
    <script>
        class wxText extends HTMLElement {
            constructor() {
                super()
                // console.log(this.innerText)
                const text = this.innerText
                this.innerText = null
                const shadowRoot = this.attachShadow({mode: 'open'})
                shadowRoot.innerHTML = `
                    <span>${text}</span>
                `
            }
        }

        customElements.define('wx-text', wxText)
    </script>
</body>

上面這段程式碼就是模擬微信小程式的標籤實現,這裡為什麼又跳到了小程式?因為微信小程式的實現原理跟這類似,我們知道小程式的圖是在WebView裡渲染的,那搭建檢視的方式自然就需要用到HTML語言。然後為了管控安全,肯定不可能讓開發者直接使用html來進行開發,所以就自己實現了一套元件組織框架Exparser內建在小程式基礎庫中。這裡的Exparser框架模型上與WebComponents的ShadowDOM高度相似,但不依賴瀏覽器的原生支援,也沒有其他依賴庫。

如何檢視小程式編譯後的標籤

很多人可能會有疑惑,我們在小程式中寫的標籤不是這樣的呀,它們都不帶有 wx 字首。是的,為了開發者使用方便,開發時是不需要帶 wx 字首的,在編譯過程會自動識別比對轉換成真實DOM。

document.getElementsByTagName('webview')[0].showDevTools(true,null)