《互联网产品的客户端架构·规模化与架构演进》10.2 读屏、键盘与动效偏好

下单失败时错误条只在视觉上出现,读屏用户什么也没听到;优惠券排序只能拖动,键盘用户无法完成;系统开了「减少动效」,页面仍在自动轮播。本节把播报内容、键盘绑定与减少动效、放大字体、高对比三类偏好映射写成可测试的纯函数,并说明去重与里程碑为什么必须进入模型。

错误条出现了,读屏用户什么也没听到

某客户端在支付失败后,会在页面顶部插入一条红色错误条:「银行卡余额不足,请更换支付方式」。视觉用户一眼看到,读屏用户却停留在原来的提交按钮上,什么也没收到。同一版本里,优惠券排序只能拖动,键盘用户按遍所有按键也无法调整顺序;系统设置里明明开了「减少动效」,首页的自动轮播仍然一张接一张地翻。

三个问题共享一个根因:无障碍被当成了视觉层的附属品。错误条是视觉组件,拖动是鼠标交互,轮播是动画,各自团队都完成了「看得见」的部分,没有人负责「感知得到」与「操作得了」。

读屏播报、键盘可达性与偏好映射,本质上都是状态到行为的映射。映射写成纯函数,才能被测试、被复用,也才能在五个端上保持一致。

播报不是把文字塞进 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。部分读屏不会朗读新建区域里的文本。
  • 用拖动作为唯一排序方式。键盘用户没有任何等价路径。
  • 单字符快捷键不提供关闭方式。与读屏浏览键冲突时用户无法操作。
  • 偏好只换主题色,不改动画与布局。用户以为设置没生效。
  • 字号放大不调整行高与行长。字大了,阅读反而更累。
  • 用单元测试宣称完成读屏验收。朗读顺序与真实软件行为仍需人工走查。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查