使用 Shadow DOM 封装样式和结构

Avatar of Caleb Williams
Caleb Williams

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

这是关于 Web Components 规范的五部分系列文章的第四部分。在 第一部分 中,我们从 10,000 英尺的高度概述了这些规范及其作用。在 第二部分 中,我们着手构建自定义模态对话框,并在 第三部分 中为将发展成为我们自己的自定义 HTML 元素创建了 HTML 模板。

文章系列

  1. Web Components 简介
  2. 创建可重用的 HTML 模板
  3. 从头开始创建自定义元素
  4. 使用 Shadow DOM 封装样式和结构(本文
  5. Web Components 的高级工具

如果您还没有阅读这些文章,建议您在继续阅读本文之前先阅读,因为本文将继续构建我们在那里完成的工作。

当我们上次查看我们的对话框组件时,它具有特定的形状、结构和行为,但是它严重依赖于外部 DOM,并且要求我们的元素的使用者需要了解其通用形状和结构,更不用说编写他们自己的所有样式(最终会修改文档的全局样式)。并且由于我们的对话框依赖于 id 为“one-dialog”的模板元素的内容,因此每个文档只能有一个我们的模态实例。

我们对话框组件的当前限制不一定是坏事。那些对对话框内部工作原理有深入了解的使用者可以通过创建自己的 <template> 元素并定义他们希望使用的内容和样式(甚至依赖于其他地方定义的全局样式)来轻松使用对话框。但是,我们可能希望对我们的元素提供更具体的 design 和结构约束以适应最佳实践,因此在本文中,我们将把 Shadow DOM 集成到我们的元素中。

什么是 Shadow DOM?

在我们的 简介文章 中,我们说 Shadow DOM “能够隔离 CSS 和 JavaScript,几乎就像一个 <iframe>。” 就像 <iframe> 一样,Shadow DOM 节点内部的选择器和样式不会泄漏到 Shadow DOM 外部,并且 Shadow DOM 外部的样式也不会泄漏到内部。有一些例外情况继承自父文档,例如字体系列和文档字体大小(例如 rem),可以在内部覆盖。

但是,与 <iframe> 不同的是,所有 Shadow 根仍然存在于同一文档中,以便所有代码都可以在给定的上下文中编写,而无需担心与其他样式或选择器的冲突。

将 Shadow DOM 添加到我们的对话框

要添加 Shadow 根(Shadow 树的基本节点/文档片段),我们需要调用元素的 attachShadow 方法

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

通过使用 mode: 'open' 调用 attachShadow,我们告诉我们的元素在 element.shadowRoot 属性上保存对 Shadow 根的引用。attachShadow 始终返回对 Shadow 根的引用,但在这里我们不需要对它做任何事情。

如果我们使用 mode: 'closed' 调用该方法,则不会在元素上存储任何引用,我们必须使用 WeakMapObject 创建自己的存储和检索方法,将节点本身作为键,将 Shadow 根作为值。

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 根的引用,使用 Symbol 或其他键来尝试使 Shadow 根私有。

通常,Shadow 根的 closed 模式用于在其实现中使用 Shadow DOM 的原生元素(如 <audio><video>)。此外,为了对我们的元素进行单元测试,我们可能无法访问 shadowRoots 对象,这使得我们无法根据库的架构方式来定位元素内部的更改。

用户空间的 closed Shadow 根可能有一些合理的用例,但它们很少见,因此我们将坚持为我们的对话框使用 open Shadow 根。

在实现新的 open Shadow 根之后,您可能会注意到,当我们尝试运行它时,我们的元素完全坏了

查看 CodePen 上 Caleb Williams (@calebdwilliams) 的 Pen
使用模板和 Shadow 根的对话框示例

CodePen 上。

这是因为我们之前的所有内容都添加到传统的 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 根的 innerHTML 中来更改它。

查看 CodePen 上 Caleb Williams (@calebdwilliams) 的 Pen
仅使用 Shadow 根的对话框示例

CodePen 上。

包含来自 light DOM 的内容

Shadow DOM 规范 包含一种方法,允许将 Shadow 根外部的内容渲染到我们的自定义元素内部。对于那些记得 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 根可以具有任意数量的 slot 元素,这些元素可以使用 name 属性进行区分。Shadow 根中第一个没有名称的 slot 将是默认 slot,所有未分配的其他内容都将流入该节点。我们的对话框实际上需要两个 slot:一个标题和一些内容(我们将设置为默认值)。

查看 CodePen 上 Caleb Williams (@calebdwilliams) 的 Pen
使用 Shadow 根和 slot 的对话框示例

CodePen 上。

继续更改对话框的 HTML 部分并查看结果。light DOM 内部的任何内容都将插入到分配给它的 slot 中。虽然 slotted 内容保留在 light DOM 内部,但它被渲染得好像它在 Shadow DOM 内部一样。这意味着这些元素仍然可以被希望控制其外观的使用者完全设置样式。

Shadow 根的作者可以在一定程度上使用 CSS ::slotted() 伪选择器来设置 light DOM 内部的 content 的样式;但是,slotted 内部的 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 上 Caleb Williams (@calebdwilliams) 的 Pen
使用 Shadow 根、slot 和模板的对话框示例


CodePen 上。

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

为 Shadow DOM 设置样式的策略

目前,为 Shadow DOM 节点设置样式的唯一可靠方法是将<style>元素添加到 Shadow root 的内部 HTML 中。这在几乎所有情况下都能正常工作,因为浏览器会在这些组件之间尽可能地对样式表进行去重。这确实会增加一些内存开销,但通常不足以察觉。

在这些样式标签内部,我们可以使用CSS 自定义属性来为我们的组件提供一个样式 API。自定义属性可以穿透 Shadow 边界并影响 Shadow 节点内部的内容。

“我们可以在 Shadow root 内部使用<link>元素吗?”你可能会问。事实上,我们可以。问题在于尝试在多个应用程序中重用此组件时,CSS 文件可能不会在所有应用程序中都保存在一致的位置。但是,如果我们确定元素的样式表位置,那么使用<link>就是一个选项。对于在样式标签中包含@import规则,也是同样的道理。

还需要注意的是,并非所有组件都需要我们这里使用的样式。使用 CSS :host:host-context 选择器,我们可以简单地将更原始的组件定义为块级元素,并允许使用者提供类来设置背景颜色、字体设置等样式。

另一方面,我们的对话框相当复杂。像列表框(由标签和复选框输入组成)这样的东西则不然,它可以仅仅作为原生元素组合的表面。这与更明确地指定样式(例如,出于设计系统目的,所有复选框可能都具有某种外观)一样是有效的样式策略。这很大程度上取决于你的用例。

CSS 自定义属性

使用CSS 自定义属性(也称为 CSS 变量)的优势之一是它们会贯穿 Shadow DOM。这是设计使然,为组件作者提供了一个表面,允许从外部对组件进行主题化和设置样式。但是,需要注意的是,由于 CSS 具有层叠性,因此在 Shadow root 内部对自定义属性所做的更改不会向上传播。

查看笔
CSS 自定义属性和 Shadow DOM
,作者是 Caleb Williams(@calebdwilliams)。
CodePen 上。

继续注释掉或删除上面演示的 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。对于创建设计系统和旨在跨多个应用程序和框架共享的组件的团队来说,此功能非常有用。

在下一个演示中,我们可以看到一个非常基本的示例,说明如何利用此功能以及可构造样式表提供的强大功能。

查看笔
可构造样式表演示
,作者是 Caleb Williams(@calebdwilliams)。
CodePen 上。

在此演示中,我们构造了两个样式表,并将它们附加到文档和自定义元素。三秒钟后,我们从 Shadow root 中删除一个样式表。但是,在这三秒钟内,文档和 Shadow DOM 共享相同的样式表。使用该演示中包含的 polyfill,实际上存在两个样式元素,但 Chrome 本地运行它。

该演示还包含一个表单,用于显示如何根据需要轻松有效地异步更改表单的规则。对于创建跨越多个框架的设计系统或希望为其网站提供主题的网站作者来说,此 Web 平台的补充功能可以成为强大的盟友。

还有一个关于CSS 模块的提案,最终可以与adoptedStyleSheets功能一起使用。如果以其当前形式实施,此提案将允许像导入 ECMAScript 模块一样导入 CSS 作为模块。

import styles './styles.css';

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

部件和主题

正在开发的另一个用于设置 Web Components 样式的功能是::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()选择器来定位任何具有名为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. 从头开始创建自定义元素
  4. 使用 Shadow DOM 封装样式和结构(本文
  5. Web Components 的高级工具