在React 中使用Shadow DOM

在React 中使用Shadow DOM

1. Shadow DOM 是什麼

Shadow DOM 是什麼?我們先來打開Chrome 的DevTool,並在'Settings -> Preferences -> Elements' 中把' Show user agent shadow DOM' 打上勾。然後,打開一個支持HTML5 播放的視頻網站。比如Youtube:

可以看到video內部有一個#shadow-root ,在ShadowRoot之下還能看到div這樣的普通HTML標籤。我們能知道video會有「播放/暫停按鈕、進度條、視頻時間顯示、音量控制」等控件,那其實,就是由ShadowRoot中的這些子元素構成的。而我們最常用的input其實也附加了Shadow DOM,比如,我們在Chrome中嘗試給一個Input加上placeholder ,通過DevTools便能看到,其實文字是在ShadowRoot下的一個Id為palcehoder的div中。

Shadow DOM 允許在文檔(Document)渲染時插入一棵「子DOM 樹」,並且這棵子樹不在主DOM 樹中,同時為子樹中的DOM 元素和CSS 提供了封裝的能力。 Shadow DOM 使得子樹DOM 與主文檔的DOM 保持分離,子DOM 樹中的CSS 不會影響到主DOM 樹的內容,如下圖所示:

這裡有幾個需要了解和Shadow DOM 相關的技術概念:

  • Shadow host: 一個常規DOM 節點,Shadow DOM 會被附加到這個節點上。
  • Shadow tree:Shadow DOM 內部的DOM 樹。
  • Shadow boundary:Shadow DOM 結束的地方,也是常規DOM 開始的地方。
  • Shadow root: Shadow tree 的根節點。

2. Shadwo DOM 有何用

2.1. 瀏覽器內建的原生組件

Shadow DOM 最大的用處應該是隔離外部環境用於封裝組件。估計瀏覽器的開發者們也意識到通過HTML/CSS來實現瀏覽器內建的原生組件更容易,如上邊提到的瀏覽器原生組件inputvideo ,還有textareaselectaudio等,也都是由HTML/CSS 渲染出來的。

2.2. Web Components

Web Components 允許開發者創建可重用的自定義元素,它們可以一起使用來創建封裝功能的自定義元素,並可以像瀏覽器原生的元素一樣在任何地方重用,而不必擔心樣式和DOM 的衝突問題,主要由三項主要技術組成:

  • Custom Elements (自定義元素):一組JavaScript API,允許您定義Custom Elements及其行為,然後可以在您的用戶界面中按照需要使用它們。
  • HTML Templates ( HTML模板): templateslot元素使您可以編寫不在呈現頁面中顯示的標記模板。然後它們可以作為自定義元素結構的基礎被多次重用。
  • Shadow DOM (影子DOM):一組JavaScript API用於將「影子DOM樹」附加到元素上,與主文檔DOM樹隔離,並能控制其關聯的功能。通過這種方式,可以保持元素的私有,並能不用擔心「樣式」與文檔的其他部分發生衝突。

在Web Components 中的一個重要特性是「封裝」,可以將「HTML 標籤結構、CSS 樣式、行為」隱藏起來,並從頁面上的其他代碼中分離開來,這樣不同的功能不會混在一起,代碼看起來也會更加干淨整潔,其中Shadow DOM 便是DOM 和CSS 封裝所依賴的關鍵特性。

2.3 其他需要隔離的場景

不少人大概會聽說過「微前端」,微前端作為一種「架構風格」,其中可由多個「可獨立交付的前端子應用」組合成一個大的整體。那麼在「微前端架構」下,每一個獨立的子應用間及子應用間的如何保證不會衝突?樣式不會相互覆蓋?那麼,是否可以將每個「子應用」通過Shadow DOM 進行隔離?答案是肯定的,我就在部分項目中有過實踐。

其他,在需要進行DOM/CSS 隔離的場景,都有可能是Shadow DOM 的用武之地。比如像「阿里雲購物車」這種需要「嵌入集成」到不同產品售賣頁的「公共組件」,就很需要避免和宿主頁面的樣式衝突,即不影響宿主頁面,也不要受宿主頁面的影響。

