错误条出现了,读屏用户什么也没听到
某客户端在支付失败后,会在页面顶部插入一条红色错误条:「银行卡余额不足,请更换支付方式」。视觉用户一眼看到,读屏用户却停留在原来的提交按钮上,什么也没收到。同一版本里,优惠券排序只能拖动,键盘用户按遍所有按键也无法调整顺序;系统设置里明明开了「减少动效」,首页的自动轮播仍然一张接一张地翻。
三个问题共享一个根因:无障碍被当成了视觉层的附属品。错误条是视觉组件,拖动是鼠标交互,轮播是动画,各自团队都完成了「看得见」的部分,没有人负责「感知得到」与「操作得了」。
读屏播报、键盘可达性与偏好映射,本质上都是状态到行为的映射。映射写成纯函数,才能被测试、被复用,也才能在五个端上保持一致。
播报不是把文字塞进 aria-live
把任意文字塞进 aria-live 会制造噪音:进度每变一个百分点就播一次,用户会被打断得无法操作。播报要先分类,再决定礼貌级别与频率。
| 事件 | 礼貌级别 | 播报内容 | 频率控制 |
|---|---|---|---|
| 状态保存成功 | polite | 原文 | 相同文本窗口内去重 |
| 操作失败 | assertive | 加「错误:」前缀 | 立即播报,不排队 |
| 导航完成 | polite | 已进入 + 页面标题 | 每次路由变化一次 |
| 选择数量变化 | polite | 已选择 N 项,共 M 项 | 数值夹在合法区间 |
| 长任务进度 | polite | 标签 + 百分比 | 只在 0/25/50/75/100 播报 |
assertive 会打断用户当前朗读,所以只留给「必须马上知道」的错误。把成功提示也设成 assertive,用户每保存一次就被打断一次,最后会关掉读屏。
进度播报的关键是里程碑,而不是实时值。上传到 37% 时播报一次「37%」,除了制造噪音没有任何决策价值;到 50% 与 100% 才值得让用户知道。
去重也需要状态。相同文本在 1.5 秒内重复出现时抑制第二次,可以挡住「保存成功」被连续触发两次的抖动。去重窗口是产品决策,不是实现细节:窗口太长会吞掉真实变化,太短则挡不住抖动。
键盘可达性要审计绑定,不是审计截图
键盘可达性不能靠「我按了一遍」来证明。把动作与按键绑定写成表,才能算出哪些动作不可达、哪些绑定互相冲突。
| 检查 | 回答的问题 | 失败表现 |
|---|---|---|
| 未绑定动作 | 哪些功能只能鼠标操作 | 拖动排序、右键菜单 |
| 按键冲突 | 同一作用域是否一键多义 | Enter 同时提交与搜索 |
| 孤儿绑定 | 绑定是否指向已删除动作 | 按了没反应 |
| 单字符快捷键 | 是否与读屏浏览键冲突 | 按 S 直接跳走 |
单字符快捷键是 WCAG 2.1.4 的重点:没有 Ctrl、Alt、Meta 修饰的字母键,会与读屏软件的浏览键冲突。允许保留,但必须提供关闭方式或改用组合键。检测器只负责标出风险,是否保留由产品决定。
覆盖率按动作算,不按控件算。一个页面有一百个按钮,只要「提交」不可达,覆盖率再高也没有意义;反过来,把指针专用动作排除后,分母才是真正需要键盘完成的任务。
偏好不是主题,是行为开关
减少动效、放大字体、高对比度经常被做成三套皮肤,结果只改了颜色,没有改行为。正确的映射是把偏好翻译成具体策略:
| 偏好 | 必须改变的行为 | 不能只改的样式 |
|---|---|---|
| 减少动效 | 动画时长归零、关闭视差与自动播放 | 换一套配色 |
| 放大字体 | 基准字号、行高、每行字数同步调整 | 只放大标题 |
| 高对比度 | 提高对比门槛、加粗焦点环与边框、关闭透明 | 只换背景色 |
字号缩放要夹在 100% 到 200% 之间。低于 100% 伤害低视力用户,高于 200% 会让布局失去意义;夹紧之后,行高与每行字数也要跟着变,否则字大了但一行仍塞 70 个字,阅读反而更累。
偏好变化本身也值得播报。用户打开「减少动效」后,读屏应该说「已开启减少动效」,而不是让用户自己猜设置有没有生效。播报内容由前后两份偏好算出,不依赖界面文案。
把三件事写成代码
下面的模块把播报生成、播报去重、键盘审计与偏好映射放在一起。它不读 matchMedia,也不碰 DOM;输入是事件、绑定表与偏好快照,输出是播报、问题与策略。
// file: src/a11y/a11y-preferences.ts
// tier: L1
// verify: node --test test/a11y/a11y-preferences.test.ts
export type LivePoliteness = "polite" | "assertive";
export type Announcement = { politeness: LivePoliteness; text: string };
export type AnnounceEvent =
| { kind: "status"; message: string }
| { kind: "error"; message: string }
| { kind: "progress"; label: string; value: number; max: number }
| { kind: "selection"; label: string; selected: number; total: number }
| { kind: "navigation"; title: string };
export type AnnounceState = { lastText: string; lastAt: number };
export type ActionSpec = { id: string; label: string; pointerOnly?: boolean };
export type KeyBinding = { action: string; key: string; scope: string; modifiers?: string[] };
export type KeyConflict = { scope: string; key: string; actions: string[] };
export type PreferenceInput = {
reduceMotion: boolean;
fontScale: number;
highContrast: boolean;
screenReader: boolean;
keyboardOnly: boolean;
};
export type MotionPolicy = {
animationMs: number;
parallax: boolean;
autoplay: boolean;
transition: "none" | "short";
};
export type TypographyPolicy = {
baseFontPx: number;
scale: number;
lineHeight: number;
maxLineLengthCh: number;
};
export type ContrastPolicy = {
minContrast: number;
focusRingPx: number;
borderPx: number;
allowOpacity: boolean;
};
export type ResolvedPreferences = {
motion: MotionPolicy;
typography: TypographyPolicy;
contrast: ContrastPolicy;
announceRouteChanges: boolean;
showSkipLink: boolean;
};
const RESERVED_MODIFIERS = ["ctrl", "alt", "meta"];
const PROGRESS_MILESTONES = [0, 25, 50, 75, 100];
export function clampPercent(value: number, max: number): number {
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0;
return Math.min(100, Math.max(0, Math.round((value / max) * 100)));
}
export function progressMilestone(value: number, max: number): number | null {
const percent = clampPercent(value, max);
return PROGRESS_MILESTONES.includes(percent) ? percent : null;
}
export function announcementFor(event: AnnounceEvent): Announcement | null {
switch (event.kind) {
case "error":
return { politeness: "assertive", text: `错误:${event.message}` };
case "status":
return { politeness: "polite", text: event.message };
case "navigation":
return { politeness: "polite", text: `已进入${event.title}` };
case "selection": {
const total = Math.max(0, event.total);
const selected = Math.min(total, Math.max(0, event.selected));
return { politeness: "polite", text: `${event.label}:已选择 ${selected} 项,共 ${total} 项` };
}
case "progress": {
const percent = progressMilestone(event.value, event.max);
if (percent === null) return null;
return { politeness: "polite", text: `${event.label} ${percent}%` };
}
}
}
export function dedupeAnnouncement(
state: AnnounceState,
announcement: Announcement,
now: number,
windowMs = 1500,
): AnnounceState | null {
if (state.lastText === announcement.text && now - state.lastAt < windowMs) return null;
return { lastText: announcement.text, lastAt: now };
}
export function bindingSignature(binding: KeyBinding): string {
const modifiers = [...(binding.modifiers ?? [])].map((item) => item.toLowerCase()).sort();
return `${binding.scope}::${modifiers.join("+")}::${binding.key.toLowerCase()}`;
}
export function keyConflicts(bindings: KeyBinding[]): KeyConflict[] {
const groups = new Map<string, KeyBinding[]>();
for (const binding of bindings) {
const signature = bindingSignature(binding);
const list = groups.get(signature) ?? [];
list.push(binding);
groups.set(signature, list);
}
const out: KeyConflict[] = [];
for (const list of groups.values()) {
const actions = [...new Set(list.map((binding) => binding.action))].sort();
if (actions.length > 1) out.push({ scope: list[0].scope, key: list[0].key, actions });
}
return out.sort((a, b) => a.scope.localeCompare(b.scope) || a.key.localeCompare(b.key));
}
export function unreachableActions(actions: ActionSpec[], bindings: KeyBinding[]): string[] {
const bound = new Set(bindings.map((binding) => binding.action));
return actions
.filter((action) => action.pointerOnly !== true && !bound.has(action.id))
.map((action) => action.id);
}
export function orphanBindings(actions: ActionSpec[], bindings: KeyBinding[]): KeyBinding[] {
const known = new Set(actions.map((action) => action.id));
return bindings.filter((binding) => !known.has(binding.action));
}
export function singleCharacterBindings(bindings: KeyBinding[]): KeyBinding[] {
return bindings.filter((binding) => {
if (binding.key.length !== 1) return false;
const modifiers = (binding.modifiers ?? []).map((item) => item.toLowerCase());
return !modifiers.some((modifier) => RESERVED_MODIFIERS.includes(modifier));
});
}
export function keyboardCoverage(actions: ActionSpec[], bindings: KeyBinding[]): number {
if (actions.length === 0) return 1;
const reachable = actions.length - unreachableActions(actions, bindings).length;
return Math.round((reachable / actions.length) * 1000) / 1000;
}
export function clampFontScale(value: number): number {
if (!Number.isFinite(value)) return 1;
return Math.round(Math.min(2, Math.max(1, value)) * 100) / 100;
}
export function motionPolicy(reduceMotion: boolean): MotionPolicy {
if (reduceMotion) {
return { animationMs: 0, parallax: false, autoplay: false, transition: "none" };
}
return { animationMs: 200, parallax: true, autoplay: true, transition: "short" };
}
export function typographyPolicy(fontScale: number): TypographyPolicy {
const scale = clampFontScale(fontScale);
return {
baseFontPx: Math.round(16 * scale),
scale,
lineHeight: Math.round((1.5 + (scale - 1) * 0.2) * 100) / 100,
maxLineLengthCh: scale >= 1.5 ? 55 : 70,
};
}
export function contrastPolicy(highContrast: boolean): ContrastPolicy {
if (highContrast) {
return { minContrast: 7, focusRingPx: 3, borderPx: 2, allowOpacity: false };
}
return { minContrast: 4.5, focusRingPx: 2, borderPx: 1, allowOpacity: true };
}
export function resolvePreferences(input: PreferenceInput): ResolvedPreferences {
return {
motion: motionPolicy(input.reduceMotion),
typography: typographyPolicy(input.fontScale),
contrast: contrastPolicy(input.highContrast),
announceRouteChanges: input.screenReader,
showSkipLink: input.keyboardOnly || input.screenReader,
};
}
export function preferenceAnnouncement(prev: PreferenceInput, next: PreferenceInput): string[] {
const messages: string[] = [];
if (prev.reduceMotion !== next.reduceMotion) {
messages.push(next.reduceMotion ? "已开启减少动效" : "已关闭减少动效");
}
if (clampFontScale(prev.fontScale) !== clampFontScale(next.fontScale)) {
messages.push(`字号已调整为 ${Math.round(clampFontScale(next.fontScale) * 100)}%`);
}
if (prev.highContrast !== next.highContrast) {
messages.push(next.highContrast ? "已开启高对比度" : "已关闭高对比度");
}
if (prev.screenReader !== next.screenReader) {
messages.push(next.screenReader ? "已开启读屏优化" : "已关闭读屏优化");
}
return messages;
}
announcementFor 返回 null 表示「这次不该播」,而不是返回空字符串。调用方据此决定是否写入 live region,避免用空文本触发一次无意义的 DOM 变更。
真实测试输出
本机运行 node --test test/a11y/a11y-preferences.test.ts 的结果如下,耗时随机器变化:
✔ 状态消息使用 polite 播报 (0.952459ms)
✔ 错误消息使用 assertive 播报并带前缀 (0.150333ms)
✔ 导航消息包含目标页面标题 (0.077042ms)
✔ 进度到达整四分位时播报 (0.084083ms)
✔ 进度未到整四分位时保持安静 (0.070167ms)
✔ 进度百分比会夹在 0 到 100 之间 (0.053292ms)
✔ 总量为零时进度按 0% 处理 (0.047625ms)
✔ 选择数量不会超过总数 (0.052833ms)
✔ 负数选择数量按零处理 (0.068542ms)
✔ 相同播报在窗口内被去重 (0.145708ms)
✔ 去重窗口过后允许再次播报 (0.075875ms)
✔ 不同文本不会被去重 (0.078292ms)
✔ 同作用域同按键绑定不同动作被判为冲突 (0.141625ms)
✔ 同一动作重复绑定同一按键不算冲突 (0.049209ms)
✔ 修饰键顺序不影响绑定签名 (0.069292ms)
✔ 没有键位绑定的动作被列为不可达 (0.075916ms)
✔ 仅指针可用的动作不要求键盘绑定 (0.061958ms)
✔ 绑定引用了未登记动作时被列为孤儿 (0.080459ms)
✔ 单字符快捷键在没有保留修饰键时被标记 (0.080542ms)
✔ 键盘覆盖率按动作数量计算 (0.036834ms)
✔ 减少动效开启后动画与视差都关闭 (0.045959ms)
✔ 默认动效策略保留短过渡 (0.033459ms)
✔ 字号缩放被夹在 100% 到 200% 之间 (0.039917ms)
✔ 放大字号后基准字号与行高同步变化 (0.052667ms)
✔ 高对比度提高对比门槛并关闭透明 (0.038375ms)
✔ 读屏用户默认显示跳转链接并播报路由变化 (0.051667ms)
✔ 纯键盘用户也显示跳转链接但不播报路由 (0.032584ms)
✔ 偏好变化生成对应的播报文本 (0.068166ms)
ℹ tests 28
ℹ suites 0
ℹ pass 28
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 129.886792
二十八个用例覆盖了播报分类、里程碑、去重、绑定冲突、孤儿绑定、单字符风险、字号夹紧与偏好播报。它们固定的是映射规则;真实读屏软件的朗读顺序还需要人工走查,不能由这些单元测试替代。
判据与反例
判断一套偏好与播报实现是否合格,看下面五条:
| 判据 | 通过的样子 | 反例 |
|---|---|---|
| 播报有级别 | 错误打断、状态排队 | 全部 assertive |
| 播报有频率 | 进度只在里程碑播 | 每个百分点都播 |
| 播报可去重 | 相同文本窗口内只播一次 | 双触发导致重复朗读 |
| 键盘可证明 | 每个动作都有绑定或明确指针专用 | 只靠人工按一遍 |
| 偏好改行为 | 动效、字号、对比度都落到策略 | 只换主题色 |
第一个反例是「错误条即播报」。视觉上插入一个红色 div,读屏不会自动读;必须把它放进 role="alert" 或 aria-live="assertive" 的区域,并且确保该区域在插入前已经存在于 DOM 中。先创建区域再填内容,比创建时自带内容更可靠。
第二个反例是「快捷键越多越专业」。给每个操作配一个单字母快捷键,看似高效,实际会与读屏浏览键、输入法、浏览器扩展冲突。快捷键应该少而可发现,并且提供查看与关闭入口。
第三个反例是「偏好只影响首屏」。用户开启减少动效后,首屏动画停了,进入详情页又开始视差滚动。偏好必须绑定在应用级策略上,而不是某个组件的局部开关。
小结
读屏、键盘与偏好,都是把状态翻译成用户可感知的行为。
- 播报要分礼貌级别与频率,错误打断、状态排队、进度只在里程碑出现。
- 键盘可达性要审计绑定表:未绑定、冲突、孤儿与单字符风险都能自动列出。
- 偏好要落到行为:减少动效关掉动画与自动播放,放大字体同步行高与行长,高对比度加粗焦点环。
先让映射可测试,再谈体验细节。映射错了,做得再精致也只是给看得见的人看的。
阅读导航:上一节:10.1 语义结构与焦点顺序 · 下一节:10.3 无障碍测试与验收 。
常见错误与边界
- 把所有提示都设成
assertive。用户会被反复打断,最后关掉读屏。 - 进度每变一个值就播报。噪音掩盖了真正需要决策的节点。
- 只在插入内容时创建 live region。部分读屏不会朗读新建区域里的文本。
- 用拖动作为唯一排序方式。键盘用户没有任何等价路径。
- 单字符快捷键不提供关闭方式。与读屏浏览键冲突时用户无法操作。
- 偏好只换主题色,不改动画与布局。用户以为设置没生效。
- 字号放大不调整行高与行长。字大了,阅读反而更累。
- 用单元测试宣称完成读屏验收。朗读顺序与真实软件行为仍需人工走查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。