← 全部文章

API 与辅助工具

构建一个可注入到任何地方的实时无障碍审计浮层

Lighthouse 和 axe 很出色 —— 它们也是一次上下文切换。你打开 DevTools、跑审计、读报告、再跳回页面。在你构建时想要一次快速的初轮检查,你希望问题被画在页面本身上:每张没有 alt 文本的图片周围有一道红框,每个没有标签的输入框上有一个徽章。本文用一条你可以借助 JustZix 注入到任何站点的 JavaScript 规则,构建出的正是这个。

这个浮层是什么 —— 以及它不是什么

这是一次初轮的冒烟测试。它捕捉那些便宜、常见、机械的错误 —— 它们占了真实无障碍失败中很大一部分 —— 并把它们可视化地呈现出来,让你不会错过。它不是 axe-core 或 Lighthouse 的替代品:它不深度测试 ARIA 语义、焦点顺序或屏幕阅读器行为。把它想成你不断运行的 linter,而不是你发布前才运行的完整审计。

整个东西是一条 JS 规则。注入它,扫一眼页面,修好亮起来的东西,再注入一次。

骨架

我们会把问题收集进一个数组,为每个问题画一道轮廓加一个徽章,并显示一个汇总计数器。从脚手架开始。

(() => {
  // Clean up a previous run
  document.querySelectorAll('.jz-a11y').forEach(n => n.remove());

  const issues = [];

  function flag(el, label, color) {
    if (!el || !el.getBoundingClientRect) return;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return; // skip hidden
    issues.push({ label });

    const box = document.createElement('div');
    box.className = 'jz-a11y';
    Object.assign(box.style, {
      position: 'absolute',
      left: (r.left + scrollX) + 'px',
      top: (r.top + scrollY) + 'px',
      width: r.width + 'px',
      height: r.height + 'px',
      outline: '2px solid ' + color,
      background: color + '22',
      zIndex: 2147483000,
      pointerEvents: 'none',
      boxSizing: 'border-box',
    });

    const tag = document.createElement('span');
    tag.textContent = label;
    Object.assign(tag.style, {
      position: 'absolute', left: 0, top: '-18px',
      font: '11px/1.4 system-ui, sans-serif',
      background: color, color: '#fff',
      padding: '1px 5px', whiteSpace: 'nowrap',
    });
    box.appendChild(tag);
    document.body.appendChild(box);
  }

每个问题元素调用一次 flag()。它记录这个问题,并在元素上方画一个绝对定位的框,带一个带标签的徽章。所有东西都带 jz-a11y 类,这样下一次运行可以把它擦掉。

检查 1 —— 缺失 alt 的图片

一个完全没有 alt 属性的 <img> 是一个失败;alt="" 是有效的(它把图片标记为装饰性的),所以我们只标记一个真正缺失的属性。

  document.querySelectorAll('img').forEach(img => {
    if (!img.hasAttribute('alt')) {
      flag(img, 'img: no alt', '#e11d48');
    }
  });

检查 2 —— 没有标签的表单输入框

一个输入框需要一个可访问的名字。它可以来自一个包裹的或关联的 <label>、一个 aria-label,或 aria-labelledby。如果一个都没有,这个字段在屏幕阅读器下就无法使用。

  const fields = 'input:not([type=hidden]):not([type=submit])'
    + ':not([type=button]), select, textarea';

  document.querySelectorAll(fields).forEach(el => {
    const byFor = el.id &&
      document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
    const wrapped = el.closest('label');
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('aria-labelledby')
      || el.getAttribute('title');

    if (!byFor && !wrapped && !aria) {
      flag(el, 'input: no label', '#f59e0b');
    }
  });

检查 3 —— 空的链接和按钮

一个没有文本内容、也没有可访问标签的链接或按钮,会被宣读为单单一个「链接」或「按钮」。这在仅图标按钮上极其常见。

  document.querySelectorAll('a, button').forEach(el => {
    const text = (el.textContent || '').trim();
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('title');
    const img = el.querySelector('img[alt]:not([alt=""])');
    const hasName = text || aria || img;

    if (!hasName) {
      const what = el.tagName === 'A' ? 'link' : 'button';
      flag(el, 'empty ' + what, '#8b5cf6');
    }
  });

检查 4 —— 低对比度文字

完整的 WCAG 对比度计算很复杂,但一次相对亮度检查能抓住最严重的违规者。计算文字颜色和解析出的背景的亮度,然后取对比度比值。

  function lum(rgb) {
    const c = rgb.map(v => {
      v /= 255;
      return v <= 0.03928
        ? v / 12.92
        : Math.pow((v + 0.055) / 1.055, 2.4);
    });
    return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2];
  }
  function parse(str) {
    const m = str.match(/\d+(\.\d+)?/g);
    return m ? m.slice(0, 3).map(Number) : null;
  }
  function bgOf(el) {
    let n = el;
    while (n) {
      const bg = getComputedStyle(n).backgroundColor;
      if (bg && !/rgba?\(0, 0, 0, 0\)|transparent/.test(bg)) {
        return parse(bg);
      }
      n = n.parentElement;
    }
    return [255, 255, 255];
  }

  document.querySelectorAll('p, span, a, li, h1, h2, h3, h4, td')
    .forEach(el => {
      if (!(el.textContent || '').trim()) return;
      const fg = parse(getComputedStyle(el).color);
      const bg = bgOf(el);
      if (!fg || !bg) return;
      const l1 = lum(fg) + 0.05, l2 = lum(bg) + 0.05;
      const ratio = l1 > l2 ? l1 / l2 : l2 / l1;
      if (ratio < 4.5) {
        flag(el, 'contrast ' + ratio.toFixed(1) + ':1', '#0ea5e9');
      }
    });

4.5:1 这个阈值是 WCAG AA 对正常字号文字的最低要求。大号文字在 3:1 就算通过,所以预期在大标题上会有一些误报 —— 那些用眼睛判断。

检查 5 —— 标题顺序跳级和缺失 lang

标题不该跳级 —— 一个 <h2> 后面直接跟一个 <h4> 会破坏文档大纲。而根 <html> 需要一个 lang,让屏幕阅读器选对发音。

  let prev = 0;
  document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
    const level = +h.tagName[1];
    if (prev && level > prev + 1) {
      flag(h, 'jump h' + prev + '→h' + level, '#db2777');
    }
    prev = level;
  });

  if (!document.documentElement.getAttribute('lang')) {
    issues.push({ label: 'html: no lang' });
  }

