AI 辅助前端无障碍测试:自动生成 WCAG 审计报告与修复建议
AI 辅助前端无障碍测试:自动生成 WCAG 审计报告与修复建议
一、前端无障碍的现状:手工审计的成本让 90% 的项目选择跳过
WCAG 2.1(Web Content Accessibility Guidelines)定义了 78 条成功标准,分为 A、AA、AAA 三个等级。其中 AA 级是多数法规的最低要求,涵盖 50 条标准。对于一个人力有限的前端团队来说,逐条手工检查 50 条标准是不现实的。结果就是:无障碍测试被列在"理想情况"的 backlog 中,永不启动。
手工审计的典型瓶颈:
- 键盘可访问性检查:需要逐页 Tab 遍历所有可交互元素,验证焦点顺序和可见焦点指示器。一个 20 页的中型网站大约需要 4 小时人工时间。
- 色彩对比度检查:需要逐个提取文本色和背景色,计算对比度比值。一页可能有 50+ 个颜色组合。
- 屏幕阅读器兼容性检查:打开 VoiceOver/NVDA,逐区域验证语义标签是否正确。对不熟悉屏幕阅读操作的开发者来说,学习成本极高。
AI 在前端无障碍测试中的价值,是将以上手工检查过程自动化为扫描 → 审计 → 修复建议的三步流程。这不是用 AI 替代人工审计(人工仍然是最终的质量保证),而是用 AI 将审计成本降低 80%,让团队有意愿启动无障碍工作。
二、自动化审计引擎的设计:从 DOM 扫描到 WCAG 规则匹配
2.1 DOM 遍历与数据结构提取
自动化审计的第一步是提取页面的完整可访问性信息。基于 Playwright/Puppeteer 的无头浏览器,在页面完全渲染后注入一段 JavaScript,遍历 DOM 树并提取每个元素的语义信息:
/**
* 页面可访问性信息提取器
* 注入页面后遍历 DOM,提取语义和样式信息
*/
interface AccessibilityNode {
tagName: string;
role: string | null; // 显式 ARIA role
implicitRole: string; // 隐式 role(如 <button> → button)
name: string | null; // Accessible Name(来自 aria-label/label/内容)
description: string | null; // Accessible Description(aria-describedby)
tabIndex: number | null; // Tab 键焦点顺序
isVisible: boolean; // 是否在视觉上可见
isFocusable: boolean;
isFocused: boolean;
// 样式信息
color: string;
backgroundColor: string;
fontSize: string;
fontWeight: string;
// 关联关系
parent: string | null; // 父节点 XPath
children: string[];
labelElement: string | null; // 关联的 <label> 元素
describedBy: string[]; // aria-describedby 指向的元素
// 问题标记
issues: AccessibilityIssue[];
}
interface AccessibilityIssue {
wcagCriterion: string; // WCAG 标准编号(如 "1.1.1")
severity: 'critical' | 'serious' | 'moderate' | 'minor';
level: 'A' | 'AA' | 'AAA';
description: string; // 问题描述(中文)
elementXPath: string; // 问题元素的 XPath
screenshot?: string; // 问题区域的截图(base64)
}
/**
* 页面可访问性扫描器
*/
class AccessibilityScanner {
/**
* 扫描整页的 WCAG 合规性
*/
async scanPage(url: string): Promise<{
nodes: AccessibilityNode[];
issues: AccessibilityIssue[];
summary: AuditSummary;
}> {
const browser = await this.launchBrowser();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// 1. 提取 DOM 的语义结构
const nodes = await page.evaluate(() => {
return this.extractAccessibilityTree(document.body);
});
// 2. 模拟键盘导航,记录焦点顺序
const focusOrder = await this.simulateKeyboardNavigation(page);
// 3. 逐节点做 WCAG 规则匹配
const issues: AccessibilityIssue[] = [];
for (const node of nodes) {
issues.push(...this.checkNode(node, focusOrder));
}
// 4. 汇总审计报告
const summary = this.summarize(issues);
await browser.close();
return { nodes, issues, summary };
}
/**
* 递归提取 DOM 树的可访问性信息
*/
private extractAccessibilityTree(element: Element): AccessibilityNode[] {
const nodes: AccessibilityNode[] = [];
const computedStyle = window.getComputedStyle(element);
const node: AccessibilityNode = {
tagName: element.tagName.toLowerCase(),
role: element.getAttribute('role'),
implicitRole: this.getImplicitRole(element),
name: this.getAccessibleName(element),
description: element.getAttribute('aria-describedby'),
tabIndex: (element as HTMLElement).tabIndex,
isVisible: computedStyle.display !== 'none' && computedStyle.visibility !== 'hidden',
isFocusable: (element as HTMLElement).tabIndex >= 0,
isFocused: document.activeElement === element,
color: computedStyle.color,
backgroundColor: computedStyle.backgroundColor,
fontSize: computedStyle.fontSize,
fontWeight: computedStyle.fontWeight,
parent: this.getXPath(element.parentElement),
children: [],
labelElement: null,
describedBy: [],
issues: [],
};
// 提取关联的 label
const id = element.id;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label) node.labelElement = this.getXPath(label);
}
nodes.push(node);
// 递归子元素
for (const child of Array.from(element.children)) {
const childNodes = this.extractAccessibilityTree(child);
node.children.push(...childNodes.map((n) => this.getXPath(child)));
nodes.push(...childNodes);
}
return nodes;
}
/**
* 获取元素的 Accessible Name(优先级:aria-label > aria-labelledby > label > 内容)
*/
private getAccessibleName(element: Element): string | null {
return (
element.getAttribute('aria-label') ||
element.getAttribute('aria-labelledby') ||
element.getAttribute('title') ||
element.textContent?.trim()?.slice(0, 100) ||
null
);
}
/**
* 获取元素的隐式 role
*/
private getImplicitRole(element: Element): string {
const roleMap: Record<string, string> = {
button: 'button',
a: element.hasAttribute('href') ? 'link' : 'generic',
input: this.getInputRole(element as HTMLInputElement),
img: element.hasAttribute('alt') ? 'img' : 'presentation',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
aside: 'complementary',
h1: 'heading', h2: 'heading', h3: 'heading',
h4: 'heading', h5: 'heading', h6: 'heading',
ul: 'list', ol: 'list',
};
return roleMap[element.tagName.toLowerCase()] || 'generic';
}
private getInputRole(input: HTMLInputElement): string {
const typeRoleMap: Record<string, string> = {
checkbox: 'checkbox',
radio: 'radio',
search: 'searchbox',
range: 'slider',
number: 'spinbutton',
};
return typeRoleMap[input.type] || 'textbox';
}
private async launchBrowser() { return {} as any; }
private async simulateKeyboardNavigation(page: any): Promise<string[]> { return []; }
private checkNode(node: AccessibilityNode, focusOrder: string[]): AccessibilityIssue[] { return []; }
private summarize(issues: AccessibilityIssue[]): AuditSummary { return {} as AuditSummary; }
private getXPath(element: Element | null): string { return ''; }
}
interface AuditSummary {
totalIssues: number;
bySeverity: Record<string, number>;
byLevel: Record<string, number>;
wcagCompliance: number; // 0~100%
}
2.2 WCAG 规则引擎:可编程的审计规则集
WCAG 的 50 条 AA 级标准不是都能用程序自动检查的(如"内容是否可理解"需要人工判断),但大约 60%~70% 的标准可以自动化:
/**
* WCAG 规则引擎
* 基于规则集的自动化检查
*/
interface WCAGRule {
id: string; // WCAG 标准编号
description: string;
level: 'A' | 'AA' | 'AAA';
severity: 'critical' | 'serious' | 'moderate' | 'minor';
check: (node: AccessibilityNode) => boolean; // true = 通过
generateFixSuggestion: (node: AccessibilityNode) => FixSuggestion;
}
interface FixSuggestion {
description: string;
codeFix?: string; // 代码级修复
beforeCode?: string; // 修复前代码
afterCode?: string; // 修复后代码
}
class WCAGRuleEngine {
private rules: WCAGRule[] = [
{
id: '1.1.1',
description: '非文本内容必须有替代文本',
level: 'A',
severity: 'critical',
check: (node) => {
if (node.tagName !== 'img') return true;
return node.name !== null && node.name.length > 0;
},
generateFixSuggestion: (node) => ({
description: '为图片添加有意义的 alt 属性',
codeFix: `<img src="..." alt="描述图片内容的文字" />`,
}),
},
{
id: '1.4.3',
description: '文本对比度不低于 4.5:1(正常文本)或 3:1(大文本)',
level: 'AA',
severity: 'serious',
check: (node) => {
const contrastRatio = this.calculateContrastRatio(
this.parseColor(node.color),
this.parseColor(node.backgroundColor)
);
const isLargeText = parseFloat(node.fontSize) >= 18 ||
(parseFloat(node.fontSize) >= 14 && parseInt(node.fontWeight) >= 700);
const minimumRatio = isLargeText ? 3 : 4.5;
return contrastRatio >= minimumRatio;
},
generateFixSuggestion: (node) => ({
description: `当前对比度不足,建议调深文本颜色或调整背景色`,
codeFix: `/* 建议将文本颜色从 ${node.color} 调整为更深的颜色 */`,
}),
},
{
id: '2.4.3',
description: '焦点顺序应符合有意义的操作序列',
level: 'A',
severity: 'serious',
check: (node, focusOrder?: string[]) => {
if (!focusOrder) return true;
if (!node.isFocusable) return true;
// AI 语义分析:焦点顺序是否合理(由 LLM 判断)
return this.evaluateFocusOrder(node, focusOrder);
},
generateFixSuggestion: (node) => ({
description: '建议使用 tabindex 属性调整焦点顺序',
codeFix: `tabindex="0" /* 使元素可出现在自然 Tab 序列中 */`,
}),
},
{
id: '4.1.2',
description: '所有可交互元素必须有可访问的名称(Accessible Name)',
level: 'A',
severity: 'critical',
check: (node) => {
const interactiveRoles = ['button', 'link', 'checkbox', 'radio', 'textbox', 'combobox'];
if (!interactiveRoles.includes(node.role || node.implicitRole)) return true;
return node.name !== null && node.name.length > 0;
},
generateFixSuggestion: (node) => ({
description: `为 ${node.tagName} 元素添加 aria-label 或关联 label`,
codeFix: `aria-label="${node.tagName} 的功能描述"`,
}),
},
];
/**
* 对所有扫描节点执行规则检查
*/
audit(nodes: AccessibilityNode[], focusOrder?: string[]): AccessibilityIssue[] {
const issues: AccessibilityIssue[] = [];
for (const node of nodes) {
// 跳过不可见元素
if (!node.isVisible) continue;
for (const rule of this.rules) {
const passed = rule.check(node, focusOrder);
if (!passed) {
issues.push({
wcagCriterion: rule.id,
severity: rule.severity,
level: rule.level,
description: `${rule.description}:${rule.generateFixSuggestion(node).description}`,
elementXPath: node.tagName,
});
}
}
}
return issues;
}
/**
* 相对亮度计算(WCAG 对比度公式)
*/
private calculateContrastRatio(
color1: [number, number, number],
color2: [number, number, number]
): number {
const l1 = this.relativeLuminance(color1);
const l2 = this.relativeLuminance(color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
private relativeLuminance([r, g, b]: [number, number, number]): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
private parseColor(color: string): [number, number, number] { return [0, 0, 0]; }
private evaluateFocusOrder(node: AccessibilityNode, focusOrder: string[]): boolean { return true; }
}
三、AI 生成的修复建议:从问题描述到可执行的代码 Diff
3.1 修复建议的格式化输出
检测到问题是第一步,但开发者需要的是"怎么修"而非"哪里有错"。AI 在此的作用是将 WCAG 标准检测结果转化为代码级的修复建议:
- 缺失 Alt 文本:AI 分析图片上下文(周围文字、链接目标、文件名),生成有意义的 Alt 文本建议。
- 对比度不足:AI 计算调整后的颜色值(保持品牌色相近但对比度达标),输出具体的 HEX 值。
-
焦点顺序混乱:AI 分析页面布局的视觉顺序,输出推荐的
tabindex赋值方案。
3.2 修复验证的回归机制
每次修复后,AI 审计引擎应重新扫描页面,验证问题是否已被修复,同时确认修复没有引入新的无障碍问题。为此引入一个基线快照机制:
interface AccessibilityBaseline {
url: string;
timestamp: number;
issueCount: number;
issues: AccessibilityIssue[];
passCount: number; // 通过的规则数
}
class RegressionTracker {
private baselines: Map<string, AccessibilityBaseline> = new Map();
/**
* 对比新扫描结果与基线,检测回归
*/
compareBaseline(url: string, current: AccessibilityIssue[]): RegressionReport {
const baseline = this.baselines.get(url);
if (!baseline) {
this.baselines.set(url, { url, timestamp: Date.now(), issueCount: current.length, issues: current, passCount: 0 });
return { isRegression: false, newIssues: [], fixedIssues: [] };
}
const newIssues = current.filter(
(i) => !baseline.issues.some((b) => b.wcagCriterion === i.wcagCriterion && b.elementXPath === i.elementXPath)
);
const fixedIssues = baseline.issues.filter(
(b) => !current.some((i) => i.wcagCriterion === b.wcagCriterion && i.elementXPath === b.elementXPath)
);
return {
isRegression: newIssues.length > 0,
newIssues,
fixedIssues,
};
}
}
四、AI 无障碍测试的边界与局限
4.1 无法自动化的 WCAG 标准
大约 30%~40% 的 WCAG 标准无法完全由程序自动检查,包括:
- 1.3.2 有意义的序列:内容的 DOM 顺序是否与视觉呈现顺序一致(需要视觉感知)。
- 2.4.4 链接目的:链接文本是否清晰表达了链接目标(需要语义理解)。
- 3.3.2 标签或说明:表单标签是否准确描述了输入要求(需要上下文理解)。
这些标准需要通过 LLM 做语义判断(准确率约 75%~85%),并且最终需要人工复核。
4.2 动态内容的测试覆盖
AI 审计引擎基于静态 DOM 快照,无法覆盖用户交互后的动态状态变化(弹窗出现后的焦点管理、表单提交后的错误信息朗读)。需要配合 E2E 测试脚本,模拟用户操作路径再做二次扫描。
五、总结
AI 辅助前端无障碍测试的核心价值是将审计成本降低 80%,让团队从"不做"变为"可以开始做"。
自动化审计引擎基于三步流程:DOM 遍历提取语义结构 → WCAG 规则引擎逐条匹配 → AI 生成代码级修复建议。规则引擎可以覆盖约 60%~70% 的 WCAG AA 标准,其余 30%~40% 需要通过 LLM 做语义判断辅助。
落地建议:第一阶段集成 axe-core 或 Lighthouse 的自动化检测规则(开箱即用),第二阶段接入 LLM 生成修复建议(提升开发者体验),第三阶段建立基线回归机制(防止修复引入新问题)。