JavaScript 是如何工作:Shadow DOM 的内部结构 + 如何编写独立的组件!

摘要: 深入JS系列17。

Fundebug经授权转载,版权归原作者所有。

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 17 篇。

如果你错过了前面的章节,可以在这里找到它们:

image

概述

Web Components 是一套不同的技术,允许你创建可重用的定制元素,它们的功能封装在你的代码之外,你可以在 Web 应用中使用它们。

Web组件由四部分组成:

  • Shadow DOM(影子DOM)
  • HTML templates(HTML模板)
  • Custom elements(自定义元素)
  • HTML Imports(HTML导入)

在本文中主要讲解 Shadow DOM(影子DOM)

Shadow DOM 这款工具旨在构建基于组件的应用。因此,可为网络开发中的常见问题提供解决方案:

  • 隔离 DOM:组件的 DOM 是独立的(例如,document.querySelector() 不会返回组件 shadow DOM 中的节点)。
  • 作用域 CSS:shadow DOM 内部定义的 CSS 在其作用域内。样式规则不会泄漏,页面样式也不会渗入。
  • 组合:为组件设计一个声明性、基于标记的 API。
  • 简化 CSS - 作用域 DOM 意味着您可以使用简单的 CSS 选择器,更通用的 id/类名称,而无需担心命名冲突。

Shadow DOM

本文假设你已经熟悉 DOM 及其它的 Api 的概念。如果不熟悉,可以在这里阅读关于它的详细文章—— https://developer.mozilla.org...

阴影 DOM 只是一个普通的 DOM,除了两个区别:

  • 创建/使用的方式

  • 与页面其他部分有关的行为方式

    通常,你创建 DOM 节点并将其附加至其他元素作为子项。 借助于 shadow DOM,您可以创建作用域 DOM 树,该 DOM 树附加至该元素上,但与其自身真正的子项分离开来。这一作用域子树称为影子树。被附着的元素称为影子宿主。 您在影子中添加的任何项均将成为宿主元素的本地项,包括 <style>。 这就是 shadow DOM 实现 CSS 样式作用域的方式

通常,创建 DOM 节点并将它们作为子元素追加到另一个元素中。借助于 shadow DOM,创建一个作用域 DOM 树,附该 DOM 树附加到元素上,但它与实际的子元素是分离的。这个作用域的子树称为 影子树,被附着的元素称为影子宿主。向影子树添加的任何内容都将成为宿主元素的本地元素,包括 <style>,这就是 影子DOM 实现 CSS 样式作用域的方式。

创建 shadow DOM

影子根是附加到“宿主”元素的文档片段,元素通过附加影子根来获取其 shadow DOM。要为元素创建阴影 DOM,调用 element.attachShadow() :

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

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

规范定义了元素列表,这些元素无法托管影子树,元素之所以在所选之列,其原因如下:

  • 浏览器已为该元素托管其自身的内部 shadow DOM(<textarea><input>)。
  • 让元素托管 shadow DOM 毫无意义 (<img>)。

例如,以下方法行不通:

document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.

Light DOM

这是组件用户写入的标记。该 DOM 不在组件 shadow DOM 之内,它是元素的实际孩子。假设已经创建了一个名为<extended-button> 的定制组件,它扩展了原生 HTML 按钮组件,此时希望在其中添加图像和一些文本。代码如下:

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

“extension -button” 是定义的定制组件,其中的 HTML 称为 Light DOM,该组件由用户自己添加。

这里的 Shadow DOM 是你创建的组件 extension-button。Shadow DOM是 组件的本地组件,它定义了组件的内部结构、作用域 CSS 和 封装实现细节。

扁平 DOM 树

浏览器将用户创建的 Light DOM 分发到 Shadow DOM,并对最终产品进行渲染。扁平树是最终在 DevTools 中看到的以及页面上呈渲染的对象。

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

如果需要 Web 页面上重复使用相同的标签结构时,最好使用某种类型的模板,而不是一遍又一遍地重复相同的结构。这在以前也是可以实现,但是 HTML <template> 元素(在现代浏览器中得到了很好的支持)使它变得容易得多。此元素及其内容不在 DOM 中渲染,但可以使用 JavaScript 引用它。

一个简单的例子:

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

这不会出现在页面中,直到使用 JavaScrip t引用它,然后使用如下方式将其追加到 DOM 中:

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

到目前为止,已经有其他技术可以实现类似的行为,但是,正如前面提到的,将其原生封装起来是非常好的,Templates 也有相当不错的浏览器支持:

