react shadow

Shadow DOM 是什么

Shadow DOM 是什么?我们先来打开 Chrome 的 DevTool,并在 Settings -> Preferences -> Elements 中把 Show user agent shadow DOM 打上勾。然后,打开一个支持 HTML5 播放的视频网站。

可以看到 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 的根节点。

Shadwo DOM 有何用

浏览器内建的原生组件

Shadow DOM 最大的用处应该是隔离外部环境用于封装组件。估计浏览器的开发者们也意识到通过 HTML/CSS 来实现浏览器内建的原生组件更容易,如上边提到的浏览器原生组件 inputvideo,还有 textareaselectaudio 等,也都是由 HTML/CSS 渲染出来的。

Web Components

Web Components 允许开发者创建可重用的自定义元素,它们可以一起使用来创建封装功能的自定义元素,并可以像浏览器原生的元素一样在任何地方重用,而不必担心样式和 DOM 的冲突问题,主要由三项主要技术组成:

  • 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 封装所依赖的关键特性。

其他需要隔离的场景

不少人大概会听说过「微前端」,微前端作为一种「架构风格」,其中可由多个「可独立交付的前端子应用」组合成一个大的整体。那么在「微前端架构」下,每一个独立的子应用间及子应用间的如何保证不会冲突?样式不会相互覆盖?那么,是否可以将每个「子应用」通过 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

Shadow DOM 必须附加在一个元素上,可以是通过 HTML 声明的一个元素,也可以是通过脚本动态创建的元素。可以是原生的元素,如 div、p ,也可以是「自定义元素」如 my-element ,语法如下:

const shadowroot = element.attachShadow(shadowRootInit);

参考如下例所示:

```
Shadow Demo

Shadow Demo


通过这个简单的示例可以看到「在 Shadow DOM 中定义的样式,并不会影响到主文档中的元素」,如下图

<br /> <br /> 

<img loading="lazy" width="2206" height="1356" class="alignnone size-full wp-image-5118 shadow" src="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png" data-src="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png?x-oss-process=image/format,webp" alt="" srcset="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png?x-oss-process=image/format,webp 2206w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png?x-oss-process=image/quality,q_50/resize,m_fill,w_300,h_184/format,webp 300w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png?x-oss-process=image/quality,q_50/resize,m_fill,w_768,h_472/format,webp 768w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa4e59184.png?x-oss-process=image/quality,q_50/resize,m_fill,w_800,h_492/format,webp 800w" sizes="(max-width: 2206px) 100vw, 2206px" />

Element.attachShadow  的参数 shadowRootInit  的 mode  选项用于设定「封装模式」。它有两个可选的值 :
- open :可 Host 元素上通过 host.shadowRoot  获取 shadowRoot 引用,这样任何代码都可以通过 shadowRoot 来访问的子 DOM 树。
- closed:在 Host 元素上通过 host.shadowRoot  获取的是 null,[我们](https://www.w3cdoc.com)只能通过 Element.attachShadow 的返回值拿到 shadowRoot 的引用(通常可能隐藏在类中)。例如,[浏览器](https://www.w3cdoc.com)内建的 input、video 等就是关闭的,[我们](https://www.w3cdoc.com)没有办法访问它们。
  
## 哪些元素可以附加 Shadow DOM
并非所有 HTML 元素都可以开启 Shadow DOM 的,只有一组有限的元素可以附加 Shadow DOM。有时尝试将 Shadow DOM 树附加到某些元素将会导致 DOMException 错误,例如:

document.createElement(‘img’).attachShadow({mode: ‘open’}); // => DOMException

用 img 这样的非容器素作为 Shadow Host 是不合理的,因此这段代码将抛出 DOMException 错误。此外因为安全原因一些元素也不能附加 Shadow DOM(比如 A 元素),会出现错误的另一个原因是[浏览器](https://www.w3cdoc.com)已经用该元素附加了 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 的项目中应该如何使用 Shadow DOM 呢?比如你正在基于 React 编写一个面向不同产品或业务,可嵌入集成使用的公共组件,比如你正在基于 React 做一个「微[前端](https://www.w3cdoc.com)架构」应用的设计或开发。

[我们](https://www.w3cdoc.com)在编写 React 应用时一般不希望到处是 DOM 操作,因为这很不 React (形容词)。那是否能封装成一下用更 React (形容词) 的组件风格去使用 Shadow DOM 呢?


### 尝试写一个 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

{children} ; } }

export function App() { return 这儿是隔离的 }

ReactDOM.render(, document.getElementById(“root”));


跑起来看看效果,一定会发现「咦?什么也没有显示」:

<img loading="lazy" width="2096" height="1174" class="alignnone size-full wp-image-5119 shadow" src="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png" data-src="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png?x-oss-process=image/format,webp" alt="" srcset="https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png?x-oss-process=image/format,webp 2096w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png?x-oss-process=image/quality,q_50/resize,m_fill,w_300,h_168/format,webp 300w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png?x-oss-process=image/quality,q_50/resize,m_fill,w_768,h_430/format,webp 768w, https://haomou.oss-cn-beijing.aliyuncs.com/upload/2019/10/img_5db7fa5c1b3c4.png?x-oss-process=image/quality,q_50/resize,m_fill,w_800,h_448/format,webp 800w" sizes="(max-width: 2096px) 100vw, 2096px" />

在这里需要稍注意一下,在一个元素上附加了 Shadow DOM 后,元素原本的「子元素」将不会再显示,并且这些子元素也不在 Shadow DOM 中,只有 host.shadowRoot  的子元素才是「子 DOM 树」中一部分。也就是说这个「子 DOM 树」的「根节点」是 host.shadowRoot 而非 host。 host.shadowRoot 是 ShadowRoot 的实例,而 ShadowRoot 则继承于 DocumentFragment,可通过原生 DOM API 操作其子元素。



[我们](https://www.w3cdoc.com)需通过 Element.attachShadow 附加到元素,然后就能拿到附加后的 ShadowRoot 实例。 针对 ShadowRoot 这样一个原生 DOM Node 的的引用,除了利用 ReactDOM.render 或 ReactDOM.createPortal  ,[我们](https://www.w3cdoc.com)并不能轻易的将 React.Element 渲染到其中,除非直接接操作 DOM。



<h2 id="10">
  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>
      内部单击
    </ShadowView>
    外部单击
  </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 改造一下

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

利用 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={onBtnClick}>单击我</button>
  </ShadowView>
}
}

ReactDOM.render(<App />, document.getElementById("root"));

Wow! 一切正常,有一个小问题是 createPortal 不支持 React 16 以下的版本,但大多数情况下这并不是个什么大问题。

面向 React 的 ShadowView 组件

上边提到了几种在 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'));

组件属性

属性名类型说明
classNamestring组件自身 className
styleany组件自身的内联样式
styleContentstring作用于 ShadowView 内部的样式
styleSheetsstring[]作用于 ShadowView 内部的外联样式表
scopedboolean是否开始隔离,默认为 true
tagNamestring外层容器 tagName,默认为 shadow-view

那么,ShadowView 是如何兼容支持 React 15 的呢? 可在 https://github.com/Houfeng/shadow-view 一探究竟。

END



请遵守《互联网环境法规》文明发言,欢迎讨论问题
扫码反馈

扫一扫,反馈当前页面

咨询反馈
扫码关注
返回顶部