3. 主流瀏覽器的支持情況

其中Chrome,Opera 和Safari 默認就支持Shadow DOM,而Firefox 從63 版本開始已經支持,可以看到支持最好的是Chrome,而IE 直到11 也都是不支持的,微軟的另一款瀏覽器Edge要換成和Chrome 相同內核了,那換核後的Edge 肯定會支持Shadow DOM 了。

各瀏覽器支持詳細情況,請參考如下鏈接:

4. 如何創建Shadow DOM

Shadow DOM 必須附加在一個元素上,可以是通過HTML 聲明的一個元素,也可以是通過腳本動態創建的元素。可以是原生的元素,如div、p ,也可以是「自定義元素」如my-element ,語法如下:

constshadowroot=element.attachShadow(shadowRootInit);

參考如下例所示:

<html><head><title>Shadow Demo</title></head><body><h1>Shadow Demo</h1><divid="host"></div><script>consthost=document.querySelector('#host');//通過attachShadow向元素附加Shadow DOMconstshodowRoot=host.attachShadow({mode:'open'});//向shodowRoot中添加一些內容shodowRoot.innerHTML=`<style>*{color:red;}</style><h2>haha!</h2>`;</script></body></html>

通過這個簡單的範例可以看到「在Shadow DOM 中定義的樣式,並不會影響到主文檔中的元素」,如下圖

Element.attachShadow的參數shadowRootInitmode選項用於設定「封裝模式」。它有兩個可選的值:

  • "open" :可Host元素上通過host.shadowRoot獲取shadowRoot引用,這樣任何代碼都可以通過shadowRoot來訪問的子DOM樹。
  • "closed" :在Host元素上通過host.shadowRoot獲取的是null,我們只能通過Element.attachShadow的返回值拿到shadowRoot的引用(通常可能隱藏在類中)。例如,瀏覽器內建的input、video 等就是關閉的,我們沒有辦法訪問它們。

5. 哪些元素可以附加Shadow DOM

並非所有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 | +----------------+----------------+----------------+

6. 在React 中如何應用Shadow DOM

在基於React 的項目中應該如何使用Shadow DOM 呢?比如你正在基於React 編寫一個面向不同產品或業務,可嵌入集成使用的公共組件,比如你正在基於React 做一個「微前端架構」應用的設計或開發。

我們在編寫React 應用時一般不希望到處是DOM 操作,因為這很不React (形容詞)。那是否能封裝成一下用更React (形容詞) 的組件風格去使用Shadow DOM 呢?

6.1. 嘗試寫一個React 組件