image

模板本身是有用的,但它们与自定义元素配合会更好。 可以 customElement Api 能定义一个自定义元素,并且告知 HTML 解析器如何正确地构造一个元素,以及在该元素的属性变化时执行相应的处理。

让我们定义一个 Web 组件名为 <my-paragraph>,该组件使用之前模板作为它的 Shadow DOM 的内容:

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 DOM 中,所以可以在模板中使用 元素的形式包含一些样式信息,然后将其封装在自定义元素中。如果只是将其追加到标准 DOM 中,它是无法工作。

例如,可以将模板更改为:

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

现在自定义组件可以这样使用:

<my-paragraph></my-paragraph>

<slot> 元素

模板有一些缺点,主要是静态内容,它不允许我们渲染变量/数据,好可以让我们按照一般使用的标准 HTML 模板的习惯来编写代码。Slot 是组件内部的占位符,用户可以使用自己的标记来填充。让我们看看上面的模板怎么使用 slot

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

可以插入插槽的元素称为 Slotable; 当一个元素插入一个插槽时,它被称为开槽 (slotted)。

注意,在上面的例子中,插入了一个 <span> 元素,它是一个开槽元素,它有一个属性 slot,它等于 my-text,与模板中的 slot 定义中的 name 属性的值相同。

在浏览器中渲染后,上面的代码将构建以下扁平 DOM 树:

<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 DOM 的组件可通过主页来设定样式,定义其自己的样式或提供钩子(以 CSS 自定义属性的形式)让用户替换默认值。

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

组件定义的样式

作用域 CSS 是 Shadow DOM 最大的特性之一:

  • 外部页面的 CSS 选择器不应用于组件内部
  • 组件内定义的样式不会影响页面的其他元素,它们的作用域是宿主元素

shadow DOM 内部使用的 CSS 选择器在本地应用于组件实际上,这意味着我们可以再次使用公共vid/类名,而不用担心页面上其他地方的冲突,最佳做法是在 Shadow DOM 内使用更简单的 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的本地样式。使用<link>元素在#shadow-root中引入样式表,这些样式表也都属于本地的。

:host 伪类选择器

使用 :host 伪类选择器,用来选择组件宿主元素中的元素 (相对于组件模板内部的元素)。

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

当涉及到 :host 选择器时,应该小心一件事:父页面中的规则具有比元素中定义的 :host 规则具有更高的优先级,这允许用户从外部覆盖顶级样式。而且 :host 只在影子根目录下工作,所以你不能在Shadow DOM 之外使用它。

如果 :host(<selector>) 的函数形式与 <selector> 匹配,你可以指定宿主,对于你的组件而言,这是一个很好的方法,它可让你基于宿主将对用户互动或状态的反应行为进行封装,或对内部节点进行样式设定:

<style>
  :host {
    opacity: 0.4;
  }
  
  :host(:hover) {
    opacity: 1;
  }
  
  :host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
  }
  
  :host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
  }
</style>

:host-context(<selector>)

:host-context(<selector>) 或其任意父级与 <selector> 匹配,它将与组件匹配。 例如,在文档的元素上可能有一个用于表示样式主题 (theme) 的 CSS 类,而我们应当基于它来决定组件的样式。
比如,很多人都通过将类应用到 <html> 或 <body> 进行主题化:

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

在下面的例子中,只有当某个祖先元素有 CSS 类theme-light时,我们才会把background-color样式应用到组件内部的所有元素中:

:host-context(.theme-light) h2 {
  background-color: #eef;
}

/deep/

组件样式通常只会作用于组件自身的 HTML 上,我们可以使用 /deep/ 选择器,来强制一个样式对各级子组件的视图也生效,它不但作用于组件的子视图,也会作用于组件的内容。

在下面例子中,我们以所有的元素为目标,从宿主元素到当前元素再到 DOM 中的所有子元素:

:host /deep/ h3 {
  font-style: italic;
}

/deep/ 选择器还有一个别名 >>>,可以任意交替使用它们。

/deep/>>> 选择器只能被用在仿真 (emulated)模式下。 这种方式是默认值,也是用得最多的方式。

从外部为组件设定样式

有几种方法可从外部为组件设定样式:最简单的方法是使用标记名称作为选择器,如下

custom-container {
  color: red;
}

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

例如,如果用户编写选择器:

custom-container {
  width: 500px;
}

它将覆盖组件的样式:

