《互联网产品的客户端架构·规模化与架构演进》10.3 无障碍测试与验收

发布前扫描器报告零错误,上线后仍收到「弹窗关不掉」「读屏走不通」的投诉:自动规则测不到焦点陷阱,也没有严重级别来决定先修什么。本节把规则集、严重级别、加权覆盖度、验收门槛与人工走查清单写成一套可执行的审计模型,并说明为什么覆盖度与门槛必须一起看。

扫描器说零问题,用户说没法用

某团队在发布流水线里接入了无障碍扫描器。版本上线前,报告显示「0 个错误」,大家签了字。两周后客服转来投诉:退款弹窗打开后键盘出不去;读屏用户走不完开票流程;把系统字号调到 200% 后,提交按钮被挤出屏幕。

扫描器没有说谎。它检查的是能静态判断的规则:图片有没有替代文本、表单有没有标签、颜色对比度是否达标。焦点陷阱需要运行时交互,读屏流程需要真实软件与真实任务,200% 缩放需要实际布局。这些不在它的能力范围内。

更糟的是,团队把扫描结果当成唯一指标。规则集没有严重级别,四十七个问题被一起排期;真正阻断流程的两条被埋在「低优先级」里,直到用户投诉才被看见。

无障碍验收要回答三个问题:检查了哪些规则、规则有多严重、没检查的部分怎么办。三者缺一,报告就只是数字。

规则要带严重级别,否则等于没有排序

同样叫「无障碍问题」,后果差别很大。缺一个装饰图片的替代文本,用户不会卡住;缺提交按钮的名称,用户根本完不成任务。规则集必须带级别,级别决定排期与门槛。

级别含义例子默认门槛
blocker主流程无法完成控件无名称、焦点陷阱、读屏走不通0
serious能完成但代价很高对比度不足、焦点不可见、表单无标签0
moderate体验受损但有替代路径标题跳级、320px 横向滚动≤ 3
minor规范瑕疵冗余角色、非关键提示不设上限

级别不是拍脑袋定的,而是按「用户能否完成任务」定的。焦点陷阱之所以是阻断级,不是因为它罕见,而是因为用户一旦进入就没有出口,只能刷新页面。

规则还要区分自动与人工。自动规则可以由扫描器或单元测试执行;人工规则必须由人用真实软件走查。把两者混在一张清单里,会出现「扫描器跑了 100 条,所以覆盖度 100%」的假象。

覆盖度不是「跑了多少条」,而是「权重覆盖了多少」

如果按条数算覆盖度,跑十条轻微规则和跑十条阻断规则得分一样。更合理的是按严重级别加权:阻断级权重 4,严重级 3,中等级 2,轻微级 1。

规则级别权重是否执行计入覆盖
控件必须有名称blocker4是4
焦点顺序一致serious3是3
标题不跳级moderate2否0
冗余角色minor1是1

加权之后,漏掉一条阻断规则会明显拉低分数,漏掉一条轻微规则几乎不影响。这符合直觉:验收关心的是主流程,而不是规则条数。

覆盖度只统计自动规则。人工规则由人工清单单独计算通过率,否则「没跑」与「没通过」会被混为一谈。两条曲线要一起看:覆盖度低说明测得不全,人工通过率低说明测得的部分也没过。

验收门槛要能说「不」

门槛不是一句「尽量没有阻断问题」,而是一组可判定的条件:

条件默认值不满足时
阻断级发现0拒绝发布
严重级发现0拒绝发布
中等级发现≤ 3拒绝发布
自动规则覆盖度≥ 80%拒绝发布
必做人工检查全部通过拒绝发布

门槛的价值在于「可拒绝」。如果任何一条都能被「这次先上线」绕过,门槛就退化成了建议。拒绝时要给出具体原因:哪条规则、哪个节点、差多少,而不是一句「无障碍不达标」。

门槛也应该允许按版本调整。新业务首版可以把中等级上限放宽到 5,但阻断级与严重级不能放宽;等流程稳定后再收紧。调整必须留下记录,否则半年后没人知道为什么这一版能过。

人工走查不是补充,是规则的一部分

自动规则覆盖不了真实交互。人工走查要固定任务、固定环境、固定记录格式,否则每个人的结论都不同。

检查项必做通过标准
仅用键盘完成一次提交是全程不碰鼠标
焦点可见且顺序可预测是每一步都能说出焦点在哪
读屏走通主流程是能独立完成,不需要旁人提示
200% 缩放不丢内容是关键操作仍在视口内
320px 视口完成核心任务否无横向滚动即可
减少动效后动画停止是无自动播放与视差
表单错误可被定位并说明修正是读屏能读到错误与建议
视频有字幕且可关闭否字幕可开关

