AI 辅助前端无障碍测试:自动生成 WCAG 审计报告与修复建议

AI1天前发布 beixibaobao
5 0 0

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

© 版权声明

相关文章