:host {
  width: 300px;
}

对组件本身进行样式化只能到此为止。但是如果人想要对组件的内部进行样式化,会发生什么情况呢?为此,我们需要 CSS 自定义属性。

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

如果组件的开发者通过 CSS 自定义属性提供样式钩子,则用户可调整内部样式。其思想类似于<slot>,但适用于样式。

看看下面的例子:

<!-- main page -->
<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;
}

在本例中,该组件将使用 black 作为背景值,因为用户指定了该值,否则,背景颜色将采用默认值 #CECECE

作为组件的作者,是有责任让开发人员了解他们可以使用的 CSS 定制属性,将其视为组件的公共接口的一部分。

在 JS 中使用 slot

Shadow DOM API 提供了使用 slot 和分布式节点的实用程序,这些实用程序在编写自定义元素时迟早派得上用场。

slotchange 事件

slot 的分布式节点发生变化时,slotchange 事件将触发。例如,如果用户从 light DOM 中添加/删除子元素。

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

要监视对 light DOM 的其他类型的更改,可以在元素的构造函数中使用 MutationObserver。以前讨论过 MutationObserver 的内部结构以及如何使用它

assignedNodes() 方法

有时候,了解哪些元素与 slot 相关联非常有用。调用 slot.assignedNodes() 可查看 slot 正在渲染哪些元素。 {flatten: true} 选项将返回 slot 的备用内容(前提是没有分布任何节点)。

让我们看看下面的例子:

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

假设这是在一个名为 <my-container> 的组件中。

看看这个组件的不同用法,以及调用 assignedNodes() 的结果是什么:

在第一种情况下,我们将向 slot 中添加我们自己的内容:

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

调用 assignedNodes() 会得到 [<span slot= " slot1 " > container text </span>],注意,结果是一个节点数组。

在第二种情况下,将内容置空:

<my-container> </my-container>

调用 assignedNodes() 的结果将返回一个空数组 []

在第三种情况下,调用 slot.assignedNodes({flatten: true}),得到结果是: [<p>默认内容</p>]

此外,要访问 slot 中的元素,可以调用 assignedNodes() 来查看元素分配给哪个组件 slot

事件模型

值得注意的是,当发生在 Shadow DOM 中的事件冒泡时,会发生什么。

当事件从 Shadow DOM 中触发时,其目标将会调整为维持 Shadow DOM 提供的封装。也就是说,事件的目标重新进行了设定,因此这些事件看起来像是来自组件,而不是来自 Shadow DOM 中的内部元素。

下面是从 Shadow DOM 传播出去的事件列表(有些没有):

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

自定义事件

默认情况下,自定义事件不会传播到 Shadow DOM 之外。如果希望分派自定义事件并使其传播,则需要添加 bubbles: truecomposed: true 选项。

让我们看看派发这样的事件是什么样的:

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

浏览器支持

如希望获得 shadow DOM 检测功能,请查看是否存在 attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
image

有史以来第一次,我们拥有了实施适当 CSS 作用域、DOM 作用域的 API 原语,并且有真正意义上的组合。 与自定义元素等其他网络组件 API 组合后,shadow DOM 提供了一种编写真正封装组件的方法,无需花多大的功夫或使用如 <iframe> 等陈旧的东西。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了9亿+错误事件,付费客户有Google、360、金山软件、百姓网等众多品牌企业。欢迎大家免费试用

[图片上传失败...(image-a6bff-1548725834164)]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

  • 第一部分 HTML&CSS整理答案 1. 什么是HTML5? 答:HTML5是最新的HTML标准。 注意:讲述HT...
    kismetajun阅读 27,406评论 1 45
  • 前端开发面试题 <a name='preface'>前言</a> 只看问题点这里 看全部问题和答案点这里 本文由我...
    自you是敏感词阅读 750评论 0 3
  • 简介浏览器可以被认为是使用最广泛的软件,本文将介绍浏览器的工 作原理,我们将看到,从你在地址栏输入google.c...
    听风阁阅读 3,270评论 0 7
  • 问答题47 /72 常见浏览器兼容性问题与解决方案? 参考答案 (1)浏览器兼容问题一:不同浏览器的标签默认的外补...
    _Yfling阅读 13,725评论 1 92
  • 今天是二十四节气中的“大雪”,入冬以来最冷的一天。尽管没有下雪,但冷风嗖嗖地刮着,路上的行人都包裹得严严实...
    小轩仔0808阅读 109评论 0 0