12k
All articles

使用 Web Components 构建可复用的表单控件

用 Web Components 和 ElementInternals 构建可复用表单控件,实现原生提交、验证、标签、重置和状态恢复。

OpenReplay Team
OpenReplay Team
使用 Web Components 构建可复用的表单控件

<input> 封装在 Shadow DOM 内部的自定义元素对其父级 <form> 来说是不可见的:其值永远不会出现在 FormData 中,原生验证会跳过它,表单重置也不会影响它。解决方案只需两行代码——在构造函数中添加 static formAssociated = true 以及 this.attachInternals()——再配合这两行代码所开启的 ElementInternals API。本文将构建一个完整的 <text-field> 控件,它能像原生 input 一样提交数据、执行验证、关联标签、响应重置并恢复状态,且仅使用原生平台 API 实现。

随着 2023 年 3 月 Safari 16.4 的发布,Web Components 在通过 ElementInternals API 与表单元素交互的能力上达到了一个重要里程碑;在此之前,位于 Shadow DOM 内部的 input 元素无法被表单发现,这意味着它们在提交时不会执行验证,其数据也不会包含在 FormData 对象中。这一兼容性问题现已得到解决,因此本文将跳过”能否使用”的顾虑,专注于介绍那些旧教程从未完整呈现的完整实现方案。

核心要点

  • 只需两行代码即可将任意自主自定义元素转变为表单控件:static formAssociated = true 声明意图,this.attachInternals() 返回将元素与父表单关联的 ElementInternals 对象。
  • 截至 2025 年 9 月,表单关联自定义元素已成为 Baseline 广泛可用特性——Chrome 从 77 版本起支持,Edge 从 79 版本起支持,Firefox 从 98 版本起支持,Safari 从 16.4 版本起支持——因此”是否已支持”的顾虑不再适用。
  • 只有调用 internals.setFormValue(value) 后,控件的值才会出现在 FormData 中;传入 null 则会将该元素完全排除在提交之外。
  • setValidity() 会阻止提交并应用 :invalid 样式,但无障碍验证还需要一条可见的、通过 aria-describedby 关联的错误提示——否则表单会静默拒绝提交。
  • 规范定义的两种状态恢复模式中,只有 "restore" 在已发布的浏览器中能可靠触发;"autocomplete" 虽已在规范中定义,但尚未得到可靠实现。

为何 Shadow DOM 会破坏原生表单参与机制

原生 <input> 只有在与 <form> 处于同一 DOM 树中时,才能参与表单。Shadow DOM 是一棵独立的树,因此渲染在自定义元素 shadow root 内部的 input 在结构上对表单不可见:浏览器不会将其加入 form.elements,不会将其值收集到 FormData 中,也不会对其执行约束验证。这是封装机制按设计运作的结果——也正是表单关联自定义元素(FACE)所要解决的问题。

平台层面的解决方案是 ElementInternalsHTMLElement.attachInternals() 方法返回一个 ElementInternals 对象;该方法允许自定义元素参与 HTML 表单,其接口提供了与标准 HTML 表单元素相同的操作工具,同时还向元素暴露了无障碍对象模型(Accessibility Object Model)。FACE 已不再是前沿特性:表单关联自定义元素自 2025-09-27 起已成为 Baseline 广泛可用特性,在 Chrome 77(2019 年)、Edge 79(2020 年)、Firefox 98(2022 年)以及 2023-03-27 发布的 Safari 16.4 中均已支持。社区提供的 element-internals-polyfill 仍可用于旧版引擎,但鉴于 Baseline 已在常青浏览器中全面支持,大多数项目不再需要它。

有一个硬性约束:将静态属性 formAssociated 设置为 true 可将自主自定义元素转变为表单关联自定义元素。该元素必须直接继承 HTMLElement,而不能是内置子类,且如果元素不是自定义元素,attachInternals() 会抛出 NotSupportedError

最简表单关联自定义元素

