Shadow DOM 内部构造及如何构建独立组件

原文请查阅 这里,略有删减,本文采用 知识共享署名 4.0 国际许可协议共享,BY Troland

这是 JavaScript 工做原理的第十七章。javascript

概述

网页组件指的是容许开发者使用一系列不一样的技术来建立可复用的自定义元素,组件内的功能不影响其它代码,以便于开发者在网页程序中使用。html

有四种网页组件标准:html5

  • Shadow DOM
  • HTML 模板
  • 自定义元素
  • HTML Imports

本章主要讨论 Shadow DOMjava

Shadow DOM 是一个被设计用来构建基于组件(积木式)的网页程序的工具。它为开发者可能常常遇到过的问题提供了解决方案:git

  • 隔离的 DOM:组件的 DOM 是独立的(好比 document.querySelector() 没法检索到组件 shadow DOM 下的f元素节点)。这样就能够简化网页程序中的 CSS 选择器,由于 DOM 组件是互不影响,这样就容许开发者能够为所欲为地使用更加通用的 id/class 命名而不用担忧命名冲突。
  • 局部样式: shadow DOM 内定义的样式不会污染 shadow DOM 以外的元素。Style 样式规则不会泄漏且页面样式也不会污染 shadow DOM 内的元素样式。
  • 组合:为开发者的组件设计一个声明式,基于标签的接口。

Shadow DOM

本篇文章假设开发者已经对 DOM 及其 API 熟拈于心。不然,能够阅读一下这方面的详细资料github

与通常的 DOM 元素相比,Shadow DOM 有两处不一样的地方:web

  • 与通常建立和使用 DOM 的方式相比,开发者如何建立及使用 Shadow DOM 及其与页面上的其它元素的关系
  • 其展示形式与页面上的其它元素的关系

通常状况下,开发者建立 DOM 节点,而后将其做为子元素挂载到其它元素下。对于 shadow DOM,开发者建立一个独立 DOM 树挂载到目标元素下而该树和其实际子元素是分离的。该独立子树称为 shadow 树。shadow 树的挂载元素称为 shadow 宿主。包括 <style> 在内的全部在 shadow 树下建立的任何标签都只做用于宿主元素内部。此即 shadow DOM 如何实现 CSS 局部样式化的原理。数组

建立 Shadow DOM

一个 shadow 根 便是一段挂载到 "宿主" 元素下的文档碎片。挂载了 shadow 根即表示宿主元素包含 shadow DOM。调用 element.attachShadow() 方法来为元素建立 shadow 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 树的元素列表。session

Shadow DOM 组合功能

组合元素是 Shadow DOM 最重要的功能之一。

当书写 HTML 的时候,组合元素构建网页程序。开发者组合及嵌套诸如 <div><header><form> 及其它不一样的构建模块来构建网页程序所需的界面。其中某些标签甚至能够互相兼容。

元素组合定义了诸如为什么 <select><form><video> 及其它元素是可扩展的且接受特定的 HTML 元素做为子元素以便用来对这些元素进行特殊处理。

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

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

Light DOM

此即组件的书写标记。该 DOM 存在于组件的 shadow DOM 以外。它是元素的实际子元素。假设开发者建立了一个名为 <better-button> 的自定义组件,扩展原生 button 标签及想在组件内部添加一个图片和一些文本。大概以下:

<extended-button>
  <!-- image 和 span 即为扩展 button 的 light DOM -->
  <img src="boot.png" slot="image">
  <span>Launch</span>
</extended-button>

「扩展 button」即开发者自定义组件,而其中的 HTML 即为 Light DOM 且是使用组件的用户所添加的。

这里的 Shadow DOM 即开发者建立的组件(「扩展 button」)。Shadow DOM 仅存在于组件内部且在其中定义其内部结构,局部样式及封装了组件实现详情。

扁平 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>

模板

当开发者不得不在网页上复用相同的标记结构的时候,最好使用某种模板而不是重复书写相同的页面结构。之前是能够实现的,可是如今可使用 <template> (现代浏览器均兼容)元素轻易地实现该功能。该元素及其内容不会在 DOM 中渲染,可是可使用 JavaScript 来引用其中的内容。

来看一个简单示例:

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

