前端知库

表单关联自定义元素怎样参与原生提交和校验?

通过 ElementInternals 和 formAssociated 让自定义元素参与 FormData、约束校验、禁用、重置和状态恢复,同时说明 Shadow DOM、可访问语义、浏览器兼容性和组件生命周期边界。

参考答案

简答

  • 自定义元素声明 static formAssociated = true,并通过 attachInternals 获得 ElementInternals。
  • setFormValue 决定提交值,setValidity 接入浏览器的约束校验。
  • 连接到 DOM 时要先同步一次有效性,必填规则按 required 属性决定。
  • 禁用、重置和状态恢复通过 formDisabledCallback 等生命周期回调处理。

组件库经常希望把一个复杂控件写成 <price-input> 或 <date-range-picker>,但使用者仍能像使用原生 <input> 一样把它放进 <form>:浏览器收集它的值、执行 required 校验、响应 form.reset(),提交失败时还能把焦点放到内部输入框。仅仅在自定义元素内部放一个 <input> 并不能自动做到这些,因为 Shadow DOM 内部的控件不是外层表单的直接表单关联控件。

表单关联自定义元素(form-associated custom element)通过 ElementInternals 把组件和外层表单连接起来。组件仍然自己管理内部 DOM 和交互,但把值、有效性和生命周期状态交给浏览器的表单模型。

先声明组件愿意成为表单控件

自定义元素类需要设置静态 formAssociated = true,并在构造器中调用 attachInternals():

JavaScript
class PriceInput extends HTMLElement {
  static formAssociated = true;

