自定义元素最佳做法

您可以使用自定义元素构建自己的 HTML 标记。此核对清单介绍了有助于您构建优质元素的最佳实践。

借助自定义元素,您可以扩展 HTML 并定义自己的标记。它们功能非常强大,但级别也较低,这意味着您并不总是清楚如何以最佳方式实现您自己的元素。

为帮助您打造尽可能最佳的体验,我们整理了以下核对清单。它详细说明了我们认为成为行为良好的自定义元素所需的所有内容。

核对清单

阴影 DOM

创建影子根来封装样式。

为什么? 将样式封装在元素的影子根中可确保无论在哪里使用它,影子根都能正常工作。如果开发者希望将您的元素放在另一个元素的影子根内,这一点尤为重要。这也适用于复选框或单选按钮等简单的元素。有时,影子根内只有样式本身。
示例 <howto-checkbox> 元素。

在构造函数中创建影子根目录。

为什么? 当您对自己的元素拥有专有信息时,才可使用构造函数。 这是一个很好的时机,可以设置您不希望其他元素影响到的实现细节。在稍后的回调(例如 connectedCallback)中执行此操作意味着您需要防止元素在分离后重新附加到文档的情况。
示例 <howto-checkbox> 元素。

将该元素创建的所有子元素放入其影子根中。

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

使用 <slot> 将 light DOM 子级投影到 shadow DOM 中

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

除非您希望使用默认值 inline,否则请设置 :host 显示样式(例如 blockinline-blockflex)。

为什么? 默认情况下,自定义元素为 display: inline,因此设置其 widthheight 不会产生任何影响。这往往会让开发者感到意外,并且可能会导致与页面布局相关的问题。除非您偏好 inline 显示屏,否则应始终设置默认的 display 值。
示例 <howto-checkbox> 元素。

添加遵循隐藏属性的 :host 显示样式。

为什么? 采用默认 display 样式的自定义元素(例如 :host { display: block })将覆盖较低的特异性内置 hidden 属性。 如果您希望在元素上设置 hidden 属性以使其呈现 display: none,可能会感到意外。除了默认的 display 样式之外,还使用 :host([hidden]) { display: none } 添加对 hidden 的支持。
示例 <howto-checkbox> 元素。

属性和属性

请勿替换作者设置的全局属性。

为什么? 全局属性是指所有 HTML 元素中都存在的属性。一些示例包括 tabindexrole。自定义元素可能希望将其初始 tabindex 设为 0,使其可通过键盘聚焦。不过,请务必先检查使用该元素的开发者是否已将此值设置为其他值。例如,如果访问者将 tabindex 设置为 -1,则表示他们不希望元素具有交互功能。
示例 <howto-checkbox> 元素。有关详细说明,请参阅请勿替换网页作者。

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

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

旨在使原始数据特性和属性保持同步,从而在属性之间反射属性,反之亦然。

为什么? 您永远不知道用户是如何与您的元素互动的。这些 API 可能使用 JavaScript 设置一个属性,然后预期使用 getAttribute() 等 API 读取该值。如果每个属性都有对应的属性,并且这两个属性都有对应的值,用户就能够更轻松地使用您的元素。也就是说,调用 setAttribute('foo', value) 也应该设置相应的 foo 属性,反之亦然。当然,此规则也有例外情况。您不应反映高频属性,例如视频播放器中的 currentTime。请做出最佳判断。如果用户看上去与某个属性或属性互动,并且反映它并不繁琐,请执行此操作。
示例 <howto-checkbox> 元素。避免重入问题中对此进行了详细说明。

尽量仅接受丰富数据(对象、数组)作为属性。

为什么? 一般来说,不存在通过属性接受丰富数据(普通 JavaScript 对象和数组)的内置 HTML 元素的示例。改为通过方法调用或属性接受丰富数据。接受丰富的数据作为属性存在几个明显的缺点:将大型对象序列化为字符串的开销可能会很大,并且在这个字符串化过程中,所有对象引用都将丢失。例如,如果要字符串化一个对象,而该对象引用了另一个对象(可能是 DOM 节点),则这些引用将会丢失。

请勿将丰富的数据属性反映到属性中。

为什么? 将丰富的数据属性映射到属性,成本高昂且需要将相同的 JavaScript 对象序列化和反序列化。除非您的用例只能使用此功能来解决,否则最好避免采用该功能。

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

为什么? 使用此元素的开发者可能会尝试在元素定义尚未加载之前为其设置属性。如果开发者使用负责加载组件、将组件印在页面上以及将其属性绑定到模型上的框架,则尤其如此。
示例 <howto-checkbox> 元素。如需详细了解,请参阅将属性设为延迟

不要自行应用类。

为什么? 需要表达其状态的元素应使用属性来表达。class 属性通常被视为由使用您的元素的开发者拥有,如果您自行写入属性可能会无意中干扰开发者类。

事件

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

为什么? 您的组件的某些属性可能会为了响应只有您的组件知道的 Activity 而更改的属性,例如,计时器或动画是否完成,或资源是否完成加载。最好分派事件来响应这些更改,以便通知主机组件的状态有所不同。

请勿分派事件来响应主机设置属性(向下数据流)。

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

释疑类视频

不替换网页作者

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

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

将属性设为延迟

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

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

<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() 会捕获未升级实例中的值并删除相应属性,这样它就不会覆盖自定义元素自身的属性 setter。这样,当元素的定义最终加载时,它可以立即反映正确的状态。

避免重入问题

人们倾向于使用 attributeChangedCallback() 将状态反映到底层属性,例如:

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

但如果属性 setter 方法也反映到该属性,就可能会出现无限循环。

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');
}

另一种方法是让属性 setter 反映到该属性,让 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;
    ...
  }
}