自定义元素介绍

一年多前,我们宣布引入基于插槽的 Shadow DOM API,这是一种轻量级机制,通过允许在元素上创建名为“影子树”的并行 DOM 树来封装 DOM 树,该影子树替换元素的渲染而无需修改常规 DOM 树。

今天,我们很高兴地宣布 WebKit 中新增了 Custom Elements API。借助此 API,开发者可以通过定义自己的 HTML 元素来创建可用的组件,而无需依赖 JS 框架。

定义自定义元素

要定义自定义元素,只需使用元素的新局部名称和 HTMLElement 的子类来调用 customElements.define。假设我们要创建一个名为 custom-progress-bar 的自定义进度条,则可以按如下方式定义该元素

class CustomProgressBar extends HTMLElement {
  constructor() {
      super();
      const shadowRoot = this.attachShadow({mode: 'closed'});
      shadowRoot.innerHTML = `
          <style>
              :host { display: inline-block; width: 5rem; height: 1rem; }
              .progress { display: inline-block; position: relative; border: solid 1px #000; padding: 1px; width: 100%; height: 100%; }
              .progress > .bar { background: #9cf; height: 100%; }
              .progress > .label { position: absolute; top: 0; left: 0; width: 100%;
                  text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
          </style>
          <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
              <div class="bar" style="width: 0px;"></div>
              <div class="label">0%</div>
          </div>
      `;
      this._progressElement = shadowRoot.querySelector('.progress');
      this._label = shadowRoot.querySelector('.label');
      this._bar = shadowRoot.querySelector('.bar');
  }

  get progress() { return this._progressElement.getAttribute('aria-valuenow'); }
  set progress(newPercentage) {
      this._progressElement.setAttribute('aria-valuenow', newPercentage);
      this._label.textContent = newPercentage + '%';
      this._bar.style.width = newPercentage + '%';
  }
};
customElements.define('custom-progress-bar', CustomProgressBar);

我们现在可以在标记中将此元素实例化为 <custom-progress-bar></custom-progress-bar>,或者动态实例化为 new CustomProgressBardocument.createElement('custom-progress-bar'),并通过例如 element.progress = 50 来更新其进度

progress-bar

请查看实时演示。尽管我上面使用了 ES6 类语法,但我们也可以使用 ES5 风格的构造函数来编写自定义元素,如下所示

function CustomProgressBar() {
  const instance = Reflect.construct(HTMLElement, [], CustomProgressBar);
  ...
  return instance;
}
customElements.define('custom-progress-bar', CustomProgressBar);

customElements.define 的第一个参数有一些限制:

  • 它必须以小写字母 a-z 开头。
  • 它不能包含大写字母 A-Z。
  • 它必须包含“-”符号。

有关有效自定义元素名称的精确定义,请参阅HTML 规范

使用自定义元素回调

许多内置元素通过其属性传递和接收数值,并响应这些值的变化。通过自定义元素的响应回调,我们也可以对自定义元素做同样的事情。例如,如果我们要让自定义进度条元素通过 data-progress 属性设置进度,我们可以这样做:

class CustomProgressBar extends HTMLElement {
  ...
  static get observedAttributes() { return ['value']; }
  attributeChangedCallback(name, oldValue, newValue, namespaceURI) {
      if (name === 'value') {
          const newPercentage = newValue === null ? 0 : parseInt(newValue);
          this._progressElement.setAttribute('aria-valuenow', newPercentage);
          this._label.textContent = newPercentage + '%';
          this._bar.style.width = newPercentage + '%';
      }
  }
  get progress() { return this.getAttribute('value'); }
  set progress(newValue) { this.setAttribute('value', newValue); }
}
<custom-progress-bar value="10"></custom-progress-bar>

在这里,我们声明此自定义元素会在 observedAttributes 中观察 value 属性。当属性被添加、删除或以其他方式修改时,浏览器引擎会调用 attributeChangedCallback。请注意,当属性被移除时,newValue 为 null。类似地,当属性新添加时,oldValue 为 null

Custom Elements API 还提供其他几种便捷的回调类型:

  • connectedCallback() – 当自定义元素被插入到文档中时调用。
  • disconnectedCallback() – 当自定义元素从文档中移除时调用。
  • adoptedCallback(oldDocument, newDocument) – 当自定义元素从旧文档被“收养”到新文档时调用。

自定义元素响应的一个优点是它们几乎是同步的,这与 MutationObserver 不同,后者在当前微任务结束时才交付其记录。当我们调用 appendChildsetAttribute 等方法时,浏览器引擎会立即调用所有必要的自定义元素响应,然后才返回到调用点。这使得自定义元素更容易模仿内置元素的语义,因为在 DOM API 调用者返回时(该调用者启动了 DOM 突变),自定义元素有机会运行并响应 DOM 突变。

然而,它们并非在所有 DOM 突变都完成后才调用所有回调的意义上是同步的。例如,Range 的 deleteContents() 可能会从文档中删除多个自定义元素,但这些自定义元素上的 disconnectedCallback 不会在所有删除操作完成后才被调用。

异步定义自定义元素

虽然我们强烈建议仅在使用 customElements.define 定义了自定义元素之后再使用它们,但在某些情况下,异步加载定义自定义元素的脚本可能会很方便。Custom Elements API 通过升级的方式支持这种场景。当我们通过 document.createElement 在脚本中或在标记中实例化一个尚未定义的自定义元素时,浏览器引擎会将其保留为普通的 HTMLElement,并在最终通过 customElements.define 定义后将其升级为自定义元素的实例。

脚本可以通过等待 customElements.whenDefined 返回的 Promise 来等待自定义元素定义变为可用,并通过 customElements.get 获取构造函数,如下所示:

customElements.whenDefined('custom-progress-bar').then(function () {
  let CustomProgressBar = customElements.get('custom-progress-bar');
  let instance = new CustomProgressBar;
  ...
});

当元素升级为自定义元素时,自定义元素的构造函数会被调用,就像同步构造新元素一样,但对 HTMLElement 构造函数的 super() 调用会返回正在升级的元素,而不是构造一个全新的对象。由于元素在升级时已经创建并插入到文档中,因此这样的元素可能已经拥有属性和子节点。当同步构造自定义元素时,HTMLElement 构造函数返回的元素不包含任何属性或子节点,并且它仍与文档断开连接。

幸运的是,在编写自定义元素时,我们几乎不必担心这种差异,因为当元素升级时,attributeChangedCallback 会自动在现有被观察的属性上调用,如果升级后的元素已连接到文档,则会调用 connectedCallback

以下是关于在构造函数内部应避免操作的一些小指南,这样我们就不必因此差异而遇到任何痛点:

  • 不要在构造函数内部添加、删除、修改或访问任何属性——在同步构造期间,属性甚至不存在。请改用 attributeChangedCallback。浏览器引擎在解析 HTML 时会为每个属性调用它。
  • 不要插入、删除、修改或访问子节点——同样,在同步构造期间,子节点甚至不存在。请使用子节点的 connectedCallback 并向上级传递信息。

总结

Custom Elements API 已在 Safari 技术预览版 18 中默认实现并启用。我们还在识别和修复 Shadow DOM API 中剩余错误的最后阶段。我们非常高兴能够通过这两个功能,最终将模块化的力量带给 Web 平台。