必做项与门槛绑定:任何一项未通过,验收直接失败。非必做项可以记录为已知问题,但不能悄悄消失。

走查记录要写到「任务、环境、结果」三件事。只写「读屏没问题」没有价值;写「iOS 旁白,开票流程,到选择发票类型时读不到选项,卡住」才能被修复。

把规则集写成代码

下面的模块把规则、严重级别、发现、加权覆盖度、人工清单与门槛判断放在一起。它不执行扫描,也不调用浏览器;输入是扫描结果与人工记录,输出是可判定的报告。

// file: src/a11y/a11y-audit.ts
// tier: L1
// verify: node --test test/a11y/a11y-audit.test.ts

export type Severity = "blocker" | "serious" | "moderate" | "minor";

export type RuleCategory = "semantics" | "keyboard" | "screen-reader" | "visual" | "forms";

export type AuditRule = {
  id: string;
  title: string;
  severity: Severity;
  category: RuleCategory;
  wcag: string;
  manual: boolean;
};

export type Finding = { ruleId: string; node: string; detail: string };

export type ManualCheck = { id: string; label: string; required: boolean };

export type ManualResult = ManualCheck & { passed: boolean; note?: string };

export type GatePolicy = {
  maxBlocker: number;
  maxSerious: number;
  maxModerate: number;
  minCoverage: number;
  requireManual: boolean;
};

export type GateInput = { findings: Finding[]; manual: ManualResult[]; executed: string[] };

export type GateResult = { accepted: boolean; reasons: string[] };

export type AuditReport = {
  findings: Finding[];
  manual: ManualResult[];
  severityCounts: Record<Severity, number>;
  coverage: number;
  coverageMissing: string[];
  manualProgress: number;
  gate: GateResult;
};

export const RULES: AuditRule[] = [
  { id: "name-required", title: "交互控件必须有可访问名称", severity: "blocker", category: "semantics", wcag: "4.1.2", manual: false },
  { id: "role-valid", title: "角色与属性必须匹配", severity: "serious", category: "semantics", wcag: "4.1.2", manual: false },
  { id: "heading-order", title: "标题层级不得跳级", severity: "moderate", category: "semantics", wcag: "1.3.1", manual: false },
  { id: "focus-order", title: "焦点顺序与视觉顺序一致", severity: "serious", category: "keyboard", wcag: "2.4.3", manual: false },
  { id: "focus-trap", title: "不得存在无法退出的焦点陷阱", severity: "blocker", category: "keyboard", wcag: "2.1.2", manual: false },
  { id: "keyboard-all", title: "所有功能可用键盘完成", severity: "blocker", category: "keyboard", wcag: "2.1.1", manual: true },
  { id: "focus-visible", title: "焦点必须可见", severity: "serious", category: "visual", wcag: "2.4.7", manual: true },
  { id: "contrast-text", title: "正文对比度不低于 4.5:1", severity: "serious", category: "visual", wcag: "1.4.3", manual: false },
  { id: "reflow-320", title: "320px 宽度下内容不横向滚动", severity: "moderate", category: "visual", wcag: "1.4.10", manual: false },
  { id: "text-spacing", title: "文本间距可调整", severity: "moderate", category: "visual", wcag: "1.4.12", manual: false },
  { id: "form-label", title: "表单控件必须有关联标签", severity: "serious", category: "forms", wcag: "3.3.2", manual: false },
  { id: "error-suggestion", title: "错误提示必须说明如何修正", severity: "moderate", category: "forms", wcag: "3.3.3", manual: false },
  { id: "status-message", title: "状态变化必须被读屏播报", severity: "serious", category: "screen-reader", wcag: "4.1.3", manual: false },
  { id: "sr-flow", title: "读屏可完整走通主流程", severity: "blocker", category: "screen-reader", wcag: "4.1.3", manual: true },
  { id: "motion-reduce", title: "减少动效偏好必须生效", severity: "moderate", category: "visual", wcag: "2.3.3", manual: true },
];

export const MANUAL_CHECKS: ManualCheck[] = [
  { id: "m-keyboard", label: "仅用键盘完成一次下单或提交", required: true },
  { id: "m-focus", label: "逐项检查焦点可见且顺序可预测", required: true },
  { id: "m-reader", label: "用读屏软件走通主流程并记录障碍", required: true },
  { id: "m-zoom", label: "200% 缩放后内容不丢失", required: true },
  { id: "m-reflow", label: "320px 视口下完成核心任务", required: false },
  { id: "m-motion", label: "开启减少动效后动画停止", required: true },
  { id: "m-error", label: "表单错误可被读屏定位并说明修正方式", required: true },
  { id: "m-captions", label: "视频有字幕且可关闭", required: false },
];

