构建一个可注入到任何地方的实时无障碍审计浮层
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 输出一份整洁的列表,所以你同时拥有可视化浮层和一份可复制的汇总。
日常如何使用它
- 把整个脚本放进一条限定到你正在构建的站点的 JS 规则,从 JS 面板按需运行它(Ctrl+Enter),这样它不会在每次导航时重绘。
- 轮廓是绝对定位的,所以一次大的尺寸调整后,只需重新运行它。
- 先修最响亮的问题 —— 缺失 alt 和没有标签的输入框通常是一行 HTML 就能修好。
- 当角落徽章变绿,切换到 Lighthouse 或 axe 做深度审计。这个浮层清掉了简单的 80%;真正的工具处理其余的。
另见
- 一个响应式调试浮层 —— 同样的点子用在布局 bug 上。
- 禁用暗黑模式与虚假紧迫感 —— 修复另一种坏掉的 UX。
一个实时审计浮层把无障碍从一个你会忘记的清单,变成一个你无法忽视的东西。安装 JustZix,粘上脚本,让页面告诉你哪里出了错。
为这篇文章评分
暂无评分 — 成为第一个。