HTML 里 role="timer" 到底怎么让读屏同步倒计时

发布于
2

页面右上角那个「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反例 · 只念一次

<!-- 只有语义,没有行为:读屏聚焦时念一次就没了 -->
<div role="timer">剩余 <span id="cd">60</span> 秒</div>

<!-- 完整写法:语义 + 播报行为 + 整体朗读 + 只听文本变化 -->
<div role="timer" aria-live="polite" aria-atomic="true" aria-relevant="text">
  剩余 <span id="cd">60</span> 秒
</div>

三个属性各管一摊,别照抄不理解

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双通道结构

<!-- 视觉层:每秒刷,读屏直接跳过 -->
<div class="resend" aria-hidden="true">
  <span id="cd-num">60</span> 秒后可重新发送
</div>

<!-- 听觉层:只在关键节点更新 -->
<div id="cd-live" class="sr-only"
     role="timer" aria-live="polite" aria-atomic="true" aria-relevant="text">剩余 60 秒</div>

<!-- 需要打断时走这里,比如「时间到」 -->
<div id="cd-alert" class="sr-only" role="alert"></div>

视觉层加 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 打开就能跑,用读屏软件听一下差别。

htmlcountdown.html · 完整示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>可访问的短信验证码倒计时</title>
<style>
  .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}
  .resend{font:16px/1.6 system-ui,sans-serif;color:#555}
  .resend b{font-size:20px;color:#b45309}
</style>
</head>
<body>

<button id="send">发送验证码</button>

<!-- 视觉层:每秒刷新,对读屏隐藏 -->
<p class="resend" aria-hidden="true" hidden id="cd-view">
  <b id="cd-num">60</b> 秒后可重新发送
</p>

<!-- 听觉层:关键节点才播报 -->
<div id="cd-live" class="sr-only" role="timer"
     aria-live="polite" aria-atomic="true" aria-relevant="text"></div>
<!-- 打断层:时间到 -->
<div id="cd-alert" class="sr-only" role="alert"></div>

<script>
const TOTAL = 60;
const MILESTONES = new Set([60, 30, 10, 5, 4, 3, 2, 1]);
const viewEl  = document.getElementById('cd-view');
const numEl   = document.getElementById('cd-num');
const liveEl  = document.getElementById('cd-live');
const alertEl = document.getElementById('cd-alert');
const sendBtn = document.getElementById('send');

let left = TOTAL, timer = null;

// 听觉层用自然语言,别丢一个裸数字过去
function format(n){
  if (n < 60) return '剩余 ' + n + ' 秒';
  const m = Math.floor(n / 60), s = n % 60;
  return s ? '剩余 ' + m + ' 分 ' + s + ' 秒' : '剩余 ' + m + ' 分';
}

function announce(text, urgent){
  const box = urgent ? alertEl : liveEl;
  box.textContent = '';                 // 清空再写,确保内容确实「变了」
  box.textContent = text;
}

function tick(){
  numEl.textContent = String(left).padStart(2, '0');  // 眼睛:补零好看
  if (MILESTONES.has(left)) announce(format(left));    // 耳朵:关键节点出声
  if (left === 0) { stop(); return; }
  left -= 1;
}

function start(){
  left = TOTAL;
  viewEl.hidden = false;
  sendBtn.disabled = true;
  announce(format(TOTAL));              // 开局先说一句,别让用户干等
  timer = setInterval(tick, 1000);
}

function stop(){
  clearInterval(timer); timer = null;
  viewEl.hidden = true;
  sendBtn.disabled = false;
  liveEl.setAttribute('aria-busy', 'true');
  announce('时间到,可以重新发送验证码', true);
}

sendBtn.addEventListener('click', start);
document.addEventListener('visibilitychange', () => {
  if (timer) document.hidden ? pause() : resume();
});

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);
}
</script>
</body>
</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 秒的沉默。真正要对齐的是信息量——你眼睛看到数字在掉,他的耳朵也该知道还剩多久。

0 / 600
0 讨论
热门最新
总结
暂无总结
嗨,下午好!
所有的成功,都源自一个勇敢的开始

暂无福利券