在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 相關的技術概念:

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

  • Shadowtree:Shadow DOM 內部的 DOM 樹。

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

  • Shadowroot: Shadow tree 的根節點。

2. Shadwo DOM 有何用

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

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

2.2. Web Components

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

  • CustomElements(自定義元素):一組 JavaScript API,允許您定義 Custom Elements 及其行爲,然後可以在您的用戶界面中按照需要使用它們。

  • HTMLTemplates( HTML 模板): template 和 slot 元素使您可以編寫不在呈現頁面中顯示的標記模板。然後它們可以作爲自定義元素結構的基礎被多次重用。

  • ShadowDOM(影子 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 了。

各瀏覽器支持詳細情況,請參考 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 等。

下表列出了所有支持的元素:

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 將 ReactEements 渲染到 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 的渲染,是能夠正常渲染到 ShadowRoot 中,並且在 Shadow DOM 中合成事件也是能正常觸發執行的。

爲什麼此時「隔離在 Shadow DOM 中的元素事件」能夠被觸發了呢?因爲在 React 在發現渲染的目標在 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. 組件屬性

 


本文作者:houfeng
原文地址:https://yq.aliyun.com/articles/717933

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章