边学边译JS工作机制---17.影子DOM 内部构造及如何构建独立组件

285 阅读8分钟

本系列其他译文请看[JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)] (juejin.cn/column/6988…

本章阅读指数:3
本地讲影子DOM,影子DOM可能是未来的一种组件化方向,但是目前没有大规模应用。视情况阅读。

影子DOM即Shadow DOM

image.png

概述

网络组件是一种不同的技术,可以让你创建可复用的自定义元素。这些元素是被封装的,跟其他的代码没有关系,可以在网络应用中使用它们。 我们有4个网络组件的标准:

  • Shadow DOM

  • HTML Templates

  • Custom elements

  • HTML Imports 这一章我们讨论Shadow DOM,也即影子DOM。
    影子DOM是构建基于组件的应用的工具。它给这些问题提供了一个解决方案:

  • 隔离 DOM: 组件的DOM是独立的(比如document.querySelector()就不会返回组件影子DOM里的节点)。这样就可以使用最简单的CSS选择器,而不用担心命名冲突。

  • 局部CSS:影子DOM内部的CSS,作用范围是在DOM内部。局部的样式不会泄漏,也不会被外部影响。

  • 组合: 为你的组件设计一个声明式的,基于标签的API。

影子DOM

如果还不熟悉影子DOM的概念和API,可以看看这里developer.mozilla.org/en-US/docs/….
和普通DOM相比,影子DOM有两点不同:

  • 个其他页面建立和使用关联的形式
  • 跟其他页面关联的变现形式

一般情况下,你可以创建一个DOM节点,然后添加到其他元素上的子节点上。至于影子DOM,则创建一个局部的DOM树,添加到元素的子节点,这个树是和元素的实际子节点分离的。这个局部的子树,叫做shadow tree。宿主元素则叫shadow host。包括 <style> 在内的所有在 shadow 树下创建的任何标签都只作用于宿主元素内部。此即 shadow DOM 如何实现 CSS 局部样式化的原理。

创建影子树

shadow root 是附加到宿主元素的文档片段。宿主元素得到它的影子DOM时,就添加了一个shadow root。调用element.attachShadow(),可以为元素创建一个影子DOM。

var header = document.createElement('header');
var shadowRoot = header.attachShadow({mode: 'open'});
var paragraphElement = document.createElement('p');

paragraphElement.innerText = 'Shadow DOM';
shadowRoot.appendChild(paragraphElement);

这个 声明定义了一些不能挂载shadow tree的元素。

影子DOM的组合

组合是影子DOM最重要的特性之一。 写HTML时,需要组合来构建网络应用。构建应用,需要合并和嵌套不同的构建块(元素)比如<div><header><form>等等。
元素组合定义了诸如为何 <select><form><video> 及其它元素是可扩展的且接受特定的 HTML 元素作为子元素以便用来对这些元素进行特殊处理。

比如,<select> 元素知道如何把 <option> 元素渲染成为带有预定义选项的下拉框组件。

Shadow DOM 引入如下功能,可以用来组合元素。

Light DOM

这是组件书写的用户标记。这个DOM在组件影子DOM之外。它是元素的真实子节点。 假如你创建了一个自定义组件,它继承了原生的HTML button,并在内部添加了图像和文本。

<extended-button>
  <!-- the image and span are extended-button's light DOM -->
  <img src="boot.png" slot="image">
  <span>Launch</span>
</extended-button>

“extended-button”中的HTML就是Light DOM,这是被用户添加的。 "extended-button"组件就是影子DOM。影子DOM定义了内部结构,局部CSS,封装可很多实现细节。

扁平DOM树

浏览器分发 light DOM 的结果即,由用户在 Shadow DOM 内部创建的 HTML 内容,这些 HTML 内容构成了自定义组件的结构,渲染出最后的产品界面。扁平树即开发者在开发者工具中看到的内容和页面的渲染结果。

<extended-button>
  #shadow-root
  <style></style>
  <slot name="image">
    <img src="boot.png" slot="image">
  </slot>
  <span id="container">
    <slot>
      <span>Launch</span>
    </slot>
  </span>
</extended-button>

Templates

如果你需要在页面中重复使用相同的标记结构,那么最好使用一些template。以前就能这么做,但是现在使用<template>更加的方便。<template>不会被渲染,但是可以在JS中引用。 比如:

<template id="my-paragraph">
  <p> Paragraph content. </p>
</template>

这段代码不会被渲染。你需要在JS中引用,然后把它添加一个dom上

var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);

