自定义元素最佳实践

自定义元素允许您构建自己的 HTML 标记。此清单涵盖了帮助您构建高质量元素的最佳实践。

自定义元素允许您扩展 HTML 并定义自己的标记。它们是一项非常强大的功能,但同时也是底层功能,这意味着如何最好地实现您自己的元素并不总是很清楚。

为了帮助您创造最佳体验,我们整理了这份清单。它分解了我们认为构成一个良好行为的自定义元素的所有要素。

清单

Shadow DOM

创建 shadow root 以封装样式。

为什么? 将样式封装在元素的 shadow root 中可确保它在任何使用位置都能正常工作。如果开发者希望将您的元素放置在另一个元素的 shadow root 内,这一点尤为重要。这甚至适用于像复选框或单选按钮这样的简单元素。可能您 shadow root 内唯一的内容就是样式本身。
示例 <howto-checkbox> 元素。

在构造函数中创建 shadow root。

为什么? 构造函数是您拥有元素独有知识的时候。这是设置您不希望其他元素干扰的实现细节的好时机。在稍后的回调(如 connectedCallback)中执行此操作意味着您需要防范元素从文档中分离然后重新附加到文档的情况。
示例 <howto-checkbox> 元素。

将元素创建的任何子元素放入其 shadow root 中。

为什么? 由您的元素创建的子元素是其实现的一部分,应设为私有。如果没有 shadow root 的保护,外部 JavaScript 可能会无意中干扰这些子元素。
示例 <howto-tabs> 元素。

使用 <slot> 将 light DOM 子元素投影到您的 shadow DOM 中

为什么? 允许组件用户在您的组件中将内容指定为 HTML 子元素,这使您的组件更具可组合性。当浏览器不支持自定义元素时,嵌套内容仍然可用、可见且可访问。
示例 <howto-tabs> 元素。

设置 :host display 样式(例如 blockinline-blockflex),除非您喜欢默认的 inline

为什么? 默认情况下,自定义元素的 display 值为 inline,因此设置它们的 widthheight 将不起作用。这通常会让开发者感到意外,并可能导致与页面布局相关的问题。除非您喜欢 inline 显示,否则您应始终设置默认的 display 值。
示例 <howto-checkbox> 元素。

添加一个 :host display 样式,使其尊重 hidden 属性。

为什么? 具有默认 display 样式的自定义元素(例如 :host { display: block })将覆盖较低优先级的内置 hidden 属性。如果您期望在元素上设置 hidden 属性以将其渲染为 display: none,这可能会让您感到意外。除了默认的 display 样式外,还应添加对 hidden 的支持,使用 :host([hidden]) { display: none }
示例 <howto-checkbox> 元素。

属性和特性

不要覆盖作者设置的全局属性。

为什么? 全局属性是所有 HTML 元素上都存在的属性。一些示例包括 tabindexrole。自定义元素可能希望将其初始 tabindex 设置为 0,以便它可以通过键盘聚焦。但是您应始终首先检查使用您元素的开发者是否已将其设置为其他值。例如,如果他们已将 tabindex 设置为 -1,则表明他们不希望该元素具有交互性。
示例 <howto-checkbox> 元素。这在 不要覆盖页面作者 中进一步解释。

始终接受原始数据(字符串、数字、布尔值)作为属性或特性。

为什么? 自定义元素(如其内置的对应项)应是可配置的。配置可以通过声明方式(通过属性)或命令方式(通过 JavaScript 特性)传入。理想情况下,每个属性也应链接到相应的特性。
示例 <howto-checkbox> 元素。

目标是保持原始数据属性和特性同步,从特性反映到属性,反之亦然。

为什么? 您永远不知道用户将如何与您的元素交互。他们可能会在 JavaScript 中设置一个特性,然后期望使用像 getAttribute() 这样的 API 读取该值。如果每个属性都有一个对应的特性,并且它们都进行反映,这将使使用者更容易使用您的元素。换句话说,调用 setAttribute('foo', value) 也应设置相应的 foo 特性,反之亦然。当然,此规则也有例外。您不应反映高频率特性,例如视频播放器中的 currentTime。请使用您的最佳判断。如果使用者似乎会与特性或属性交互,并且反映它不会造成负担,那么就这样做。
示例 <howto-checkbox> 元素。这在 避免重入问题 中进一步解释。

目标是仅接受富数据(对象、数组)作为特性。