  #internals;
  #input;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <label>
        <span>金额</span>
        <input inputmode="decimal" aria-describedby="error">
      </label>
      <span id="error" role="alert"></span>
    `;
    this.#input = root.querySelector('input');
    this.#input.addEventListener('input', () => this.#syncValue());
    this.#input.addEventListener('blur', () => this.#syncValidity());
  }

  connectedCallback() {
    // 初始就同步一次,否则用户不输入直接提交也会被视为有效
    this.#syncValidity();
  }

  #syncValue() {
    const value = this.#input.value.trim();
    this.#internals.setFormValue(value || null);
    this.#syncValidity();
  }

  #syncValidity() {
    const value = this.#input.value.trim();
    if (!value) {
      if (this.hasAttribute('required')) {
        this.#internals.setValidity({ valueMissing: true }, '请输入金额', this.#input);
      } else {
        this.#internals.setValidity({});
      }
      return;
    }

    const amount = Number(value);
    if (!Number.isFinite(amount) || amount < 0) {
      this.#internals.setValidity({ badInput: true }, '金额必须是非负数字', this.#input);
      return;
    }

    this.#internals.setValidity({});
  }

  formResetCallback() {
    this.#input.value = '';
    this.#internals.setFormValue(null);
    this.#syncValidity();
  }
}

customElements.define('price-input', PriceInput);

页面可以直接使用:

HTML
<form id="order-form">
  <price-input name="amount" required></price-input>
  <button type="submit">提交</button>
  <button type="reset">重置</button>
</form>

setFormValue() 的第一个参数是提交值。传入 null 表示当前没有要提交的值;传入字符串或 File 等适用值,浏览器才会在表单数据中产生对应的 name=value 条目。自定义元素必须有 name 才能像普通控件一样产生字段;没有 name 时,即使内部状态正常,也不应假设它会自动出现在 FormData 中。

setValidity 连接浏览器校验和内部焦点

setValidity(flags, message, anchor) 让组件把约束状态暴露给外层表单。flags 不是任意业务对象,使用 valueMissing、typeMismatch、badInput、customError 等标准约束标志;传空对象表示有效。设置非空 flags 时必须提供非空消息,否则调用可能抛出异常。

第三个参数可以指向 Shadow DOM 内部的控件,浏览器在交互式校验失败时可以把焦点关联到这个 anchor。它不会替组件自动完成所有读屏提示:内部输入仍需要可访问名称、错误文本和合适的 aria-describedby,组件本身还要提供清楚的外部标签或命名方式。

checkValidity() 和 reportValidity() 可以作用于自定义元素,表单调用 checkValidity() 时也会考虑组件的状态。不要只在 click 事件中显示红字,否则键盘提交、脚本 requestSubmit() 和浏览器原生表单流程可能得到不同结果。

组件生命周期回调不能当成普通 DOM 事件

除了 formResetCallback(),表单关联自定义元素还可以实现这些回调:

  • formAssociatedCallback(form):元素关联或解除某个表单时调用;
  • formDisabledCallback(disabled):自身或祖先 fieldset 被禁用时调用;
  • formResetCallback():外层表单执行 reset 时调用;
  • formStateRestoreCallback(state, mode):浏览器恢复表单状态时调用,具体触发条件与浏览器实现有关。

例如,禁用状态应该同步到内部输入,而不是只给宿主元素加一个看起来灰色的 class:

JavaScript
formDisabledCallback(disabled) {
  this.#input.disabled = disabled;
  // 通过 ElementInternals 暴露语义,避免空字符串属性值不被识别为 true
  this.#internals.ariaDisabled = String(disabled);
}

这段回调只有在类已完成初始化后才安全。组件连接、断开、重新连接和表单归属变化可能交错发生,不能假设构造器之后只会有一次生命周期。外部监听器、定时器和异步校验仍需在 disconnectedCallback() 中清理;表单关联能力不会自动取消网络请求。

初始值、显示值和提交值要分开

复杂控件常有多个内部值,例如日期范围有开始日期、结束日期和格式化显示文本。提交值可以用一个字符串,也可以使用 FormData 表示多个同名条目;不要把用户看见的本地化字符串直接当成服务端稳定数据:

JavaScript
this.#internals.setFormValue(
  JSON.stringify({ start: '2026-10-28', end: '2026-10-31' }),
);

实际应用应定义稳定的序列化契约、时区、空值和非法范围规则。第二个 setFormValue(value, state) 参数可以保存用于状态恢复的内部状态,通常不应把它当成提交给服务器的第二个字段。恢复状态时需要验证版本和数据来源,不能把任意恢复字符串直接当作可信业务输入。

重置行为也要有明确的初始状态。可以在元素连接时保存属性提供的默认值,在 formResetCallback() 中恢复;不要只把内部 input 清空,却忘记同步 setFormValue() 和有效性状态,否则界面与实际提交数据会不一致。

name、禁用和表单外关联仍按原生规则

自定义元素的宿主节点才是表单关联控件。它可以放在 form 外,通过 form="form-id" 关联一个表单,但这只改变归属,不会让任意 Shadow DOM 后代自动参与提交。disabled、祖先 fieldset、表单的 novalidate 和提交按钮规则仍会影响最终行为。

组件不能把禁用只实现成 pointer-events: none:这会让鼠标点不到,却可能仍被键盘聚焦、仍能提交值或仍在辅助技术树中显示为可用。应让 formDisabledCallback、内部控件的 disabled、可访问状态和 setFormValue 的行为保持一致,并实际检查键盘和读屏流程。

与框架受控组件的边界

React、Vue 等框架可能把自定义元素当成普通 DOM 元素处理,属性和 property 的传递、事件名称、表单 reset 和受控状态更新需要单独适配。不要因为浏览器原生表单能收集值,就假设框架的 v-model 或 value prop 已经自动双向绑定。

组件可以暴露 value property 和 input/change 自定义事件,但要规定谁是状态的最终来源:

  • 用户输入先更新内部值,再调用 setFormValue 并派发事件;
  • 外部设置 property 时,更新内部显示、提交值和校验;
  • 外部状态与用户输入同时到达时,按版本或事件顺序避免旧值覆盖新值;
  • 组件卸载时移除监听和取消异步校验,不能让迟到结果改变已断开的元素。

这类契约比“封装一个 input”复杂得多,但它能让组件在原生表单、框架表单和渐进增强页面中保持一致行为。

兼容性和安全边界

ElementInternals、表单关联自定义元素和相关状态恢复能力属于较新的 Web Components 能力,目标浏览器和 WebView 的支持程度需要查 MDN 兼容性数据并实际验证。旧环境可以把组件降级成普通内部控件或提供显式 hidden input,但降级路径必须避免同一个值被提交两次,并重新实现校验、禁用和 reset 语义。

服务端仍要校验最终提交值。setFormValue 只是浏览器提交路径,不能防止用户直接构造请求、修改 DOM 或绕过前端校验。金额、日期、权限和租户字段必须在服务端按业务规则重新解析和授权;自定义元素的 Shadow DOM 也不是安全边界。

验收一个表单关联自定义元素时,至少检查:

  • FormData 是否只出现预期的 name/value,空值和同名多值是否明确;
  • 用户提交、按 Enter、requestSubmit() 和 form.reset() 是否一致;
  • checkValidity()、reportValidity()、错误消息和焦点是否正确;
  • fieldset[disabled]、元素自身 disabled、表单外 form 属性是否有效;
  • 元素移除、重新插入、切换 form 和异步校验后,是否没有重复监听或迟到状态;
  • 不支持 ElementInternals 的浏览器是否仍能显示、提交或明确降级。

表单关联自定义元素的价值,不是把所有原生控件行为复制一遍,而是让组件在需要复杂交互时仍遵守浏览器的表单契约。把提交值、有效性、禁用、重置、焦点和资源生命周期一起设计,组件才不会只在演示页面里工作。