[译] 使用 Shadow DOM 封装样式和结构

该系列由 5 篇文章构成,对 Web Components 规范进行了讨论,这是其中的第四部分。在第一部分中,咱们对于 Web Components 的规范和具体作的事情进行了全面的介绍。在第二部分中咱们开始构建一个自定义的模态框,而且建立了 HTML 模版,这在第三部分中将演变为咱们的自定义 HTML 元素。css

系列文章:

  1. Web Components 简介
  2. 编写能够复用的 HTML 模板
  3. 从 0 开始建立自定义元素
  4. 使用 Shadow DOM 封装样式和结构(本文
  5. Web Components 的高级工具

在开始阅读本文以前,咱们建议你先阅读该系列文章中的前三篇,由于本文的工做是以它们为基础构建的。前端

咱们在上文中实现的对话框组件具备特定的外形,结构和行为,可是它在很大程度上依赖于外层的 DOM,它要求使用者必须理解它的基本外形和结构,更不用说容许使用者编写他们本身的样式(最终将修改文档的全局样式)。由于咱们的对话框依赖于 id 为 “one-dialog” 的模板元素的内容,因此每一个文档只能有一个模态框的实例。node

目前对于咱们的对话框组件的限制不必定是坏的。熟悉对话框内部工做原理的使用者能够经过建立本身的 <template> 元素,并定义他们但愿使用的内容和样式(甚至依赖于其余地方定义的全局样式)来轻松地使用对话框。可是,咱们但愿在元素上提供更具体的设计和结构约束以适应最佳实践,所以在本文中,咱们将在元素中使用 shadow DOM。android

什么是 shadow DOM ?

介绍文章中咱们说到,shadow DOM ”可以隔离 CSS 和 JavaScript,和 <iframe> 很是类似“。在 shadow DOM 中选择器和样式不会做用于 shadow root 之外,shadow root 之外的样式也不会影响 shadow DOM 内部。不过有一些特例,像是 font family 或者 font sizes(例如:rem)能够在内部重写覆盖。ios

可是不一样于 <iframe>,全部的 shadow root 仍然存在于同一份文件当中,所以全部的代码均可以在指定的上下文中编写,而没必要担忧和其余样式或者选择器冲突。git

在咱们的对话框中添加 shadow DOM

为了添加一个 shadow root(shadow 树的基本节点/文档片断),咱们须要调用元素的 attachShadow 方法:github

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
}
复制代码

经过调用 attachShadow 方法并设置参数 mode: 'open',咱们在元素的 element.shadowRoot 属性中保存一份对 shadow root 的引用。attachShadow 方法将始终返回一个 shadow root 的引用,可是在这里咱们不会用到它。web

若是咱们调用 attachShadow 方法并设置参数 mode: 'closed',元素上将不会存储任何引用,咱们必须经过使用 WeakMap 或者 Object 来实现存储和检索,将节点自身设置为键,shadow root 设置为值。后端

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}
复制代码

咱们还能够在元素自身上保存对 shadow root 的引用,经过使用 Symbol 或者其余的键来设置 shadow root 为私有属性。浏览器

一般,有一些原生元素(例如:<audio> 或者 <video>),它们会在自身的实现中使用 shadow DOM,shadow root 的关闭模式就是为了这些元素而存在的。此外,基于库的架构方式,在元素的单元测试中,咱们可能没法获取 shadowRoots 对象,致使咱们没法定位到元素内部的更改。

对于用户主动使用关闭模式下的 shadow root 可能存在一些合理的用例,可是数量不多并且目的各不相同,因此咱们将在咱们的对话框中坚持使用 shadow root 的打开模式。

在实现新的打开模式下的 shadow root 以后,你可能注意到如今当咱们尝试运行时,咱们的元素已经彻底没法使用了:

CodePen 中查看对话框示例:使用模板以及 shadow root

这是由于咱们以前拥有的全部内容都被添加在传统 DOM(咱们称之为light DOM)中,并在其中被操做。既然如今咱们的元素上绑定了一个 shadow DOM,那么就没有一个 light DOM 能够渲染的出口。咱们能够经过将内容放到 shadow DOM 中来解决这个问题:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
    }
  }
  
  close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);
复制代码

到目前为止,咱们对话框的主要变化实际上相对较小,但它们带来了很大的影响。首先,咱们全部的选择器(包括咱们的样式定义)都在内部做用域内。例如,咱们的对话框模板内部只有一个按钮,所以咱们的 CSS 只针对 button {...},并且这些样式不会影响到 light DOM。

