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 生成修复建议(提升开发者体验),第三阶段建立基线回归机制(防止修复引入新问题)。