importReactfrom"react";importReactDOMfrom"react-dom";exportclassShadowViewextendsReact.Component{attachShadow=(host:Element)=>{host.attachShadow({mode:"open"});}render(){const{children}=this.props;return<divref={this.attachShadow}>{children}</div>;}}exportfunctionApp(){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.renderReactDOM.createPortal ,我們並不能輕易的將React.Element渲染到其中,除非直接接操作DOM。

6.2. 基於直接操作DOM 改造一版

在React 中通過ref 拿到真實的DOM 引用後,是否能通過原生的DOM API,將host 的children 移動到host.shadowRoot 中?

importReactfrom"react";importReactDOMfrom"react-dom";//基於直接操作DOM的方式改造的一版exportclassShadowViewextendsReact.Component{attachShadow=(host:Element)=>{constshadowRoot=host.attachShadow({mode:"open"});//將所有children移到shadowRoot中[].slice.call(host.children).forEach(child=>{shadowRoot.appendChild(child);});}render(){const{children}=this.props;return<divref={this.attachShadow}>{children}</div>;}}//驗證一下exportclassAppextendsReact.Component{state={message:'...'};onBtnClick=()=>{this.setState({message:'haha'});}render(){const{message}=this.state;return<div><ShadowView><div>{message}</div><buttononClick={this.onBtnClick}>內部單擊</button></ShadowView><buttononClick={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 語法綁定的事件被觸發了。

6.3. 利用ReactDOM.render 改造一下

ReactDOM.render 的第二個參數,可傳入一個DOM 元素。那是不是能通過ReactDOM.render 將React Eements 渲染到Shodaw DOM 中呢?看一下如下嘗試:

importReactfrom"react";importReactDOMfrom"react-dom";//換用ReactDOM.render實現exportclassShadowViewextendsReact.Component{attachShadow=(host:Element)=>{const{children}=this.props;constshadowRoot=host.attachShadow({mode:"open"});ReactDOM.render(children,shadowRoot);}render(){return<divref={this.attachShadow}></div>;}}//試試效果如何exportclassAppextendsReact.Component{state={message:'...'};onBtnClick=()=>{this.setState({message:'haha'});alert('haha');}render(){const{message}=this.state;return<ShadowView><div>{message}</div><buttononClick={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 渲染上下文中了。

6.4. 利用ReactDOM.createPortal 實現一版

我們知道createPortal 的出現為「彈窗、提示框」等脫離文檔流的組件開發提供了便利,替換了之前不穩定的API unstable_renderSubtreeIntoContainer。

ReactDOM.createPortal 有一個特性是「通過createPortal 渲染的DOM,事件可以從Portal 的入口端冒泡上來」,這一特性很關鍵,沒有父子關係的DOM ,合成事件能冒泡過來,那通過createPortal 渲染到Shadow DOM 中的元素的事件也能正常觸發吧?並且能讓所有元素的渲染在一個上下文中。那就基於createPortal 實現一下:

importReactfrom"react";importReactDOMfrom"react-dom";//利用ReactDOM.createPortal的實現exportfunctionShadowContent({root,children}){returnReactDOM.createPortal(children,root);}exportclassShadowViewextendsReact.Component{state={root:null};setRoot=eleemnt=>{constroot=eleemnt.attachShadow({mode:"open"});this.setState({root});};render(){const{children}=this.props;const{root}=this.state;return<divref={this.setRoot}>{root&&<ShadowContentroot={root}>{children}</ShadowContent>}</div>;}}//試試如何exportclassAppextendsReact.Component{state={message:'...'};onBtnClick=()=>{this.setState({message:'haha'});}render(){const{message}=this.state;return<ShadowView><div>{message}</div><buttononClick={this.onBtnClick}>單擊我</button></ShadowView>}}ReactDOM.render(<App/>,document.getElementById("root"));

Wow! 一切正常,有一個小問題是createPortal 不支持React 16 以下的版本,但大多數情況下這並不是個什麼大問題。

7. 面向React 的ShadowView 組件

上邊提到了幾種在React 中實現Shadwo DOM 組件的方法,而ShadowView 是一個寫好的可開箱即用的面向React 的Shadow DOM 容器組件,利用ShadowView 可以像普通組件一樣方便的在React 應用中創建啟用Shadow DOM 的容器元素。

ShadowView 目前完整兼容支持React 15/16,組件的「事件處理、組件渲染更新」等行為在兩個版中都是一致的。

7.1. 安裝組件

npm i shadow-view --save

7.2. 使用組件

import*asReactfrom"react";import*asReactDOMfrom"react-dom";import{ShadowView}from"shadow-view";functionApp(){return(<ShadowViewstyleContent={`*{color:red;}`}styleSheets={['your_style1_url.css','your_style2_url.css']}><style>{`在這兒也可寫內部樣式`}</style><div>這是一個測試</div></ShadowView>);}ReactDOM.render(<App/>,document.getElementById('root'));

7.3. 組件屬性

那麼,ShadowView 是如何兼容支持React 15 的呢?可在如下地址一探究竟:

-- END --

What do you think?

Written by marketer

第5 屆FEDAY 所有嘉賓已集齊,就等你來!

重磅!滴滴跨端框架Chameleon 1.0正式發布(學不動啦…)