可是,咱们仍然依赖于元素外部的模板。让咱们经过从模板中删除这些标记并将它们放入 shadow root 的 innerHTML 中来改变它。

CodePen 中查看对话框示例:仅使用 shadow root

渲染来自 light DOM 的内容

shadow DOM 规范包括了一种容许在咱们的自定义元素内,渲染 shadow root 外部的内容的方法。它和 AngularJS 中的 ng-transclude 概念以及在 React 中使用 props.children 都很类似。在 Web Components 中,咱们能够经过使用 <slot> 元素实现。

这里有一个简单的例子:

<div>
  <span>world <!-- this would be inserted into the slot element below --></span>
  <#shadow-root><!-- pseudo code -->
    <p>Hello <slot></slot></p>
  </#shadow-root>
</div>
复制代码

一个给定的 shadow root 能够拥有任意数量的 slot 元素,能够用 name 属性来区分。Shadow root 中没有名称的第一个 slot 将是默认 slot,未分配的全部内容将在该节点内按文档流(从左到右,从上到下)显示。咱们的对话框确实须要两个 slot:标题和一些内容(咱们将设置为默认 slot)。

CodePen 中查看对话框示例:使用 shadow root 以及 slot

继续更改对话框的 HTML 部分并查看结果。Light DOM 内部的任何内容都被放入到分配给它的 slot 中。被插入的内容依旧保留在 light DOM 中,尽管它被渲染的好像在 shadow DOM 中同样。这意味着这些元素的内容和样式均可以由使用者定义。

Shadow root 的使用者经过 CSS ::slotted() 伪选择器,能够有限度地定义 light DOM 中内容的样式;然而,slot 中的 DOM 树是折叠的,因此只有简单的选择器能够工做。换句话说,在前面示例的扁平的 DOM 树中,咱们没法设置在 <p> 元素内部的 <strong> 元素的样式。

一箭双鵰的方法

咱们的对话框目前状态良好:它具备封装、语义标记、样式和行为;然而,一些使用者仍然想要定义他们本身的模板。幸运的是,经过结合两种咱们所学的技术,咱们能够容许使用者有选择地定义外部模板。

为此,咱们将容许组件的每一个实例引用一个可选的模板 ID。首先,咱们须要为组件的 template 定义一个 getter 和 setter。

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}
复制代码

在这里,经过将它直接绑定到相应的属性上,咱们完成了和使用 open 属性时很是相似的事情。可是在底部,咱们为咱们的组件引入了一个新的方法:render。如今咱们可使用 render 方法插入 shadow DOM 的内容,并从 connectedCallback 中移除行为;相反,咱们将在链接元素时调用 render 方法:

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = '';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else {
    shadowRoot.innerHTML = `<!-- template text -->`;
  }
  shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}
复制代码

如今咱们的对话框不只拥有了一些很是基本的样式,并且能够容许使用者为每一个实例定义一个新模板。咱们甚至能够基于它当前指向的模板使用 attributeChangedCallback 更新此组件:

static get observedAttributes() { return ['open', 'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if (newValue !== oldValue) {
    switch (attrName) {
      /** Boolean attributes */
      case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Value attributes */
      case 'template':
        this[attrName] = newValue;
        break;
    }
  }
}
复制代码

CodePen 中查看对话框示例:使用 shadow root、插槽以及模板

在上面的示例中,改变 <one-dialog> 元素的 template 属性将改变元素渲染时使用的设计。

Shadow DOM 样式策略

目前,定义一个 shadow DOM 节点样式的惟一方法就是在 shadow root 的内部 HTML 中添加一个 <style> 元素。这种方法几乎在全部状况下都能正常工做,由于浏览器会在可能的状况下对这些组件中的样式表进行重写。这个确实会增长一些内存开销,但一般不足以引发关注。

在这些样式标签内部,咱们可使用 CSS 自定义属性为定义组件样式提供 API。自定义属性能够穿透 shadow 的边界并影响 shadow 节点内的内容。

你可能会问:“咱们能够在 shadow root 内部使用 <link> 元素吗”?事实上,咱们确实能够。可是当尝试在多个应用之间重用这个组件时可能会出现问题,由于在全部应用中 CSS 文件可能没法保存在同一个位置。可是,若是咱们肯定了元素样式表的位置,那么咱们就可使用 <link> 元素。在样式标签中包含 @import 规则也是如此。

值得一提的是,不是全部的组件都须要像这样定义样式。使用 CSS 的 :host:host-context 选择器,咱们能够简单地定义更多初级的组件为块级元素,而且容许用户以提供类名的方式定义样式,如背景色,字体设置等。

