你跑了一次 Lighthouse 或 axe,无障碍报告很难看:180 篇文章里 340 张图没有 alt 属性,或者随手贴了个 alt="" 当占位符。屏幕阅读器要么把原始文件名念出来(“IMG00023.JPG”),要么默默跳过。Google 图片搜索没有东西可索引。AdSense 质量审核会把这读成作者偷懒。这直接违反 WCAG 2.2 成功标准 1.1.1(非文本内容)。
最快修法: 先跑一个按类别分组的审计脚本(见下文),然后分三档回填——流量最高的页面手写 alt,中间的用 AI 起草再人工审,真正的装饰性图片用显式 alt="" 标掉。接着把一个 prebuild 检查接进来,只要还有缺 alt 就让构建非零退出,问题就再也回不来了。一次做完锁死,比每次都重新审计便宜得多。
写这批内容时根本没强制过 alt。多数图片是被拖进编辑器、alt 字段空着的,而构建从来不抱怨。
你属于哪一类?
| 症状 | 可能原因 | 修法 |
|---|---|---|
 —— Markdown alt 为空 | 编辑器从未要求 | 在方括号里回填 alt |
<img src="x.png" /> 没有 alt= | 作者为控制尺寸改用裸 HTML | 补 alt;在 MDX 里它是 JSX 元素,不是 HTML |
alt="image" / alt="screenshot" | 应付式审计 | 改写成描述真实内容 |
| 2025 年前的文章全无 alt | CMS 迁移剥掉了 alt | 从源导出重新导入,或批量回填 |
| 分隔线、通用插画 | 确实是装饰性的 | 保留 alt=""(这是对的) |
最后一行很关键:缺 alt 和故意空 alt 看着像,结果却相反。WCAG 把缺少 alt 记为失败 F65(屏幕阅读器读文件名),把 alt="image" / alt="spacer" 这种本应被忽略却给了非空 alt 记为失败 F39。对纯装饰性图片,alt="" 才是对的答案——辅助技术会干净地跳过它。
常见原因
1. 编辑器从未要求 alt
Markdown 编辑器接受  而不抱怨。作者省掉 alt,因为没有任何摩擦。
如何判断——grep 所有 MDX 找空的 Markdown alt:
grep -rEn '!\[\]\(' src/content/articles/ | wc -l
2. HTML img 标签没有 alt 属性
作者需要控制尺寸时改用裸 <img>,结果忘了 alt。<img src="x.png" width="600" /> 是合法 HTML,但无障碍坏了。
如何判断——grep 没有 alt 属性的 img:
grep -rEn '<img [^>]*src=' src/content/articles/ | grep -v 'alt='
MDX 注意点:在 .mdx 里,裸 <img> 被解析成 JSX 元素(mdxJsxTextElement / mdxJsxFlowElement),而不是 HTML 节点。这在后面写 lint 规则时会咬你一口(见 Step 4)——一个只访问 html 节点的规则,会默默漏掉你 MDX 文件里的每一个 <img>。
3. alt 存在但只是装饰性占位
作者写了 alt="image" 或 alt="screenshot" 来糊弄一次审计。技术上有、语义上没用,而且是 WCAG 的 F39 失败。
如何判断:
grep -rEn 'alt="(image|screenshot|picture|img|photo)"' src/content/articles/
4. 老 CMS 批量导入剥掉了 alt
你从 WordPress / Notion / Ghost 迁移过来。导出格式丢了 alt,或者把它存在导入器忽略的兄弟字段里,于是所有迁来的图都没 alt。
如何判断——在迁移日期当天或之前发布的文章,就是受影响的那一批。把这个日期区间和审计输出对一遍,确认重叠。
5. 确实应该用空 alt 的装饰性图片
分隔线或通用插画就是纯装饰。对这些,W3C 装饰性图片教程 规定用 alt=""——屏幕阅读器应该跳过它们。你的审计必须区分”缺 alt”和”故意空 alt”,否则它会对一批本来就正确的图片唠叨个没完。
最短修复路径
Step 1:按类别盘点缺口
做一份按类型分组的报告,这样你可以分级处理,而不是盯着一个扁平的大清单:
// scripts/audit-image-alt.mjs
import fs from "node:fs";
import path from "node:path";
const ROOT = "src/content/articles";
const issues = { missingMarkdown: [], missingHtml: [], placeholder: [] };
function scan(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) { scan(p); continue; }
if (!p.endsWith(".mdx")) continue;
const txt = fs.readFileSync(p, "utf8");
if (/!\[\]\(/.test(txt)) issues.missingMarkdown.push(p);
// 匹配整个标签里都没有 alt= 的 <img ...>
if (/<img(?:(?!alt=)[^>])*>/i.test(txt)) issues.missingHtml.push(p);
if (/alt="(image|screenshot|picture|img|photo)"/i.test(txt)) {
issues.placeholder.push(p);
}
}
}
scan(ROOT);
const total = Object.values(issues).reduce((n, a) => n + a.length, 0);
console.log(JSON.stringify(issues, null, 2));
console.log(`\n${total} files with alt problems`);
process.exitCode = total > 0 ? 1 : 0;
现在你有了精确的违规清单,以及每一条属于哪个类别。
Step 2:按流量回填,别按字母序
不要一口气 AI 生成 340 条 alt——读者和审核都会注意到那种通用措辞,而且 Google 明确反对堆关键词的 alt。改成分级处理:
| 档位 | 文章 | 方法 |
|---|---|---|
| 高流量 | 按 Search Console 展示量 top 50 左右 | 手写真实 alt |
| 中流量 | 大头 | AI 起草,再每 20 篇一批人工审 |
| 低流量 / 装饰 | 长尾 | alt="" 加一条注释标”故意” |
好的 alt 文本长什么样,参照 Google 图片 SEO 文档:在上下文里描述主体,不堆关键词。Google 自己的例子里,alt="Dalmatian puppy playing fetch" 排在 alt="puppy" 之上,两者又都好过空 alt。控制在一两句话即可——足够描述主体和它的作用,又不会把屏幕阅读器用户埋掉(有些屏幕阅读器会在约 125 个字符处截断,所以越简洁越好)。UI 截图就把 app 名、功能名和当前状态写清楚:alt="Cursor settings panel with the Composer model set to Sonnet 4.6" 比 alt="settings" 强得多。
Step 3:缺 alt 就让构建失败
把审计接进 prebuild,让它的非零退出码挡住构建:
"scripts": {
"audit:alt": "node scripts/audit-image-alt.mjs",
"prebuild": "npm run audit:content && npm run audit:alt"
}
这个检查必须区分缺 alt 和故意空 alt。一个可行的约定:只有当上一行带有标记注释 {/* decorative */},或者资产路径匹配已知装饰模式(比如 /decor/ 或 *-divider.svg)时,才允许空 alt。把这个例外写进脚本,它就不会再误报正确的装饰性图片。
Step 4:加一条真能看到 MDX 图片的 lint 规则
PR 时强制——把 remark-lint-no-empty-image-alt-text(管 Markdown 的 ![]() 语法)和一条针对裸 <img> 的自定义规则配在一起。陷阱在于:MDX 里裸 <img> 是 JSX 节点,所以规则必须访问 mdxJsxFlowElement 和 mdxJsxTextElement,而不是 html。原来那种”访问 html”的写法会漏掉每一个 MDX <img>。
// .remarkrc.mjs
import { visit } from "unist-util-visit";
export default {
plugins: [
"remark-lint",
"remark-mdx",
["remark-lint-no-empty-image-alt-text", "warn"],
// 自定义规则:MDX 里的裸 <img> 是 JSX 元素,不是 html
() => (tree, file) => {
visit(tree, ["mdxJsxFlowElement", "mdxJsxTextElement"], (node) => {
if (node.name !== "img") return;
const alt = node.attributes?.find(
(a) => a.type === "mdxJsxAttribute" && a.name === "alt"
);
if (!alt) file.message("img tag missing alt attribute", node);
});
},
],
};
在改动 .mdx 的 PR 上跑 CI。如果你还想自动抓出无用的占位字符串,可以再加上 remark-lint-alt-text。
Step 5:把约定写下来
加一段简短的作者指南,让未来的作者和审稿人共用同一条规则:
- 真实 alt:描述图片显示什么 AND 它在上下文里为何重要(一两句话)
- 装饰性图片:alt="" 加上上一行的 {/* decorative */} 注释
- 永远不用 "image"、"screenshot" 这类占位字符串
- UI 截图:写清 app 名、功能名和当前相关状态
- 文件名:描述性、用连字符(account-settings.png,不是 IMG00023.JPG)
如何确认已修好
- 重跑
npm run audit:alt——它应打印0 files with alt problems并以 0 退出。 - 在几个重建后的页面上重跑 Lighthouse(或
npx @axe-core/cli https://yoursite.com/some-article/);“Image elements do not have[alt]attributes” 这一项应通过。 - 用屏幕阅读器打开一个修好的页面(macOS VoiceOver:Cmd+F5;Windows 用 NVDA),逐张图箭头走一遍——每张都应念出描述,装饰性的应保持沉默,没有一张该读文件名。
- 在 Google Search Console 里,接下来几周观察”图片”报告,看你回填过的页面有没有展示量。
预防
- 任何
![]或缺 alt 的<img>——prebuild 直接失败,零例外。 - MDX lint 规则(访问
mdxJsxFlowElement,而非html)在 PR 时就抓。 - 作者指南的 alt 段从 PR 模板里链过去。
- 装饰性图片用显式
alt=""加{/* decorative */}标记。 - 季度审计重跑脚本,报告零回退。
- 任何迁移脚本,都在下次导入前验证 alt 被保留。
常见问题
缺 alt 比空 alt="" 更糟吗?
对非装饰性图片,是的。完全没有 alt 属性时,很多屏幕阅读器会退而读文件名(WCAG 失败 F65)。空 alt="" 至少保持沉默——但对有意义的图片这同样错,因为信息丢了。内容图片用真实描述,alt="" 只留给装饰。
AI 生成的 alt 会伤 AdSense 或 SEO 吗? 只有当它通用或堆关键词时才会。Google 衡量 alt 看的是有用性和上下文,不看是谁写的。AI 起草、人工改得具体的 alt 没问题;340 条一模一样的 “screenshot of the dashboard” 才是问题。上线前先审。
alt 文本该多长? 控制在一两句话——足够描述主体和它的作用,又短到不会拖累屏幕阅读器用户。HTML 没有硬性长度上限,但有些屏幕阅读器会在约 125 个字符处截断,所以尽量精简;过长的 alt 是个可用性问题。
装饰性图片真的要 alt="",而不是干脆不写属性吗?
是的。完全省掉属性,会让某些屏幕阅读器把文件名念出来。显式的 alt="" 才是有文档支持的、告诉辅助技术跳过这张图的方式(W3C 装饰性图片指南)。
我是不是也该顺手修文件名和格式?
有帮助。Google 偏好描述性、连字符的文件名(account-settings.png 好过 IMG00023.JPG),也能很好地处理 WebP/AVIF。这是另一项优化,但既然你已经在动每一张图,顺手批进同一轮里是好事。