汇总计数器

最后,在角落里放一个固定徽章,显示总数。它是一眼可见的分数;那些轮廓是细节。

  const sum = document.createElement('div');
  sum.className = 'jz-a11y';
  sum.textContent = issues.length
    ? issues.length + ' a11y issue(s)'
    : 'No quick a11y issues found';
  Object.assign(sum.style, {
    position: 'fixed', right: '12px', bottom: '12px',
    font: '13px/1.5 system-ui, sans-serif',
    background: issues.length ? '#e11d48' : '#16a34a',
    color: '#fff', padding: '6px 12px', borderRadius: '6px',
    zIndex: 2147483600, pointerEvents: 'none',
  });
  document.body.appendChild(sum);

  console.table(issues);
})();

末尾的 console.table(issues) 还会向 Output Console 输出一份整洁的列表,所以你同时拥有可视化浮层和一份可复制的汇总。

日常如何使用它

另见

一个实时审计浮层把无障碍从一个你会忘记的清单,变成一个你无法忽视的东西。安装 JustZix,粘上脚本,让页面告诉你哪里出了错。

为这篇文章评分

暂无评分 — 成为第一个。

自己动手试试

安装 JustZix,粘贴本文中的任意代码片段。两分钟,从零到一条在你所有设备上生效的规则。

获取 JustZix

功能 · 工作原理 · 示例 · 应用场景