export const DEFAULT_POLICY: GatePolicy = {
  maxBlocker: 0,
  maxSerious: 0,
  maxModerate: 3,
  minCoverage: 0.8,
  requireManual: true,
};

export function severityRank(severity: Severity): number {
  switch (severity) {
    case "blocker": return 4;
    case "serious": return 3;
    case "moderate": return 2;
    case "minor": return 1;
  }
}

export function ruleById(id: string): AuditRule | undefined {
  return RULES.find((rule) => rule.id === id);
}

export function automatedRules(): AuditRule[] {
  return RULES.filter((rule) => !rule.manual);
}

export function unknownRuleIds(findings: Finding[]): string[] {
  return [...new Set(
    findings.filter((finding) => ruleById(finding.ruleId) === undefined)
      .map((finding) => finding.ruleId),
  )].sort();
}

function findingRank(finding: Finding): number {
  const rule = ruleById(finding.ruleId);
  return rule ? severityRank(rule.severity) : 5;
}

export function normalizeFindings(findings: Finding[]): Finding[] {
  const seen = new Set<string>();
  const kept: Finding[] = [];
  for (const finding of findings) {
    const key = `${finding.ruleId}::${finding.node}`;
    if (seen.has(key)) continue;
    seen.add(key);
    kept.push(finding);
  }
  return kept.sort(
    (a, b) => findingRank(b) - findingRank(a)
      || a.ruleId.localeCompare(b.ruleId)
      || a.node.localeCompare(b.node),
  );
}

export function countBySeverity(findings: Finding[]): Record<Severity, number> {
  const out: Record<Severity, number> = { blocker: 0, serious: 0, moderate: 0, minor: 0 };
  for (const finding of findings) {
    const rule = ruleById(finding.ruleId);
    if (rule) out[rule.severity] += 1;
  }
  return out;
}

export function coverageMissing(executed: string[]): string[] {
  const done = new Set(executed);
  return automatedRules().filter((rule) => !done.has(rule.id)).map((rule) => rule.id);
}

export function coverageScore(executed: string[]): number {
  const rules = automatedRules();
  if (rules.length === 0) return 1;
  const done = new Set(executed);
  let total = 0;
  let covered = 0;
  for (const rule of rules) {
    const weight = severityRank(rule.severity);
    total += weight;
    if (done.has(rule.id)) covered += weight;
  }
  return Math.round((covered / total) * 1000) / 1000;
}

export function manualProgress(results: ManualResult[]): number {
  if (results.length === 0) return 0;
  const passed = results.filter((result) => result.passed).length;
  return Math.round((passed / results.length) * 1000) / 1000;
}

export function evaluateGate(input: GateInput, policy: GatePolicy = DEFAULT_POLICY): GateResult {
  const reasons: string[] = [];
  const unknown = unknownRuleIds(input.findings);
  if (unknown.length > 0) reasons.push(`发现未登记规则:${unknown.join(", ")}`);
  const counts = countBySeverity(input.findings);
  if (counts.blocker > policy.maxBlocker) {
    reasons.push(`阻断级问题 ${counts.blocker} 个,上限 ${policy.maxBlocker}`);
  }
  if (counts.serious > policy.maxSerious) {
    reasons.push(`严重问题 ${counts.serious} 个,上限 ${policy.maxSerious}`);
  }
  if (counts.moderate > policy.maxModerate) {
    reasons.push(`中等问题 ${counts.moderate} 个,上限 ${policy.maxModerate}`);
  }
  const coverage = coverageScore(input.executed);
  if (coverage < policy.minCoverage) {
    reasons.push(`规则覆盖度 ${(coverage * 100).toFixed(1)}%,低于 ${(policy.minCoverage * 100).toFixed(0)}%`);
  }
  if (policy.requireManual) {
    const failed = input.manual
      .filter((item) => item.required && !item.passed)
      .map((item) => item.label);
    if (failed.length > 0) reasons.push(`必做人工检查未通过:${failed.join(";")}`);
  }
  return { accepted: reasons.length === 0, reasons };
}

export function buildReport(input: GateInput, policy: GatePolicy = DEFAULT_POLICY): AuditReport {
  const findings = normalizeFindings(input.findings);
  return {
    findings,
    manual: input.manual,
    severityCounts: countBySeverity(findings),
    coverage: coverageScore(input.executed),
    coverageMissing: coverageMissing(input.executed),
    manualProgress: manualProgress(input.manual),
    gate: evaluateGate({ findings, manual: input.manual, executed: input.executed }, policy),
  };
}

export function topFindings(findings: Finding[], limit = 5): Finding[] {
  return normalizeFindings(findings).slice(0, Math.max(0, limit));
}

