扫描器说零问题,用户说没法用
某团队在发布流水线里接入了无障碍扫描器。版本上线前,报告显示「0 个错误」,大家签了字。两周后客服转来投诉:退款弹窗打开后键盘出不去;读屏用户走不完开票流程;把系统字号调到 200% 后,提交按钮被挤出屏幕。
扫描器没有说谎。它检查的是能静态判断的规则:图片有没有替代文本、表单有没有标签、颜色对比度是否达标。焦点陷阱需要运行时交互,读屏流程需要真实软件与真实任务,200% 缩放需要实际布局。这些不在它的能力范围内。
更糟的是,团队把扫描结果当成唯一指标。规则集没有严重级别,四十七个问题被一起排期;真正阻断流程的两条被埋在「低优先级」里,直到用户投诉才被看见。
无障碍验收要回答三个问题:检查了哪些规则、规则有多严重、没检查的部分怎么办。三者缺一,报告就只是数字。
规则要带严重级别,否则等于没有排序
同样叫「无障碍问题」,后果差别很大。缺一个装饰图片的替代文本,用户不会卡住;缺提交按钮的名称,用户根本完不成任务。规则集必须带级别,级别决定排期与门槛。
| 级别 | 含义 | 例子 | 默认门槛 |
|---|---|---|---|
blocker | 主流程无法完成 | 控件无名称、焦点陷阱、读屏走不通 | 0 |
serious | 能完成但代价很高 | 对比度不足、焦点不可见、表单无标签 | 0 |
moderate | 体验受损但有替代路径 | 标题跳级、320px 横向滚动 | ≤ 3 |
minor | 规范瑕疵 | 冗余角色、非关键提示 | 不设上限 |
级别不是拍脑袋定的,而是按「用户能否完成任务」定的。焦点陷阱之所以是阻断级,不是因为它罕见,而是因为用户一旦进入就没有出口,只能刷新页面。
规则还要区分自动与人工。自动规则可以由扫描器或单元测试执行;人工规则必须由人用真实软件走查。把两者混在一张清单里,会出现「扫描器跑了 100 条,所以覆盖度 100%」的假象。
覆盖度不是「跑了多少条」,而是「权重覆盖了多少」
如果按条数算覆盖度,跑十条轻微规则和跑十条阻断规则得分一样。更合理的是按严重级别加权:阻断级权重 4,严重级 3,中等级 2,轻微级 1。
| 规则 | 级别 | 权重 | 是否执行 | 计入覆盖 |
|---|---|---|---|---|
| 控件必须有名称 | blocker | 4 | 是 | 4 |
| 焦点顺序一致 | serious | 3 | 是 | 3 |
| 标题不跳级 | moderate | 2 | 否 | 0 |
| 冗余角色 | minor | 1 | 是 | 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 架构决策记录与契约 。
常见错误与边界
- 把扫描器零错误当成验收通过。焦点、读屏与缩放不在静态规则里。
- 规则不分级别。阻断项被埋进长列表,排期时被忽略。
- 覆盖度按条数计算。漏掉一条阻断规则与漏掉一条轻微规则得分相同。
- 把人工规则混进自动覆盖度。没跑过的规则被算成已覆盖。
- 门槛没有拒绝权。任何失败都能用「本次特殊情况」绕过。
- 人工走查没有固定任务。结论无法复现,也无法回归。
- 拒绝时只报总数。团队不知道先修哪一条、差多少。
- 报告排序不稳定。同一份输入每次生成不同顺序,对比失去意义。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。