也有一些其他的手段实现类似的效果,但是相对来讲,<template>的支持性更好。 看看它的支持情况:

image.png Templates 可以自己工作,也可以在定制元素时使用效果更佳。 这一章我们讨论浏览器提供的customElement 接口允许开发者自定义标签内容的渲染。

现在定义一个内容是影子DOM是template的组件,<my-paragraph>

customElements.define('my-paragraph',
 class extends HTMLElement {
   constructor() {
     super();

     let template = document.getElementById('my-paragraph');
     let templateContent = template.content;
     const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(templateContent.cloneNode(true));
  }
});

我们使用了Node.cloneNode()方法来创建一个template的副本,然后添加到shadow root上。 在template中,我们可以使用<style>来包含一些样式,这些样式会被组件封装起来。当然,在将其附加到一个标准DOM之前,它是不生效的。

<template id="my-paragraph">
  <style>
    p {
      color: white;
      background-color: #666;
      padding: 5px;
    }
  </style>
  <p>Paragraph content. </p>
</template>

这样,我们就可以在页面中这么使用它了:

Slots

template 有一些弊端,最大的问题是它的内容是静态的,我们无法渲染变量/数据,无法像标准的HTML模板那样使用。 可以解决这个问题。 可以将理解为占位符,可以让你放置你自己的HTML元素。这样,你可以创建通用性的HTML模板,并且添加来进行定制。 看看使用slot是怎么工作的:

<template id="my-paragraph">
  <p> 
    <slot name="my-text">Default text</slot> 
  </p>
</template>

如果在标记中引用该元素的时候没有定义slot内容,或者浏览器不支持插槽,则 <my-paragraph> 只会包含默认的 "Default text" 内容。 为了定义slot内容,我们需要在中包含HTML结构。我们使用一个slot属性,它的值就是你想插入的slot的名字。 看下代码:

<my-paragraph>
 <span slot="my-text">Let's have some different text!</span>
</my-paragraph>

可以插入slot的元素叫Slotable 已经插入进去的元素,叫做slotted

上面的例子中<span>就是slotted元素。它具有一个slot属性,值为'my-text',和模板中插槽定义的 name 属性值相等。 浏览器渲染之后,我们能看到这样的扁平树结构:

<my-paragraph>
  #shadow-root
  <p>
    <slot name="my-text">
      <span slot="my-text">Let's have some different text!</span>
    </slot>
  </p>
</my-paragraph>

注意#shadow-root只是表示,有一个影子DOM。

样式

使用影子DOM的组件,可以被主页面定义样式,也可以自己定义,还可以提供一些钩子让用户重写。

组件定义样式

影子DOM的重要特性之一是局部CSS

  • 外部页面的CSS选择器不会影响到你的组件内部
  • 组件内部定义的样式,也不会影响页面的其他部分。他们只作用于宿主元素的范围。 在影子DOM中使用CSS选择器,只会影响组件内部。这样,你就可以使用一些重名的id/类名,不用担心在页面其他部分产生命名冲突。要知道,简单的CSS选择器会有更好的性能。 看看下面的#shadow-root如何定义样式:
#shadow-root
<style>
  #container {
    background: white;
  }
  #container-items {
    display: inline-flex;
  }
</style>

<div id="container"></div>
<div id="container-items"></div>

例子中的样式,都只局限于#shadow-root内部。你还可以使用标签加载样式脚本,同样只是局部生效。

:host 伪类

:host允许你选择并且样式化寄宿了shadow tree的元素。

<style>
  :host {
    display: block; /* by default, custom elements are display: inline */
  }
</style>

使用:host需要注意一个事情,父页面的样式规则具有更高的权重,:host定义的规则权重要低一些。这样,就允许用户从外部来重写顶层样式。同样,:host只会在shadow root的上下文中生效。
:host()功能模式允许你关联匹配的宿主元素。这样你可以封装用户的交互和状态的行为,并且基于宿主节点样式化内部节点。

<style>
  :host {
    opacity: 0.4;
  }
  
  :host(:hover) {
    opacity: 1;
  }
  
  :host([disabled]) { /* 宿主元素拥有 disabled 属性的样式. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
  }
  
  :host(.pink) > #tabs {
    color: pink; /* 当宿主元素含有 pink 类时的选项卡样式. */
  }
