### [HTML 里 role=”timer” 到底怎么让读屏同步倒计时](https://www.huociguo.com/article/1446) **Published:** 2026-09-26T10:12:25 **Author:** 米了 **Excerpt:** 页面右上角那个「60 秒后重新获取」,看得见的人盯着数字往下掉;用读屏的人呢?多半只在第一次聚焦时听到一句「剩… 页面右上角那个「60 秒后重新获取」,看得见的人盯着数字往下掉;用读屏的人呢?多半只在第一次聚焦时听到一句「剩余 60 秒」,然后就安静了。等他想再确认一次,得手动把焦点挪回去——在抢购、答题、验证码这种场景里,这基本等于没给信息。 问题不在读屏软件,在我们对 `role="timer"` 的理解。这个属性只声明了语义,没有声明行为。 ![](https://api.huociguo.com/wp-content/uploads/2026/09/20260926181202226-2610d4887a-1.png "20260926181202226-2610d4887a-1") 读屏不会自己去「盯」一个元素,得由 aria-live 明确告诉它:这里变了要念出来 ## **role=”timer” 不负责播报** 直觉上我们觉得「都说了这是 timer,读屏就该每秒念一次」。但 ARIA 规范里,`timer` 属于 live region 家族,而它**隐含的** `aria-live` **值是** `off`——也就是说,只挂一个 `role`,读屏没有任何「监听」义务。它只在初次渲染或焦点进入时念一次,之后你改多少次,都是静音。 顺手把这一家子的默认值理一遍,差别就清楚了: | | | | | --- | --- | --- | | **role** | **隐含 aria-live** | **适合干的事** | | `alert` | assertive(打断) | 表单报错、支付失败 | | `status` | polite(排队) | 「保存成功」这类状态提示 | | `log` | polite | 聊天记录、日志追加 | | `marquee` | off | 滚动字幕,一般不播报 | | `timer` | off | 倒计时 / 计时器,**必须自己补 aria-live** | 所以正确组合是:`role="timer"` 管语义(告诉辅助技术「这是一段计时信息」),`aria-live="polite"` 管行为(「内容变了就排队念一遍」)。两个缺一不可。 **html**反例 · 只念一次 ```
剩余 60 秒
剩余 60 秒
``` ## **三个属性各管一摊,别照抄不理解** `aria-live="polite"` 变更时排队播报,等当前这句念完再插进来,不打断用户正在做的操作。倒计时用 `polite`;`assertive` 是留给「验证码错误」「余额不足」这种必须立刻打断的话,拿它念秒数,用户会被你吵到关掉读屏。 `aria-atomic="true"` 把容器当整体念一遍。默认是 `false`,读屏只念变化的那一段——你把「剩余」和「秒」写在外面、只改中间那个数字,读屏可能只蹦出一个「3」,用户听到的是一串孤零零的数字。设成 `true`,「剩余 3 秒」才是完整的一句话。 `aria-relevant="text"` 只关心文本内容变化。默认值是 `additions text`(节点新增 + 文本变化),意味着你插一个装饰图标、换一次 class,都可能被当成「新增了点东西」念出来。倒计时本来就是纯文本更新,显式写 `text` 最干净。另外别碰 `removals`,否则删节点时读屏会念「移除……」,非常吵。 **还有个不写在例子里的前提:**带 `aria-live` 的容器,必须在页面初次渲染时就存在于 DOM 里。运行时才 `appendChild` 进去的容器,很多读屏(NVDA + Firefox 上尤其明显)不认第一次变化——大家管这叫「live region 没被注册上」。先渲染一个空容器,之后只往里填文本,才是稳的。 ## **真正的难点:别把读屏念成机关枪** 一秒刷一次,对眼睛很自然;对耳朵,60 秒就是 60 句话排进队列。`polite` 的语义是「等这句念完再念下一句」,用户想动一下界面,就被这一串数字拖住,NVDA 上还能听出明显的延迟感。 我常用的做法是**拆成两条通道**:眼睛看的一条,耳朵听的一条。 - 视觉层:照旧每秒刷新,该怎么显示怎么显示; - 听觉层:一个只对读屏可见的 live region,只在关键节点更新——开始、60、30、10,最后 5 秒逐秒报。 **html**双通道结构 ```
剩余 60 秒
``` 视觉层加 `aria-hidden="true"` 是必要的:否则用户聚焦到那一行时,数字被念一次,live region 又念一次,同一个人说两遍话。 **javascript**关键节点才出声 ``` const MILESTONES = new Set([60, 30, 10, 5, 4, 3, 2, 1]); function tick() { // 眼睛:每秒都刷 numEl.textContent = String(left).padStart(2, '0'); // 耳朵:只在这些点上说一句 if (MILESTONES.has(left)) announce(format(left)); } ``` 这里有个坑要提前说:`.sr-only` 只能用「裁剪」的方式隐藏,**不能**用 `display:none` 或 `visibility:hidden`。不渲染的节点,读屏根本不会去监听它,live region 直接失效。 **css**只给读屏看的隐藏方式 ``` .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } ``` ## **更新 DOM 的手势要对** - **只改** `textContent`**。**别 `innerHTML`,更别整块 `outerHTML` 重写。重建节点对读屏来说是「删除 + 新增」,轻则重复播报,重则监听关系丢失,后边彻底不念了——这类 bug 最难查,因为它只在某些读屏上出现。 - **数字别零填充给读屏。**视觉上 `05` 好看,读屏可能念成「零五」。要好看就在视觉层补零,听觉层老老实实写「5 秒」。 - **超过一分钟要说人话。**丢一个 `90` 过去,用户得自己换算;写成「1 分 30 秒」才叫同步。 - **容器初始就在 DOM 里**,前面说过,再说一遍,因为它是最常见的原因。 ## **暂停、归零和 aria-busy** 暂停时把 `aria-busy="true"` 挂到 live region 上,读屏会暂缓播报这段时间的变更,恢复时再去掉。不然用户点暂停后,还可能被一堆已经过期的数字追着念。 归零那一下值得单独打断一次:切 `aria-live` 的值在部分读屏上不生效,更稳的是另起一个 `role="alert"` 的节点专门说「时间到」。 再补一条容易被忘的:页面切到后台时(`visibilitychange`)先停表,回来再走。否则用户切回来的瞬间,读屏很可能一次性补念十几条。 **javascript**暂停 / 恢复 / 归零 ``` function announce(text, urgent) { const box = urgent ? alertEl : liveEl; box.textContent = text; // 只换文本,不碰结构 } function pause() { clearInterval(timer); timer = null; liveEl.setAttribute('aria-busy', 'true'); // 暂停期间先别念 announce('倒计时已暂停', true); } function resume() { liveEl.removeAttribute('aria-busy'); announce(format(left)); timer = setInterval(tick, 1000); } document.addEventListener('visibilitychange', () => { if (timer) document.hidden ? pause() : resume(); }); ``` ## **一份能直接抄的完整实现** 下面是短信验证码 60 秒倒计时的完整版,存成 `.html` 打开就能跑,用读屏软件听一下差别。 **html**countdown.html · 完整示例 ``` 可访问的短信验证码倒计时
``` ## **实测清单与收尾核对表** 纸上跑通不算数,live region 的实现差异是真存在的。至少过一遍这几组: | | | | | --- | --- | --- | | **组合** | **重点验证** | **常见状况** | | NVDA + Firefox | 动态插入的容器是否被注册 | 后插的 live region 第一次变化不播报 | | NVDA + Chrome | polite 队列堆积 | 高频更新时播报明显滞后于画面 | | JAWS + Chrome | `aria-relevant` 的取值 | 对非默认值支持偏保守,建议用 `text` | | VoiceOver + Safari(macOS) | 切换标签页后回来 | 偶发漏播,别依赖焦点触发播报 | | VoiceOver + Safari(iOS) | 播报频率 | 节点太密容易卡,关键节点要更稀疏 | | TalkBack + Chrome(Android) | `aria-atomic` | false 下表现不一,统一设 true 更省心 | **提交前对着过一遍** - `role="timer"` 和 `aria-live` 是同时写的,不是二选一 - `aria-atomic="true"` 已设,整句播报而不是单个数字 - `aria-relevant="text"` 已设,装饰性变更不会插话 - 容器初始就在 DOM 里,不是用到才插 - 更新只改 `textContent`,没重建节点 - 播报做了节流,眼睛和耳朵分开喂 - 视觉层 `aria-hidden`,同一个数字不会被念两遍 - 暂停有 `aria-busy`,归零有 alert 打断 - 真机 + 读屏实测过,不是只看 MDN 无障碍不是给标签打勾:`role="timer"` 写上了,清单上就多一个√,但用户听到的还是 60 秒的沉默。真正要对齐的是信息量——你眼睛看到数字在掉,他的耳朵也该知道还剩多久。 **Categories:** HTML5 ---