上面的内容不会在页面中渲染,除非使用 JavaScript 来引用其中的内容,而后使用相似以下的代码来挂载到 DOM 中:

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

迄今为止,可使用其它技术来实现相似的功能,可是正如以前所提到的,尽可能使用原生功能来实现可能会更酷些。另外,兼容性也蛮好。

自己模板就很好用,可是若和自定义元素配合使用会更好哦。咱们将会另外的文章中介绍自定义元素,当下开发者只需了解 customElement 接口容许开发者自定义标签内容的渲染。

让咱们定义一个使用模板做为其 shadow DOM 渲染内容的网页组件。且称其为 <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() 方法来复制模板内容挂载到 shadow 根下。

另外,因为把模板的内容挂载到 shadow DOM 中,开发者能够在模板中使用 <style> 元素包含一些样式信息,该 <style> 元素随后会被封装进自定义元素里面。若是直接把模板挂载到标准 DOM 里面是不起做用的。

好比,能够更改模板内容为以下:

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

能够以以下方式使用刚才使用模板建立的自定义组件:

<my-paragraph></my-paragraph>

插槽

模板有一些不足的地方,主要的不足在于静态内容不容许开发者像通常的标准 HTML 模板那样渲染自定义的变量或者数据。

这时候 <slot> 就派上用场了。

能够把插槽当作是容许开发者在模板中放置自定义 HTML 的占位符的功能。这样开发者就能够建立能用的 HTML 模板而且经过引入插槽来自定义渲染内容。

让咱们看一下以上模板添加一个插槽的代码以下:

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

若是在标记中引用该元素的时候没有定义插槽内容,或者浏览器不支持插槽,则 <my-paragraph> 只会包含默认的 "Default text" 内容。

若想要定义插槽内容,开发者得在 <my-paragraph> 中定义元素 HTML 结构的 slot 属性值和对应填充的插槽名称保持一致便可。

如前所述,开发者能够随便写插槽内容:

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

全部能够被插入插槽的元素被称为可插入元素;已插入插槽元素称为插槽元素。

注意以上示例中插入的 <span> 元素便是插槽元素。它拥有一个 slot 属性,属性值和模板中插槽定义的 name 属性值相等。

浏览器渲染以后,以上代码会建立以下扁平 DOM 树:

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

这里原文有误,有改动。

注意 #shadow-root 元素只是表示存在 Shadow DOM 而已。

样式化

能够在主页面样式化含有 shadow DOM 的组件,能够定义组件样式或者提供 CSS 自定义属性的形式让用户覆盖掉默认样式值。

组件定义的样式

局部样式 是 Shadow DOM 极好的功能之一:

  • 主页面上的 CSS 选择器不会影响到组件内部元素的样式。
  • 组件内部定义的样式不会影响页面上的其它元素样式。它们只做用于宿主元素。

Shadow DOM 中的 CSS 选择器只影响组件内部的元素。实际上,这意味着开发者能够重复使用通用的 id/class 名称而不用担忧和主页面上的其它样式发生冲突。简单的 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 内部。

开发者也能够在 #shadow-root 里面使用 <link> 元素来引入样式表,也只做用于 #shadow-root 内部。

:host 伪类

:host 伪类容许开发者选择和样式化包含 shadow 树的宿主元素:

<style>
  :host {
    display: block; /* 默认状况下, 自定义元素是内联元素 */
  }
</style>

只有一个地方须要注意即若主页面上定义的宿主元素样式优先级比元素里面定义的 :host 样式规则要高。这样就容许开发者从外部覆盖掉组件内部定义的顶级样式。

即当在主页面上定义了以下的样式:

my-paragraph {
  marbin-bottom: 40px;
}

<template id="my-paragraph">
    <style>
        :host {
      margin-bottom: 30px;/* 将不起做用,由于会被前面父页面已定义的样式覆盖 */
        }
    </style>
  <p> 
    <slot name="my-text">Default text</slot> 
  </p>
</template>

同理,:host 只在shadow 根的上下文中起做用,所以开发者不可以在 Shadow DOM 外面使用。

:host(<selector>) 这样的功能样式容许开发者只样式化匹配 <selector> 的宿主元素。这是一个绝佳的方式,开发者能够在组件内部封装响应用户交互或者状态的行为,而后基于宿主元素来样式化内部节点。