</style>

使用:host-context()伪类的主题和元素

:host-context()伪类找出宿主元素或者宿主元素任意的祖先元素匹配 <selector>。 常见的使用场景是定制主题。很多人会给<html> 或者 <body>上应用样式,来实现定制主题。

<body class="lightheme">
  <custom-container></custom-container>
</body>

当是.lightheme子节点的时候,:host-context(.lightheme)将会样式化它。

:host-context(.lightheme) {
  color: black;
  background: white;
}

从外部样式化组件的宿主元素

开发者可以从外部通过把标签名作为选择器来样式化组件宿主元素,如下:

custom-container {
  color: red;
}

相比影子DOM内部的样式,外部样式的邮件及更高。 例如,如果用户这么写选择器:

custom-container {
  width: 500px;
}

就会覆盖掉组件的样式规则:

:host {
  width: 300px;
}

样式化组件只能做到这一步了。但是如果我们想样式化组件内部怎么办?我们需要CSS自定义属性

使用CSS自定义属性创建样式钩子

如果组件开发者使用CSS自定义属性提供了样式钩子,我们就可以改变它的内部样式。这概念有点像。 看一个例子:

<!-- main page -->
<style>
  custom-container {
    margin-bottom: 60px;
     - custom-container-bg: black;
  }
</style>

<custom-container background></custom-container>

影子DOM内部:

:host([background]) {
  background: var( - custom-container-bg, #CECECE);
  border-radius: 10px;
  padding: 10px;
}

由于用户提供了背景颜色,所有组件会使用相同的颜色,也就是#CECECE。 作为组件的作者,你有责任让后面开发之道哪些CSS自定义属性可以使用。将它们作为组件公共接口的一部分。

插槽JS API

影子 DOM API 可能用来操作插槽

slotchange事件

当slot 分发的节点改变了,就会触发这个事件。比如,我们添加/删除了light dom的子节点

var slot = this.shadowRoot.querySelector('#some_slot');
slot.addEventListener('slotchange', function(e) {
  console.log('Light DOM change');
});

为了维护light DOM其他类型的变化,你可以使用MutationObserver。我们之前章节中已经讨论过。

assignedNodes()方法

知道slot关联了什么元素有时候很有用。slot.assignedNodes()方法可以告诉你slot渲染了什么元素。 flatten: true} 选项会返回插槽的默认内容(若没有分发任何节点)。 看一个例子:

Default content

我们假设它在组件之中。 看一下使用这个组件有什么不同,以及调用assignedNodes()返回什么: 首先,我们添加自己的内容到slot

<my-container>
  <span slot="slot1"> container text </span>
</my-container>

调用assignedNodes()将会返回[ container text ],注意这个结果是一个节点数组。

在第二个案例中,我们放一个空内容 assignedNodes()将会返回一个空数组[] 但是此时如果你传入了{flatten: true}参数,你将会得到一个默认结果:[<p>Default content</p>] 为了方位slot内部的元素,你可以调用assignedNodes()去看看你的元素被分配给了哪个组件slot。

事件模型

当影子DOM事件冒泡时发生了什么? 目标事件被调整为维护影子DOM的封闭性。当一个事件被重定位,就好像来自于组件本身,而不是组件中的影子DOM中的元素。 这里有传播出 Shadow DOM 的事件列表(还有一些只能在 Shadow DOM 内传播):


  • Focus Events
    : blur, focus, focusin, focusout

  • Mouse Events: click, dblclick, mousedown, mouseenter, mousemove, etc.

  • Wheel Events: wheel

  • Input Events: beforeinput, input

  • Keyboard Events: keydown, keyup

  • Composition Events: compositionstart, compositionupdate, compositionend

  • DragEvent: dragstart, drag, dragend, drop, etc.

自定义事件

自定义事件默认不会被影子DOM传递出去。如果你想分发一个自定义事件,并且想传播它,你需要增加bubbles: true和composed: true作为选项。 看看如何分发这样一个事件:

var container = this.shadowRoot.querySelector('#container');
container.dispatchEvent(new Event('containerchanged', {bubbles: true, composed: true}));

浏览器支持

通过检查attachShadow属性是否存在,可以判断当前浏览器是否支持影子DOM:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

image.png