在 React 中使用 Shadow DOM

1. Shadow DOM 是什麼

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 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 渲染出來的。git

2.2. Web Components

Web Components 容許開發者建立可重用的自定義元素,它們能夠一塊兒使用來建立封裝功能的自定義元素,並能夠像瀏覽器原生的元素同樣在任何地方重用,而沒必要擔憂樣式和 DOM 的衝突問題,主要由三項主要技術組成:github

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

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

2.3 其餘須要隔離的場景

很多人大概會據說過「微前端」,微前端做爲一種「架構風格」,其中可由多個「可獨立交付的前端子應用」組合成一個大的總體。那麼在「微前端架構」下,每個獨立的子應用間及子應用間的如何保證不會衝突?樣式不會相互覆蓋?那麼,是否能夠將每一個「子應用」經過 Shadow DOM 進行隔離?答案是確定的,我就在部分項目中有過實踐。瀏覽器

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

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

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

各瀏覽器支持詳細狀況,請參考 https://caniuse.com/#feat=shadowdomv1

4. 如何建立 Shadow DOM

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  選項用於設定「封裝模式」。它有兩個可選的值 :

  • "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 組件:

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。

6.2. 基於直接操做 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 語法綁定的事件被觸發了。

6.3. 嘗試利用 ReactDOM.render 改造一下:

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 渲染上下文中了。

6.4. 利用 ReactDOM.createPortal 實現一版:

咱們知道 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 如下的版本,但大多數狀況下這並非個什麼大問題。

7. 面向 React 的 ShadowView 組件

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

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

GitHub: https://github.com/Houfeng/shadow-view

7.1. 安裝組件

npm i shadow-view --save

7.2. 使用組件

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'));

7.3. 組件屬性

屬性名 類型 說明
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

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索