自定义元素必须调用 attachInternals() 才能真正参与原生表单逻辑,否则无法被 form 收集、校验或提交;需配合 setFormValue()、checkValidity() 等手动桥接,并注意事件派发、焦点管理与可访问性全链路重写。

自定义元素必须用 attachInternals() 才能参与表单逻辑
原生表单控件(如 input、select)能自动被 form 收集、校验、提交,是因为它们内置了表单关联能力。而自定义元素默认是“表单绝缘体”——哪怕你把它放在 form 里,form.elements 不会包含它,form.reportValidity() 也完全无视它。
真正让自定义元素融入表单体系的唯一路径,是调用 attachInternals() 并配合 setValidity()、checkValidity()、value setter 等手动桥接。不这么做,就只能靠外部 JavaScript 拦截 submit 事件、手动收集值、手动验证,本质上仍是“假表单”。
-
attachInternals()必须在constructor或connectedCallback早期调用,晚了会报错 - 返回的
internals对象不能缓存到实例属性以外的地方,否则可能被 GC 掉 -
internals.setFormValue()是控制该元素在FormData中呈现值的唯一标准方式,别用dataset或自定义属性模拟
事件监听要区分“用户触发”和“程序触发”,避免死循环
自定义元素内部常需同步状态:比如用户点击选项后,要更新内部 value、触发 change 事件、同时更新 UI 样式。但如果所有变更都统一派发 change,再由外部监听器反过来调用元素方法,很容易形成无限递归。
可靠做法是加一层判断:只在真正由用户操作(如 click、input)驱动的状态变更时派发事件;程序调用(如 element.value = 'x')则跳过事件派发。
立即学习“前端免费学习笔记(深入)”;
- 用
event.isTrusted === true判断是否为真实用户操作(注意:部分浏览器对合成事件设为false,但仍是必要防线) - 对外暴露的 setter(如
set value(val))内部应设标记位,避免触发自身监听逻辑 - 不要在
change回调里直接修改this.value,除非你明确需要双向同步且已处理循环
复杂交互场景下,Shadow DOM 的 slot 和 part 比 CSS 类更可控
当你的自定义元素支持插槽内容(如 <my-select><span slot="option">A</span></my-select>),又需要让用户定制选项样式时,仅靠开放 class 或全局 CSS 很容易污染或失效。
正确姿势是结合 :host(<selector>) + ::part() + <slot> 的组合:
- 用
<slot name="option">明确内容出口,而非依赖子元素遍历 - 在 Shadow DOM 内给插槽容器加
part="option",再通过my-select::part(option)允许外部精确样式化 - 避免在 Shadow DOM 内写
.option这类类名,它会被封装隔离,外部无法穿透 - 若需响应插槽内容变化,监听
slotchange事件,而不是DOMSubtreeModified(已废弃)
拖拽、多选、异步加载这类交互,必须自己管理 focus 和键盘导航
浏览器不会自动为自定义元素赋予可聚焦性或键盘行为。像 my-autocomplete 支持上下键切换选项、my-dnd-list 支持 Tab 键顺序聚焦,这些都不能靠 tabindex 一设了事。
关键点在于:把焦点管理当作交互逻辑的一部分,而不是附加功能。
- 用
element.focus()主动接管焦点,尤其在用户按下 Enter 选中某项后 - 监听
keydown事件,识别ArrowUp/ArrowDown/Home/End,并阻止默认行为 - 动态更新
aria-activedescendant属性指向当前高亮项的 ID,配合role="listbox"等语义角色 - 每次键盘操作后,确保视觉焦点环(
outline)出现在正确位置,别让它卡在宿主元素上不动
最常被忽略的是:自定义元素一旦脱离原生表单控件的“舒适区”,所有可访问性链路都要重写——不是加几个 ARIA 属性就完事,而是得模拟整个交互生命周期。



