evaluateGate 返回的 reasons 是给人看的拒绝理由,不是错误码。发布负责人需要一眼看出「是阻断问题、覆盖度不足,还是人工检查没过」,而不是再去翻报告。

真实测试输出

本机运行 node --test test/a11y/a11y-audit.test.ts 的结果如下,耗时随机器变化:

✔ 规则 id 唯一 (0.596291ms)
✔ 每条规则都带 WCAG 编号 (0.17325ms)
✔ 规则集区分自动规则与人工规则 (0.065542ms)
✔ 严重级别可以排序 (0.094375ms)
✔ 未知规则返回 undefined (0.074875ms)
✔ 同一规则同一节点只保留一条发现 (7.928083ms)
✔ 发现按严重级别从高到低排序 (0.443959ms)
✔ 未登记规则被单独列出 (0.073209ms)
✔ 按严重级别统计发现 (0.086708ms)
✔ 全部自动规则执行时覆盖度为 1 (0.154333ms)
✔ 覆盖度按严重级别加权,而不是简单计数 (0.063708ms)
✔ 未执行的自动规则出现在缺失清单里 (0.055458ms)
✔ 人工规则不参与自动覆盖度计算 (0.065042ms)
✔ 人工检查通过率按条目计算 (0.062916ms)
✔ 没有任何人工检查结果时通过率为零 (0.0485ms)
✔ 干净报告可以通过默认验收门槛 (0.1315ms)
✔ 存在阻断级问题时验收失败 (0.065458ms)
✔ 覆盖度低于门槛时验收失败 (0.056709ms)
✔ 必做人工检查未通过时验收失败 (0.058084ms)
✔ 非必做人工检查失败不影响验收 (0.044459ms)
✔ 未登记规则会直接导致验收失败 (0.04475ms)
✔ 构建报告汇总发现、覆盖度与门槛结论 (0.077333ms)
✔ 报告沿用自定义门槛 (0.04675ms)
✔ topFindings 按严重级别返回前若干条 (0.0565ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 138.820083

二十四个用例覆盖了规则登记、严重级别排序、去重、加权覆盖、人工通过率与门槛判断。它们证明的是模型自洽;扫描器接入与真实走查仍需在发布流程里单独验证。

判据与反例

判断一套无障碍验收是否可信,看下面五条:

判据通过的样子反例
规则有级别阻断与轻微分开排期四十七个问题同一优先级
覆盖有权重漏阻断规则显著掉分按条数算覆盖率
人工有清单必做项与门槛绑定只写「已人工检查」
门槛可拒绝输出具体拒绝理由任何情况都能「先上线」
结果可回归同一输入得到同一报告每次报告排序都不同

第一个反例是「零错误等于合格」。扫描器的零错误只说明它检查的规则通过了,不说明焦点、读屏与缩放过关。报告必须同时给出覆盖度与人工结论,否则零错误会变成免责声明。

第二个反例是「规则越多越专业」。把社区规则全部打开,报告里几百条提示,团队很快学会忽略。规则集应该少而可执行,每条都能对应一个修复动作。

第三个反例是「人工检查走过场」。没有固定任务与环境,走查人打开首页看一眼就签字。清单要具体到「用哪个读屏、走哪条流程、在哪一步记录结果」,否则无法复现。

小结

无障碍验收不是一次扫描,而是一套能说「不」的判定。

  • 规则要带严重级别,按「用户能否完成任务」分级,阻断项不能与轻微项一起排期。
  • 覆盖度按严重级别加权,只统计自动规则;人工规则用独立清单计算通过率。
  • 门槛要包含阻断数、严重数、覆盖度与必做人工检查,拒绝时给出具体原因。
  • 人工走查要固定任务、环境与记录格式,必做项与门槛绑定。

先让验收能拒绝,再谈覆盖率提升。一个永远通过的闸门,比没有闸门更危险。

阅读导航:上一节:10.2 读屏、键盘与动效偏好 · 下一节:11.1 架构决策记录与契约 。

常见错误与边界

  • 把扫描器零错误当成验收通过。焦点、读屏与缩放不在静态规则里。
  • 规则不分级别。阻断项被埋进长列表,排期时被忽略。
  • 覆盖度按条数计算。漏掉一条阻断规则与漏掉一条轻微规则得分相同。
  • 把人工规则混进自动覆盖度。没跑过的规则被算成已覆盖。
  • 门槛没有拒绝权。任何失败都能用「本次特殊情况」绕过。
  • 人工走查没有固定任务。结论无法复现,也无法回归。
  • 拒绝时只报总数。团队不知道先修哪一条、差多少。
  • 报告排序不稳定。同一份输入每次生成不同顺序,对比失去意义。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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