### [HTML 里 role=”timer” 到底怎么让读屏同步倒计时](https://www.huociguo.com/article/1446)
**Published:** 2026-09-26T10:12:25
**Author:** 米了
**Excerpt:** 页面右上角那个「60 秒后重新获取」,看得见的人盯着数字往下掉;用读屏的人呢?多半只在第一次聚焦时听到一句「剩…
页面右上角那个「60 秒后重新获取」,看得见的人盯着数字往下掉;用读屏的人呢?多半只在第一次聚焦时听到一句「剩余 60 秒」,然后就安静了。等他想再确认一次,得手动把焦点挪回去——在抢购、答题、验证码这种场景里,这基本等于没给信息。
问题不在读屏软件,在我们对 `role="timer"` 的理解。这个属性只声明了语义,没有声明行为。

读屏不会自己去「盯」一个元素,得由 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 秒后可重新发送
剩余 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 · 完整示例
```
可访问的短信验证码倒计时
60 秒后可重新发送
```
## **实测清单与收尾核对表**
纸上跑通不算数,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
---