最简可用的控件需要声明 formAssociated、获取其 internals 对象,并通过 setter 将值推送到表单中。其他所有功能都在这个骨架的基础上构建。

class TextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['value', 'required'];

  #internals;
  #value = '';

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

  get value() { return this.#value; }
  set value(v) {
    this.#value = v ?? '';
    this.#internals.setFormValue(this.#value);
  }

  get form() { return this.#internals.form; }
  get name() { return this.getAttribute('name'); }
  get type() { return this.localName; }
}

customElements.define('text-field', TextField);

setFormValue() 是这里的核心调用。只有调用 internals.setFormValue(value) 后,控件的值才会出现在 FormData 中;传入 null 则会将该元素完全排除在提交之外。setter 模式确保每次对 .value 的赋值——无论来自脚本、属性还是用户输入——都会重新同步提交值。关联后,该元素的行为与内置元素一致:表单的 elements 集合包含所有与表单关联的 <button><fieldset><input><object><output><select><textarea> 以及表单关联自定义元素。

如果只需要向提交数据中注入任意数据,而不是构建可复用控件,使用更轻量的 formdata 事件即可:在表单上监听该事件并调用 event.formData.append(...)。当需要构建一个必须执行验证、关联标签、响应重置和恢复状态的控件时,才需要使用 ElementInternals

渲染并同步内部 input

控件需要一个内部 <input>,其值通过公共 setter 向外传递,其 input/change 事件从宿主元素重新分发,使 <text-field> 上的监听器行为与原生字段一致。由于 shadow root 只能附加一次,因此需要加以保护,防止断开连接后再次连接时重复调用 attachShadow()(否则会抛出异常):

connectedCallback() {
  if (!this.shadowRoot) {
    const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    root.innerHTML = `<input part="input" />`;
    this.input = root.querySelector('input');

    this.input.addEventListener('input', () => {
      this.value = this.input.value;        // 重新同步 setFormValue
      this.#validate();                      // 实时验证
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

  this.value = this.getAttribute('value') ?? '';
  this.#validate();
}

原生 change 事件不会穿越 shadow 边界,因此通过 new e.constructor(e.type, e) 克隆事件并从宿主元素重新分发,可以在 light DOM 中重新触发该事件,供父级代码监听。在 input 的 input 监听器中驱动 this.value,可确保每次按键时提交的 FormData 值保持最新。

标签关联、焦点委托与 tabindex

原生 <label for="..."> 在点击时应将焦点移至控件。对于表单关联自定义元素,这一关联来自 shadow root 上的 delegatesFocus: true。设置 delegatesFocus: true 可使 <label for> 的点击将焦点移至内部 input,与原生字段完全一致——若不设置,点击标签不会有任何效果,聚焦宿主元素也无法到达内部 input。由于 FACE 是可标记元素(labelable element),标记方式与原生 input 完全相同:

<form>
  <label for="email">Email</label>
  <text-field id="email" name="email" required></text-field>
</form>

自定义控件的屏幕阅读器标签关联在不同浏览器和辅助技术组合中历来表现不一致,因此请针对目标辅助技术进行测试,并在支持广泛矩阵的情况下保留 shadow 内部的 <label> 作为备用方案。设置 delegatesFocus 后,通常无需手动管理 tabindex——焦点会委托给内部 input,而内部 input 本身已在 tab 顺序中。

使用 setValidity() 和完整的 ValidityStateFlags 进行验证

ElementInternals.setValidity() 与原生约束验证系统相对应。传入一个 ValidityStateFlags 对象、一条可选的人类可读消息以及一个可选的锚点元素;浏览器随后会应用 :invalid 伪类并阻止提交。通过这种设置,每当设置了某个标志位时,:invalid 伪类会自动应用于该元素。传入空对象 {} 可清除所有错误。

各标志位与原生验证原因一一对应:

标志位对应的原生触发条件
valueMissingrequired 属性存在但无值
typeMismatch值与 type 不匹配(如 email
patternMismatch值不符合 pattern
tooLong / tooShort超过 maxlength / 未达 minlength
rangeUnderflow / rangeOverflow低于 min / 超过 max
stepMismatch值不符合 step 步进规则
badInput无法解析的输入
customError作者通过自定义消息定义的错误

在每次 input 事件时执行验证以提供实时反馈,并暴露标准方法(checkValidity()reportValidity()validityvalidationMessage),使调用方可以像对待原生字段一样操作你的控件:

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'This field is required.';
  }
  this.#internals.setValidity(flags, message, this.input);
  this.#renderError(message);
}

checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }

setValidity() 只完成了无障碍验证的一半:浏览器会阻止提交并应用 :invalid,但除非同时渲染一条可见的、通过 aria-describedby 关联的错误消息,用户得到的将是一个静默拒绝提交的表单。#renderError 步骤将在无障碍章节中介绍。

四个表单生命周期回调

表单关联自定义元素在标准自定义元素生命周期之外,还额外获得了四个响应回调。这是大多数教程所跳过的完整性关键部分。formAssociatedCallback(form) 在关联表单发生变化时调用;formResetCallback() 在表单重置时调用,元素应清除用户设置的值;formDisabledCallback(isDisabled) 在禁用状态变化时调用;formStateRestoreCallback(state, reason) 在浏览器恢复元素状态(原因为 "restore")或完成自动填充(原因为 "autocomplete")时调用。

回调触发时机参数典型实现
formAssociatedCallback元素获得/失去表单所有者新的 form(或 null关联依赖表单的状态
formDisabledCallback宿主或祖先 fieldset 的禁用状态切换boolean同步到内部 input
formResetCallback表单重置恢复默认值
formStateRestoreCallback前进/后退导航、页面重载、自动填充恢复的状态、原因从保存的状态重新初始化
formDisabledCallback(disabled) {
  this.input.disabled = disabled;
}

formResetCallback() {
  this.value = this.getAttribute('value') ?? '';
  this.#internals.setValidity({});
  this.#renderError('');
}

formStateRestoreCallback(state, reason) {
  if (reason === 'restore') this.value = state;
}

disabledreadonly 属性在此具有规范层面的含义:disabled 属性使表单关联自定义元素变为非交互状态,并阻止其提交值被提交,而 readonly 属性则指定该元素不参与约束验证。

符合规范的状态恢复

setFormValue() 接受一个可选的第二个参数——setFormValue(value, state)——其中 state 是仅在客户端使用的表示,不会发送到服务器,适用于恢复比裸提交值更丰富的内部状态。规范现已明确恢复回调接收的内容:当用户代理代表用户或在导航过程中更新表单关联自定义元素的值时,会调用其 formStateRestoreCallback,传入新的状态以及表示原因的字符串——"autocomplete""restore"。早期规范的不一致性曾导致实际分歧——Chromium 使用值调用回调,而 WebKit 使用状态调用回调——但该问题已以状态为准得到解决。如果需要支持较旧的 Chromium/Edge 版本,请对该参数进行防御性处理。

目前只有 "restore" 是可靠的。Firefox 已支持 formStateRestoreCallback,但注明自定义元素的 'autocomplete' 仍不受支持。请将 "restore"(后退导航和页面重载)视为跨引擎唯一可依赖的模式。

正确实现无障碍

设置验证状态与传达验证状态是两回事。要使错误可被感知,需要在 shadow root 内渲染一条消息,通过 aria-describedby 将其与内部 input 关联,并将其标记为动态区域(live region),使辅助技术能够播报它:

#renderError(message) {
  let err = this.shadowRoot.querySelector('.error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'error';
    err.id = 'err';
    err.setAttribute('role', 'alert');
    err.setAttribute('aria-live', 'assertive');
    this.shadowRoot.append(err);
    this.input.setAttribute('aria-describedby', 'err');
  }
  err.textContent = message;
}

由于 input 和错误提示都位于同一个 shadow root 中,aria-describedby 可在该树内正确解析。宿主元素自身的 ARIA 语义可通过 ElementInternals 属性设置——该接口现已获得广泛支持,包括 ElementInternals.rolearia* 反射属性。

一种常见的生产故障模式是”看似完成”但静默失败的控件——而这正是会话回放(session replay)所能发现的 bug,因为它们不会在控制台抛出任何错误。回放可以揭示:用户反复点击提交,而控件报告了 valueMissing 却没有显示任何可见消息;标签点击无处落地,因为从未设置 delegatesFocus;或者提交的数据缺少某个字段,因为 setter 从未调用 setFormValue。还有一个值得在代码审查中标注的陷阱:将原生 required input 插槽化(slot)到自定义元素中,会使 light DOM 中的控件与宿主元素同时参与表单,造成双重参与。当宿主元素本身是表单关联元素时,请勿将原生控件放入插槽。

在框架中使用

表单关联自定义元素的框架集成,正是对这里所展示的原生 ElementInternals API 的封装。Stencil 自 v4.5.0 起通过 @AttachInternals 装饰器提供一流支持,其 formStateRestoreCallback 同样接收第二个 mode 参数,值为 "restore""autocomplete",表示恢复原因。Lit 并未内置 FACE 支持——需要自行添加一个小型 FormAssociated mixin,或引入社区提供的 mixin——但该 mixin 封装的正是这里所展示的 formAssociated 标志、attachInternals()setFormValue() 和生命周期回调。直接学习平台 API,意味着你能够读懂、调试或替换任何这些封装层。

将各个部分组合在一起——value setter、内部 input 同步、delegatesFocus、带有关联 role="alert" 消息的 setValidity,以及全部四个生命周期回调——你就拥有了一个与原生 input 无异的 <text-field>,能够提交数据、执行验证、响应重置并恢复状态。将其放入 <form>,关联一个 <label for>,下一个具体步骤是验证完整的数据往返:提交表单,从 new FormData(form) 中读回值,并确认约束验证会阻止空的必填字段提交。

常见问题

formdata 事件与 ElementInternals 在向表单添加值方面有何区别?

formdata 事件用于向提交数据中注入任意数据:在表单上监听该事件并调用 event.formData.append() 来添加字段。ElementInternals 则用于构建一个完整参与表单的可复用控件,包括通过 setFormValue 和 setValidity 实现约束验证、标签关联、重置和状态恢复。如需快速一次性注入数据,使用 formdata 事件;如需构建行为与原生 input 一致的控件,则使用 ElementInternals。

为何 attachInternals() 会抛出 NotSupportedError?

当元素不是自主自定义元素时,attachInternals() 会抛出 NotSupportedError。表单关联自定义元素必须直接继承 HTMLElement,而不能是 HTMLInputElement 等内置子类,且该类必须在实例化前通过 customElements.define 注册。在同一元素上多次调用 attachInternals() 也会抛出异常。请在继承 HTMLElement 的类的构造函数中调用一次,以避免该错误。

delegatesFocus 是否会干扰表单关联自定义元素上的手动 tabindex 管理?

将 shadow root 的 delegatesFocus 设置为 true 后,通常无需手动管理 tabindex。焦点会委托给 shadow root 内第一个可聚焦的元素,即已在 tab 顺序中的内部 input,因此宿主元素会自动转发焦点。标签点击和键盘 Tab 键都能到达内部 input。在宿主元素上手动添加 tabindex 可能会产生重复的 tab 停靠点,因此当 delegatesFocus 已处理焦点路由时,请省略手动设置。

调用 setFormValue(null) 会发生什么?

调用 internals.setFormValue(null) 会将该元素完全从表单提交中移除:其名称和值不会出现在表单生成的 FormData 对象中。这与传入空字符串不同——传入空字符串会以空值提交该字段。当需要明确排除某个控件(如禁用或只读字段)时,传入 null;当控件应参与提交时,传入实际的值字符串或 FormData。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.