自定义元素

Web 功能: 自主自定义元素

自定义元素是通过 customElements.define() 针对标记名称注册的类。注册和基本生命周期很容易理解;以下规则针对的是通常实现不正确的部分。

生命周期:实现错误的部分

回调本身有详细的文档说明;以下是导致 bug 的误解:

  • connectedCallback 可以运行多次。它会在每次插入时触发,因此移动元素会重新运行它。使设置具有幂等性,不要假设只运行一次。
  • connectedMoveCallback 会在移动时跳过断开连接/重新连接对。当元素通过 Node.moveBefore() 重新定位时,系统会触发此回调,而不是 disconnectedCallback → connectedCallback,因此状态、焦点和 iframe/媒体播放会在移动后保留。如果设置或拆解成本高昂或明显重新运行,请定义此方法。
  • 在 disconnectedCallback 中删除。移除全局监听器、计时器和观察器,否则会发生泄漏;但元素可能会重新插入,因此请勿将拆解视为永久性操作。
  • 首先在构造函数中调用 super(),并且不依赖于完整的 DOM。在轻触 this 后调用 super() 会抛出 ReferenceError。属性通常(但并非总是)存在;长子级列表可能尚未完成解析。使用 connectedCallback 进行一次性初始化,使用 slotchange 监控直接子级,使用 attributeChangedCallback 监控属性。connectedCallback 仍可能在所有子元素解析之前触发,因此请勿忽略后续的 slotchange/attributeChangedCallback 调用。
  • attributeChangedCallback 仅针对 observedAttributes 触发,对于不影响输出的属性,应保持低开销。
  • 生命周期钩子和 observedAttributes 会在注册时进行快照。customElements.define() 会读取这两者一次;在调用之后,在类上添加钩子或扩展 observedAttributes 会被静默忽略。具体而言,除非 observedAttributes 在 define() 时间已存在,否则 attributeChangedCallback 绝不会触发。

升级时间和:defined

元素可以在其类注册之前存在于 DOM 中(在服务器呈现的 HTML 中,或在脚本加载较晚时)。在定义之前,它是一个不含任何行为的惰性 HTMLElement;当 customElements.define() 运行时,浏览器会升级文档中已有的每个匹配元素,运行其构造函数,然后运行 connectedCallback。

模型通常会忽略以下两种后果:

  1. 当您的构造函数/connectedCallback 在升级时运行时,属性和子级可能已经存在。读取 connectedCallback 中的现有属性以同步初始状态,切勿假设元素最初为空。
  2. 即使在 connectedCallback 中,子元素也可能仍然缺失 - HTML 解析器可能会在解析长子元素列表的过程中产生结果,因此回调有时会在每个子元素附加之前运行。如需了解模式 (slotchange + attributeChangedCallback),请参阅上文生命周期中的构造函数要点。

隐藏未定义的元素,以避免出现未设置样式的内容,但要采用故障开放模式,以便在脚本未加载时内容仍可访问。使用延迟时间为 opacity 添加动画效果,而不是使用 visibility: hidden 强制隐藏,这样即使元素从未升级,也会在超时后显示:

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

将这些保护措施的作用域限定为需要它们的元素,或通过 :has(:not(:defined)) 限定为特定子树,而不是整个 body。一个缓慢、非关键的 widget(例如分享按钮)不应延迟整个网页的绘制。

特性 ⇄ 属性反射

使 HTML 属性和 JS 属性保持同步,以便该元素可同时通过标记和脚本使用。属性值始终为字符串;请强制转换它们。

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

价值所在

上面的示例将 DOM 保留为可靠来源,每个 getter 都会读取 getAttribute。对于少数简单的字符串属性,这是正确的默认设置:状态只能存在于一个位置,因此不会出现不同步的情况。

对于更丰富的组件,请将可信来源保留在 JS 字段中,并且仅在其他内容必须看到它(CSS 定位或使用方读取它)时才向外反映到属性。读取字段比在每次访问时解析和强制转换属性要便宜得多,并且非字符串值永远不会通过序列化进行往返。这就是为什么像 Lit 这样的库会在 JS 中保留状态并选择性地反映,而不是将属性视为存储:仅反映外部世界必须观察的内容,而不是每个属性。

误区:

  • observedAttributes 纪律:仅列出其更改必须重新渲染或触发副作用的属性。观察所有内容会为每个变更添加回调,但没有任何好处。
  • 反射循环:写入属性的 setter 将重新进入 attributeChangedCallback。使用 oldVal !== newVal 检查进行保护,如上所示。
  • 仅样式状态:对于仅用于驱动样式(加载、活跃、展开)的内部状态,最好使用自定义状态,而不是反映合成属性 - 请参阅下文的自定义状态。仅当使用者必须能够通过标记设置状态时,才反映到属性。

自定义状态

自定义状态是元素关于自身公开的布尔标志,供 CSS 定位,通过 ElementInternals 设置,并与 :state() 伪类匹配。将它们用于内部运行时界面状态(busy、expanded、invalid),而不是反映合成属性:它们不会与使用者的属性发生冲突,永远不会出现在 DOM 中,并且从外部来看是只读的(使用者可以设置 :state() 的样式,但无法伪造它)。

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet 是一个集合,因此独立状态会组合(busy 加上 error),而无需发明组合的 data-status 枚举。

自定义状态仅在运行时有效。它不会序列化到 DOM,因此在重新加载、元素重新创建或服务器呈现后不会保留。如果必须在标记中以声明方式设置状态,或者状态必须在加载之间持久保存,则应使用属性(请参阅值的存储位置),在 connectedCallback 中从可信来源恢复状态。经验法则:自定义状态用于组件在运行时派生和拥有的内容,且只需设置样式;属性用于必须通过标记或序列化设置的内容。

如需了解如何设置这些状态的样式(在组件内部和从使用者的样式表中),请参阅设置 Web 组件的样式。

命名规则

  • 真正的私有状态:使用原生 # 字段 (#count) 实现硬封装;仅在子类仍必须访问时才使用 _ 前缀。请注意,# 字段不会被继承,子类无法看到基类的私有字段。
  • 以符号为键的属性则介于两者之间:它们在普通枚举中隐藏,但任何持有该符号的代码都可以访问它们(通过导出符号,或将其作为类上的 static 字段公开)。由于每个 Symbol 都是唯一的,因此即使两个 Symbol 具有相同的描述,它们也不会发生冲突。当子类或同级类需要 # 字段无法提供的受控访问权限时,Symbol 就非常有用。
  • 请勿使用您自己的属性遮蔽全局属性(style、class、id、slot、part、title、lang、dir);否则会破坏其内置行为。请注意,disabled 仅在表单控件上是全局属性;在其他元素上,您必须自行实现其效果。

为新的 API Surface 命名时,请遵循平台自身的惯例:对于自定义元素标记名称,使用小写烤串命名法,且至少包含一个连字符(例如 my-toggle);对于属性和方法,使用 lowerCamelCase;对于属性名称,使用小写形式,并与对应的属性保持同步。