应优先使用原生语义化标签(如、),因其天然支持屏幕阅读器导航、键盘聚焦与触控反馈;自定义元素需显式设置tabindex、aria-label并监听keydown,且dom深度不宜超6层,否则触控延迟显著增加。

直接用 <button></button>、<input type="checkbox"> 这类原生标签,别自己造 <div role="button"> —— 后者在 VoiceOver/TalkBack 下大概率被跳过,且不响应 <code>:active 或键盘触发。
为什么自定义元素在触控下容易“失联”
移动端辅助技术(如 iOS VoiceOver)不是靠视觉定位按钮,而是解析 DOM 的语义结构生成导航树。<div class="btn"> 没有语义,屏幕阅读器会读作“静态文本”,手指点下去也收不到系统级触控反馈通道。实测中常见现象是:真机双指滑动切换焦点时,某个“按钮”直接被跳过;或点击后无任何反馈,但手动触摸却能触发 JS 逻辑——这说明 DOM 存在,但语义缺失、事件链断裂。
<ul>
<li>
<code>role="button" 不自动获得 keyboard focus,也不触发 :active,在部分 WebView(如微信 X5)中完全无效
tabindex="0",键盘用户无法 tab 进入,VoiceOver 用户也无法聚焦tabindex,它仍不支持空格/回车触发,必须手动监听 keydown 并模拟 click,维护成本高哪些场景真需要自定义元素?怎么安全替换
只有三类情况值得考虑封装:图标按钮(无文字)、开关(<input type="checkbox"> 样式不可控)、复杂菜单触发器(需嵌套 SVG + badge)。其他一律用原生标签。
- 图标按钮:用
<button><svg aria-hidden="true"></svg></button>,再加min-width: 48px; min-height: 48px;强制兜底尺寸 - 开关:保留
<input type="checkbox" id="toggle">,用label[for="toggle"]包裹自定义 UI,确保点击 label 文字区域也能切换 - 菜单触发器:用
<button aria-expanded="false"></button>,JS 控制aria-expanded值,并在展开后主动firstFocusableElement.focus()
DOM 深度超 6 层会让 touchstart 延迟翻倍
浏览器解析 HTML 是流式过程,每层嵌套都会延长事件冒泡路径。当用户手指落在一个深层嵌套的 <div> 上,<code>touchstart 要从最内层逐层向上冒泡到绑定监听器的父容器。中间若遇到 pointer-events: none 或 touch-action: none 的中间层,就可能中断。
- 实测:
body > div > div > div > div > div > button(7 层),低端安卓设备上touchstart到 handler 执行平均延迟 42ms;三层结构稳定在 8–12ms - Chrome DevTools → Elements 面板右键任意元素 →
Show DOM properties查看depth值,超过 6 就该重构 - 避免为加 class 或 JS 选择器而套
<div>:改用 CSS <code>display: contents(Safari 15.4+ 支持)抹除无意义包裹层touchstart 绑定在自定义元素上要特别小心
很多团队以为绑了
touchstart就能解决 300ms 延迟,结果反而破坏可访问性。关键在于:原生按钮自带click、keydown、focus全链路,而自定义元素一旦监听touchstart又没同步处理键盘和屏幕阅读器,等于把门关死。- 如果必须用
touchstart(比如做拖拽),记得加{ passive: false },否则调preventDefault()会报错 - 按钮类交互不要在
touchstart里调preventDefault(),iOS 某些 WebView 会因此阻断后续click或跳转 - 所有自定义交互必须补
keydown监听(Enter/Space),并确保tabindex="0"和aria-label齐全
真正难的不是写个
role="button",而是让这个元素在 VoiceOver 下能被“摸到”、在键盘下能被“走到”、在手指下能被“稳稳点中”——这三件事同时成立,才叫触控友好。DOM 层级、语义标签、事件绑定方式,任何一个环节松动,体验就断掉一截。 - 如果必须用