<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(<selector>) 伪类来定制化元素样式

:host-context(<selector>) 伪类找出宿主元素或者宿主元素任意的祖先元素匹配 <selector>

经常使用于定制化。例如,开发者经过为 <html> 或者 <body> 添加类来进行定制化:

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

或者

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

当宿主元素的祖先元素包含有 .lightheme 类 :host-context(.lightheme) 将会样式化 <fancy-tabs>

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

可使用 :host-context() 来进行定制化主题样式,可是更好的方法即经过 CSS 自定义属性来建立样式钩子。

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

开发者能够从外部经过把标签名做为选择器来样式化组件宿主元素,以下:

custom-container {
  color: red;
}

外部样式比 Shadow DOM 中定义的样式拥有更高的优先级。

例如,假设用户书写以下选择器:

custom-container {
  width: 500px;
}

将会覆盖以下组件样式规则 :

:host {
  width: 300px;
}

组件自身样式化只能作到这么多。但若是想要样式化组件内部属性呢?这就须要 CSS 自定义属性。

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

若组件做者使用 CSS 自定义属性提供样式钩子,用户能够用来更改内部样式。

这和 <slot> 思路相似只是应用到了样式。

让咱们看以下示例:

<!-- 主页面 -->
<style>
  custom-container {
    margin-bottom: 60px;
    --custom-container-bg: black;
  }
</style>

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

Shadow DOM 内部:

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

该示例中,由于用户提供了该背景颜色值,因此组件将会把黑色做为背景颜色值。不然,默认为 #CECECE

做为组件做者,须要让开发者知道可使用的 CSS 自定义属性。能够把自定义属性看做组件的公共接口。

插槽 JavaScript 接口

Shadow DOM API 可能用来操做插槽。

slotchange 事件

当一个插槽的分发元素节点发生变化的时候触发 slotchange 事件。例如,当用户从 light DOM 中添加/删除子节点。

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

能够在元素的构造函数中建立 MutationObserver 来监听 light DOM 的其它类型的修改事件。前面文章中有介绍过 MutationObserver 的内部构造及使用指南

assignedNodes() 方法

了解哪些元素是和插槽有关是颇有用处的。调用 slot.assignedNodes() 能够找出哪些元素是由插槽渲染的。flatten: true} 选项会返回插槽的默认内容(若没有分发任何节点)。

看一下以下示例:

<slot name='slot1'><p>Default content</p></slot>

假设以上内容包含在一个叫作 <my-container> 的组件内部。

让咱们查看一下该组件的不一样用法,而后调用 assignedNodes() 输出不一样的结果:

第一例中,咱们将往插槽中添加内容:

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

调用 assignedNodes() 将会返回 [<span slot="slot1"> container text </span>]。注意结果为一个节点数组。

第二例中,将不添加内容:

<my-container> </my-container>

调用 assignedNodes() 将会返回空数组 []

可是,假设添加 {flatten: true} 参数将会返回默认内容:[<p>Default content</p>]

同理,为了查找插槽中的元素,开发者能够调用 assignedNodes() 来找出元素被挂载到哪一个组件插槽中。

事件模型

Shadow DOM 中的事件冒泡的通过是值得注意的。

事件目标被调整为维护 Shadow DOM 的封闭性。当事件被从新定位,看起来是由组件自身产生而不是组件的 Shadow DOM 内部元素。

这里有传播出 Shadow DOM 的事件列表(还有一些只能在 Shadow DOM 内传播):

  • Focus 事件:blur, focus, focusin, focusout
  • 鼠标事件:click, dblclick, mousedown, mouseenter, mousemove 等.
  • 滚轮事件: wheel
  • 输入事件: beforeinput, input
  • 键盘事件: keydown, keyup
  • 组合事件: compositionstart, compositionupdate, compositionend
  • 拖拽事件: dragstart, drag, dragend, drop 等.

自定义事件

默认状况下,自定义事件不会传播出 Shadow DOM。开发者若想要分派自定义事件且想要传播出 Shadow DOM,须要添加 bubbles: truecomposed: true 选项参数。

让咱们瞧瞧相似这样的事件分派:

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

浏览器兼容状况

能够经过检查 attachShadow 来检查是否支持 Shadow DOM 功能:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

参考资料:

相关文章
相关标签/搜索