另外一方面,不一样于只能够做为原生元素组合来展现的列表框(由标签和复选框组成),咱们的对话框至关复杂。这与样式策略同样有效,由于样式更明确(好比设计系统的目的,其中全部复选框可能看起来都是同样的)。这在很大程度上取决于你的使用场景。

CSS 自定义属性

使用 CSS 自定义属性(也被称为 CSS 变量)的一个好处是它们能够传入 shadow DOM 内。在设计上,为组件使用者提供了一个接口,容许他们从外部定义组件的主题和样式。然而,值得注意的是,由于 CSS 级联的缘故,在 shadow root 内部对于自定义样式的更改不会回流。

CodePen 中查看CSS 自定义样式以及 shadow DOM

继续注释或删除上面示例中的 CSS 面板里设置的变量,看看它是如何影响渲染内容的。你能够看一下 shadow DOM 的 innerHTML 中的样式,无论 shadow DOM 如何定义它本身的属性,都不会影响到 light DOM。

可构造的样式表

在撰写本文的时候,有一项提议的 web 功能,它容许使用可构造的样式表对 shadow DOM 和 light DOM 的样式进行更多地模块化定义。这个功能已经登录 Chrome 73,而且从 Mozilla 获得了不少积极的消息。

此功能容许使用者在其 JavaScript 文件中定义样式表,相似于编写普通 CSS 并在多个节点之间共享这些样式的方式。所以,单个样式表能够添加到多个 shadow root 内,也能够添加到文档内。

const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; }');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`;
  }
}
复制代码

在上面的示例中,everythingTomato 样式表能够同时应用到 shadow root 以及文档的 body 内。对于那些想要建立能够被多个应用和框架共享的设计系统和组件的团队来讲很是有用。

在下一个示例中,咱们能够看到一个很是基础的例子,展现了可构造样式表的使用方法以及它提供的强大功能。

CodePen 中查看可构造的样式表示例

在这个示例中,咱们构造了两个样式表,并将它们添加到文档和自定义元素上。三秒钟后,咱们从 shadow root 中删除一个样式表。可是,对于这三秒钟,文档和 shadow DOM 共享相同的样式表。使用该示例中包含的 polyfill,实际上存在两个样式元素,但 Chrome 运行的很天然。

该示例还包括一个表单,用于显示如何根据须要异步有效地更改工做表的规则。对于那些想要为他们的网站提供主题的使用者,或者那些想要建立跨越多个框架或网址的设计系统的使用者来讲,Web 平台的这一新增功能能够成为一个强大的盟友。

这里还有一个关于 CSS 模块的提议,最终能够和 adoptStyleSheets 功能一块儿使用。若是以当前形式实现,该提议将容许把 CSS 做为模块导入,就像 ECMAScript 模块同样:

import styles './styles.css';

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [styles];
  }
}
复制代码

部分和主题

用于样式化 Web 组件的另外一个特性是 ::part()::theme() 伪选择器。::part() 规范容许使用者能够定义他们的部分自定义元素,提供了下面的样式定义接口:

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);
复制代码

在咱们的全局 CSS 中,咱们能够经过调用 CSS 的 ::part() 选择器来定位任何 part 属性值为 description 的元素。

other-component::part(description) {
  color: tomato;
}
复制代码

在上面的示例中,<h1> 标签的主要消息与描述部分的颜色不一样,对于那些自定义元素的使用者,让他们能够暴露本身组件的样式 API,并保持对他们想要保持控制的部分的控制。

::part()::theme() 的区别在于 ::part() 必须做用于特定的选择器上,::theme() 能够嵌套在任何层级上。下面的示例和上面 CSS 代码有着相同的效果,但也适用于在整个文档树中包含 part="description" 的任何其余元素。

:root::theme(description) {
  color: tomato;
}
复制代码

和可构造的样式表同样,::part() 已经能够在 Chrome 73 中使用。

总结

咱们的对话框组件如今已经完成。它具备本身的标记,样式(没有任何外部依赖)和行为。此组件如今能够被包含在使用任何当前或将来框架的项目中,由于它们是根据浏览器规范而不是第三方 API 构建的。

一些核心控件有点冗长,而且或多或少依赖于对 DOM 工做原理一些知识。在咱们的最后一篇文章中,咱们将讨论更高级别的工具以及如何与流行的框架结合使用。

系列文章:

  1. Web Components 简介
  2. 编写能够复用的 HTML 模板
  3. 从 0 开始建立自定义元素
  4. 使用 Shadow DOM 封装样式和结构(本文
  5. Web Components 的高级工具

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索