为什么? 一般来说,没有内置 HTML 元素通过其属性接受富数据(纯 JavaScript 对象和数组)的示例。富数据而是通过方法调用或特性来接受。将富数据作为属性接受有两个明显的缺点:将大型对象序列化为字符串可能很昂贵,并且任何对象引用都将在此字符串化过程中丢失。例如,如果您字符串化一个具有对另一个对象或 DOM 节点的引用的对象,这些引用将丢失。

不要将富数据特性反映到属性。

为什么? 将富数据特性反映到属性是不必要的昂贵,需要序列化和反序列化相同的 JavaScript 对象。除非您有只能通过此功能解决的用例,否则最好避免这样做。

考虑检查在元素升级之前可能已设置的特性。

为什么? 使用您元素的开发者可能会尝试在元素的定义加载之前在该元素上设置特性。如果开发者使用的是处理加载组件、将其标记到页面以及将其特性绑定到模型的框架,则尤其如此。
示例 <howto-checkbox> 元素。在 使特性延迟加载 中进一步解释。

不要自我应用类。

为什么? 需要表达其状态的元素应使用属性来做到这一点。class 属性通常被认为由使用您元素的开发者拥有,并且自己写入它可能会无意中踩踏开发者的类。

事件

响应内部组件活动分派事件。

为什么? 您的组件可能具有响应只有您的组件知道的活动而变化的特性,例如,如果计时器或动画完成,或者资源完成加载。分派事件以响应这些更改以通知主机组件的状态不同是很有帮助的。

不要响应主机设置特性(向下数据流)而分派事件。

为什么? 响应主机设置特性而分派事件是多余的(主机知道当前状态,因为它刚刚设置了它)。响应主机设置特性而分派事件可能会导致数据绑定系统出现无限循环。
示例 <howto-checkbox> 元素。

说明

不要覆盖页面作者

使用您元素的开发者可能想要覆盖其某些初始状态。例如,使用 tabindex 更改其 ARIA role 或可聚焦性。在应用您自己的值之前,请检查是否已设置这些属性和任何其他全局属性。

connectedCallback() {
  if (!this.hasAttribute('role'))
    this.setAttribute('role', 'checkbox');
  if (!this.hasAttribute('tabindex'))
    this.setAttribute('tabindex', 0);

使特性延迟加载

开发者可能会尝试在元素的定义加载之前在该元素上设置特性。如果开发者使用的是处理加载组件、将其插入页面以及将其特性绑定到模型的框架,则尤其如此。

在以下示例中,Angular 以声明方式将其模型的 isChecked 特性绑定到复选框的 checked 特性。如果 howto-checkbox 的定义是延迟加载的,则 Angular 可能会尝试在元素升级之前设置 checked 特性。

<howto-checkbox [checked]="defaults.isChecked"></howto-checkbox>

自定义元素应通过检查是否已在其实例上设置任何特性来处理这种情况。<howto-checkbox> 使用名为 _upgradeProperty() 的方法演示了此模式。

connectedCallback() {
  ...
  this._upgradeProperty('checked');
}

_upgradeProperty(prop) {
  if (this.hasOwnProperty(prop)) {
    let value = this[prop];
    delete this[prop];
    this[prop] = value;
  }
}

_upgradeProperty() 从未升级的实例捕获值并删除该特性,使其不会遮蔽自定义元素自身的特性设置器。这样,当元素的定义最终加载时,它可以立即反映正确的状态。

避免重入问题

很想使用 attributeChangedCallback() 将状态反映到基础特性,例如

// When the [checked] attribute changes, set the checked property to match.
attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'checked')
    this.checked = newValue;
}

但是,如果特性设置器也反映到属性,则可能会创建无限循环。

set checked(value) {
  const isChecked = Boolean(value);
  if (isChecked)
    // OOPS! This will cause an infinite loop because it triggers the
    // attributeChangedCallback() which then sets this property again.
    this.setAttribute('checked', '');
  else
    this.removeAttribute('checked');
}

一种替代方法是允许特性设置器反映到属性,并让 getter 根据属性确定其值。

set checked(value) {
  const isChecked = Boolean(value);
  if (isChecked)
    this.setAttribute('checked', '');
  else
    this.removeAttribute('checked');
}

get checked() {
  return this.hasAttribute('checked');
}

在此示例中,添加或删除属性也会设置特性。

最后,attributeChangedCallback() 可用于处理副作用,例如应用 ARIA 状态。

attributeChangedCallback(name, oldValue, newValue) {
  const hasValue = newValue !== null;
  switch (name) {
    case 'checked':
      // Note the attributeChangedCallback is only handling the *side effects*
      // of setting the attribute.
      this.setAttribute('aria-checked', hasValue);
      break;
    ...
  }
}