Shadow DOM 是什麼?咱們先來打開 Chrome 的 DevTool,並在 'Settings -> Preferences -> Elements' 中把 ' Show user agent shadow DOM' 打上勾。而後,打開一個支持 HTML5 播放的視頻網站。好比 Youtube:css
能夠看到 video
內部有一個 #shadow-root
,在 ShadowRoot 之下還能看到 div
這樣的普通 HTML 標籤。咱們能知道 video
會有「播放/暫停按鈕、進度條、視頻時間顯示、音量控制」等控件,那其實,就是由 ShadowRoot 中的這些子元素構成的。而咱們最經常使用的 input
其實也附加了 Shadow DOM,好比,咱們在 Chrome 中嘗試給一個 Input 加上 placeholder
,經過 DevTools 便能看到,其實文字是在 ShadowRoot 下的一個 Id 爲 palcehoder
的 div 中。html
Shadow DOM 容許在文檔(Document)渲染時插入一棵「子 DOM 樹」,而且這棵子樹不在主 DOM 樹中,同時爲子樹中的 DOM 元素和 CSS 提供了封裝的能力。Shadow DOM 使得子樹 DOM 與主文檔的 DOM 保持分離,子 DOM 樹中的 CSS 不會影響到主 DOM 樹的內容,以下圖所示:前端
這裏有幾個須要瞭解和 Shadow DOM 相關的技術概念:react
Shadow DOM 最大的用處應該是隔離外部環境用於封裝組件。估計瀏覽器的開發者們也意識到經過 HTML/CSS 來實現瀏覽器內建的原生組件更容易,如上邊提到的瀏覽器原生組件 input
,video
,還有 textarea
,select
,audio
等,也都是由 HTML/CSS 渲染出來的。git
Web Components 容許開發者建立可重用的自定義元素,它們能夠一塊兒使用來建立封裝功能的自定義元素,並能夠像瀏覽器原生的元素同樣在任何地方重用,而沒必要擔憂樣式和 DOM 的衝突問題,主要由三項主要技術組成:github
template
和 slot
元素使您能夠編寫不在呈現頁面中顯示的標記模板。而後它們能夠做爲自定義元素結構的基礎被屢次重用。在 Web Components 中的一個重要特性是「封裝」,能夠將「HTML 標籤結構、CSS 樣式、行爲」隱藏起來,並從頁面上的其餘代碼中分離開來,這樣不一樣的功能不會混在一塊兒,代碼看起來也會更加乾淨整潔,其中 Shadow DOM 即是 DOM 和 CSS 封裝所依賴的關鍵特性。npm
很多人大概會據說過「微前端」,微前端做爲一種「架構風格」,其中可由多個「可獨立交付的前端子應用」組合成一個大的總體。那麼在「微前端架構」下,每個獨立的子應用間及子應用間的如何保證不會衝突?樣式不會相互覆蓋?那麼,是否能夠將每一個「子應用」經過 Shadow DOM 進行隔離?答案是確定的,我就在部分項目中有過實踐。瀏覽器
其餘,在須要進行 DOM/CSS 隔離的場景,都有多是 Shadow DOM 的用武之地。好比像 「阿里雲購物車」這種須要「嵌入集成」到不一樣產品售賣頁的「公共組件」,就很須要避免和宿主頁面的樣式衝突,即不影響宿主頁面,也不要受宿主頁面的影響。安全
其中 Chrome,Opera 和 Safari 默認就支持 Shadow DOM,而 Firefox 從 63 版本開始已經支持,能夠看到支持最好的是 Chrome,而 IE 直到 11 也都是不支持的,微軟的另外一款瀏覽器 Edge 要換成和 Chrome 相同內核了,那換核後的 Edge 確定會支持 Shadow DOM 了。架構
各瀏覽器支持詳細狀況,請參考 https://caniuse.com/#feat=shadowdomv1
Shadow DOM 必須附加在一個元素上,能夠是經過 HTML 聲明的一個元素,也能夠是經過腳本動態建立的元素。能夠是原生的元素,如 div、p
,也能夠是「自定義元素」如 my-element
,語法以下:
const shadowroot = element.attachShadow(shadowRootInit);
參考以下例所示:
<html> <head> <title>Shadow Demo</title> </head> <body> <h1>Shadow Demo</h1> <div id="host"></div> <script> const host = document.querySelector('#host'); // 經過 attachShadow 向元素附加 Shadow DOM const shodowRoot = host.attachShadow({ mode: 'open' }); // 向 shodowRoot 中添加一些內容 shodowRoot.innerHTML = `<style>*{color:red;}</style><h2>haha!</h2>`; </script> </body> </html>
經過這個簡單的示例能夠看到「在 Shadow DOM 中定義的樣式,並不會影響到主文檔中的元素」,以下圖
Element.attachShadow
的參數 shadowRootInit
的 mode
選項用於設定「封裝模式」。它有兩個可選的值 :
host.shadowRoot
獲取 shadowRoot 引用,這樣任何代碼均可以經過 shadowRoot 來訪問的子 DOM 樹。host.shadowRoot
獲取的是 null,咱們只能經過 Element.attachShadow
的返回值拿到 shadowRoot 的引用(一般可能隱藏在類中)。例如,瀏覽器內建的 input、video 等就是關閉的,咱們沒有辦法訪問它們。並不是全部 HTML 元素均可以開啓 Shadow DOM 的,只有一組有限的元素能夠附加 Shadow DOM。有時嘗試將 Shadow DOM 樹附加到某些元素將會致使 DOMException
錯誤,例如:
document.createElement('img').attachShadow({mode: 'open'}); // => DOMException
用 <img>
這樣的非容器素做爲 Shadow Host 是不合理的,所以這段代碼將拋出 DOMException
錯誤。此外由於安全緣由一些元素也不能附加 Shadow DOM(好比 A 元素),會出現錯誤的另外一個緣由是瀏覽器已經用該元素附加了 Shadow DOM,好比 Input 等。
下表列出了全部支持的元素:
+----------------+----------------+----------------+ | article | aside | blockquote | +----------------+----------------+----------------+ | body | div | footer | +----------------+----------------+----------------+ | h1 | h2 | h3 | +----------------+----------------+----------------+ | h4 | h5 | h6 | +----------------+----------------+----------------+ | header | main | nav | +----------------+----------------+----------------+ | p | section | span | +----------------+----------------+----------------+
在基於 React 的項目中應該如何使用 Shadow DOM 呢?好比你正在基於 React 編寫一個面向不一樣產品或業務,可嵌入集成使用的公共組件,好比你正在基於 React 作一個「微前端架構」應用的設計或開發。
咱們在編寫 React 應用時通常不但願處處是 DOM 操做,由於這很不 React (形容詞)。那是否能封裝成一下用更 React (形容詞) 的組件風格去使用 Shadow DOM 呢?
import React from "react"; import ReactDOM from "react-dom"; export class ShadowView extends React.Component { attachShadow = (host: Element) => { host.attachShadow({ mode: "open" }); } render() { const { children } = this.props; return <div ref={this.attachShadow}> {children} </div>; } } export function App() { return <ShadowView> <span>這兒是隔離的</span> </ShadowView> } ReactDOM.render(<App />, document.getElementById("root"));
跑起來看看效果,必定會發現「咦?什麼也沒有顯示」:
在這裏須要稍注意一下,在一個元素上附加了 Shadow DOM 後,元素本來的「子元素」將不會再顯示,而且這些子元素也不在 Shadow DOM 中,只有 host.shadowRoot
的子元素纔是「子 DOM 樹」中一部分。也就是說這個「子 DOM 樹」的「根節點」是 host.shadowRoot
而非 host。 host.shadowRoot
是 ShadowRoot 的實例,而 ShadowRoot 則繼承於 DocumentFragment,可經過原生 DOM API 操做其子元素。
咱們需經過 Element.attachShadow
附加到元素,而後就能拿到附加後的 ShadowRoot 實例。 針對 ShadowRoot 這樣一個原生 DOM Node 的的引用,除了利用 ReactDOM.render
或 ReactDOM.createPortal
,咱們並不能輕易的將 React.Element 渲染到其中,除非直接接操做 DOM。
在 React 中經過 ref 拿到真實的 DOM 引用後,是否能經過原生的 DOM API,將 host 的 children 移動到 host.shadowRoot 中?
import React from "react"; import ReactDOM from "react-dom"; // 基於直接操做 DOM 的方式改造的一版 export class ShadowView extends React.Component { attachShadow = (host: Element) => { const shadowRoot = host.attachShadow({ mode: "open" }); //將全部 children 移到 shadowRoot 中 [].slice.call(host.children).forEach(child => { shadowRoot.appendChild(child); }); } render() { const { children } = this.props; return <div ref={this.attachShadow}> {children} </div>; } } // 驗證一下 export class App extends React.Component { state = { message: '...' }; onBtnClick = () => { this.setState({ message: 'haha' }); } render() { const { message } = this.state; return <div> <ShadowView> <div>{message}</div> <button onClick={this.onBtnClick}>內部單擊</button> </ShadowView> <button onClick={this.onBtnClick}>外部單擊</button> </div> } } ReactDOM.render(<App />, document.getElementById("root"));
在瀏覽器中看看效果,能夠看到是能夠正常顯示的。但與此同時會發現一個問題「隔離在 ShadowRoot 中的元素上的事件沒法被觸發了」,這是什麼緣由呢?
是因爲 React 的「合成事件機制」的致使的,咱們知道在 React 中「事件」並不會直接綁定到具體的 DOM 元素上,而是經過在 document 上綁定的 ReactEventListener 來管理, 當時元素被單擊或觸發其餘事件時,事件被 dispatch 到 document 時將由 React 進行處理並觸發相應合成事件的執行。
那爲何合成事件在 Shadow DOM 中不能被正常觸發?是由於當在 Shadow DOM 外部捕獲時瀏覽器會對事件進行「重定向」,也就是說在 Shadow DOM 中發生的事件在外部捕獲時將會使用 host 元素做爲事件源。這將讓 React 在處理合成事件時,不認爲 ShadowDOM 中元素基於 JSX 語法綁定的事件被觸發了。
ReactDOM.render 的第二個參數,可傳入一個 DOM 元素。那是否是能經過 ReactDOM.render 將 React Eements 渲染到 Shodaw DOM 中呢?看一下以下嘗試:
import React from "react"; import ReactDOM from "react-dom"; // 換用 ReactDOM.render 實現 export class ShadowView extends React.Component { attachShadow = (host: Element) => { const { children } = this.props; const shadowRoot = host.attachShadow({ mode: "open" }); ReactDOM.render(children, shadowRoot); } render() { return <div ref={this.attachShadow}></div>; } } // 試試效果如何 export class App extends React.Component { state = { message: '...' }; onBtnClick = () => { this.setState({ message: 'haha' }); alert('haha'); } render() { const { message } = this.state; return <ShadowView> <div>{message}</div> <button onClick={this.onBtnClick}>單擊我</button> </ShadowView> } } ReactDOM.render(<App />, document.getElementById("root"));
能夠看到經過 ReactDOM.render 進行 children 的渲染,是可以正常渲染到 Shadow Root 中,而且在 Shadow DOM 中合成事件也是能正常觸發執行的。
爲何此時「隔離在 Shadow DOM 中的元素事件」可以被觸發了呢? 由於在 Reac 在發現渲染的目標在 ShadowRoot 中時,將會將事件綁定在經過 Element.getRootNode() 獲取的 DocumentFragment 的 RootNode 上。
看似一切順利,但卻會發現父組件的 state 更新時,而 ShadowView 組件並無更新。如上邊的示例,其中的 message 顯示的仍是舊的,而緣由就在咱們使用 ReactDOM.render 時,Shadow DOM 的元素和父組件不在一個 React 渲染上下文中了。
咱們知道 createPortal 的出現爲「彈窗、提示框」等脫離文檔流的組件開發提供了便利,替換了以前不穩定的 API unstable_renderSubtreeIntoContainer。
ReactDOM.createPortal 有一個特性是「經過 createPortal 渲染的 DOM,事件能夠從 Portal 的入口端冒泡上來」,這一特性很關鍵,沒有父子關係的 DOM ,合成事件能冒泡過來,那經過 createPortal 渲染到 Shadow DOM 中的元素的事件也能正常觸發吧?而且能讓全部元素的渲染在一個上下文中。那就基於 createPortal 實現一下:
import React from "react"; import ReactDOM from "react-dom"; // 利用 ReactDOM.createPortal 的實現 export function ShadowContent({ root, children }) { return ReactDOM.createPortal(children, root); } export class ShadowView extends React.Component { state = { root: null }; setRoot = eleemnt => { const root = eleemnt.attachShadow({ mode: "open" }); this.setState({ root }); }; render() { const { children } = this.props; const { root } = this.state; return <div ref={this.setRoot}> {root && <ShadowContent root={root} > {children} </ShadowContent>} </div>; } } // 試試如何 export class App extends React.Component { state = { message: '...' }; onBtnClick = () => { this.setState({ message: 'haha' }); } render() { const { message } = this.state; return <ShadowView> <div>{message}</div> <button onClick={this.onBtnClick}>單擊我</button> </ShadowView> } } ReactDOM.render(<App />, document.getElementById("root"));
Wow! 一切正常,有一個小問題是 createPortal 不支持 React 16 如下的版本,但大多數狀況下這並非個什麼大問題。
上邊提到了幾種在 React 中實現 Shadwo DOM 組件的方法,而 ShadowView 是一個寫好的可開箱即用的面向 React 的 Shadow DOM 容器組件,利用 ShadowView 能夠像普通組件同樣方便的在 React 應用中建立啓用 Shadow DOM 的容器元素。
ShadowView 目前完整兼容支持 React 15/16,組件的「事件處理、組件渲染更新」等行爲在兩個版中都是一致的。
GitHub: https://github.com/Houfeng/shadow-view
npm i shadow-view --save
import * as React from "react"; import * as ReactDOM from "react-dom"; import { ShadowView } from "shadow-view"; function App() { return ( <ShadowView styleContent={`*{color:red;}`} styleSheets={[ 'your_style1_url.css', 'your_style2_url.css' ]} > <style>{`在這兒也可寫內部樣式`}</style> <div>這是一個測試</div> </ShadowView> ); } ReactDOM.render(<App/>, document.getElementById('root'));
屬性名 | 類型 | 說明 |
---|---|---|
className | string | 組件自身 className |
style | any | 組件自身的內聯樣式 |
styleContent | string | 做用於 ShadowView 內部的樣式 |
styleSheets | string[] | 做用於 ShadowView 內部的外聯樣式表 |
scoped | boolean | 是否開始隔離,默認爲 true |
tagName | string | 外層容器 tagName,默認爲 shadow-view |
那麼,ShadowView 是如何兼容支持 React 15 的呢? 可在 https://github.com/Houfeng/shadow-view 一探究竟。
本文做者:houfeng
本文爲雲棲社區原創內容,未經容許不得轉載。