构建日志全程显示成功,产出的 HTML 里却没有内容。复盘 Vite + React 单页应用静态壳注入失效、跨条目正则取错值、静态壳与运行时抢 title、内链饿死长尾等五个静默失败点,附可直接抄的产物自查命令。
我们给 pokitx 做了一轮 SEO 改造。这是一个 Vite + React 的单页应用,117 个在线工具,构建时会为每条路由生成静态 HTML 外壳(中英双语,共 416 个页面)。
改造过程中最值得写下来的不是"做对了什么",而是发现了五个一直在静默失败的地方——构建日志全程显示成功,脚本没报过一次错,但产出的 HTML 里关键内容根本不存在。
这类问题的共同特征是:失败路径和成功路径产出的都是一个"看起来正常"的页面。你不去 grep 产物,就永远不会知道。
我们的构建后脚本会为每条路由生成静态 HTML,把真实的 <h1>、工具说明、FAQ、分类内链写进 #root,供不执行 JS 的爬虫抓取。注入代码是这样的:
html = html.replace(/<div id="root">\s*<\/div>/, `<div id="root">${SPLASH_HTML}${fallback}</div>`)
这条正则要求 #root 是一个空 div。但某个时间点,index.html 里的 #root 被手工塞进了品牌闪屏和一段首页兜底文案:
<div id="root">
<div id="app-splash" aria-hidden="true">…</div>
<div id="ssr-fallback">
<h1>… 首页标题 …</h1>
…
</div>
</div>
于是正则永远匹配不上。String.prototype.replace 匹配不到时不报错,原样返回——脚本继续跑完,日志打印"wrote 416 static route shells",一切正常。
实际后果是全部 416 个页面原样继承了首页那份写死的兜底内容:
grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort -u | wc -l
# 1
117 个工具页,1 个唯一 h1。 中文页面拿到的还是英文文案。工具说明、FAQ、烘焙给 AI 爬虫的价格表、分类链接列表,一个都没进 HTML。
修法是按 div 配对扫描出 #root 的真实闭合位置:
function replaceRootContent(html, inner) {
const OPEN = '<div id="root">'
const open = html.indexOf(OPEN)
if (open === -1) return null
const tag = /<\/?div\b[^>]*>/gi
tag.lastIndex = open + OPEN.length
let depth = 1, m
while ((m = tag.exec(html))) {
depth += m[0][1] === '/' ? -1 : 1
if (depth === 0) return html.slice(0, open) + OPEN + inner + '</div>' + html.slice(m.index + m[0].length)
}
return null
}
但比修法更重要的是匹配不到时抛错:
const injected = replaceRootContent(html, SPLASH_HTML + fallback)
if (!injected) throw new Error(`#root not found or unbalanced (route ${r.path})`)
一个静默降级了半年的 bug,和一个让构建立刻红掉的 bug,成本差了好几个数量级。
我们的工具注册表是一个数组,构建脚本用正则从源码里把字段抠出来:
const re = /slug:\s*'([a-z0-9-]+)'[\s\S]*?category:\s*'([^']+)'[\s\S]*?name:\s*T\('…'\)[\s\S]*?desc:\s*T\('…'\)/g
这条正则在所有条目都有全部字段时能正常工作。问题出在我们想加一个可选字段 seoTitle 的时候——[\s\S]*? 是跨条目惰性匹配的,一旦某个条目没有这个字段,它会一路吃到下一个条目里,把邻居的值当成自己的。
这种错误不会抛异常,只会让某几个页面的标题莫名其妙变成隔壁工具的标题。
正确做法是先切块再取字段:
// 按行首的 " { slug: '...'" 把数组切成一段一个工具
const starts = [...txt.matchAll(/^ \{ slug: '([a-z0-9-]+)'/gm)]
const chunk = txt.slice(m.index, next ? next.index : txt.length)
// 再在 chunk 内部单独取每个字段,取不到就是真的没有
一般性的教训:用正则解析结构化文本时,先确定边界,再在边界内取值。跨越记录边界的惰性量词迟早会咬你一口。
<title>SPA 的静态壳有个容易被忽略的前提:它只在 JS 执行前有效。
我们的静态壳写入 title A,React 挂载后 setSeo() 又写入 title B。Google 是渲染 JS 的,所以它索引的是 B。静态壳里那个精心优化过的标题,只有不执行 JS 的爬虫看得到。
这次我们把工具页标题从"工具名 + 站名"改成写死关键词的形式:
改前:Merge PDF - PocketKit
改后:Merge PDF Free — In Your Browser, No Upload | PocketKit
如果只改静态壳而忘了改运行时,Google 看到的还是改前的版本,等于白做。
所以标题必须同源——静态壳和运行时读同一份数据、走同一套拼接逻辑。我们的做法是把标题写进注册表的 seoTitle 字段,两边都从这里读,品牌后缀抽成常量并在两处都写上"改这里必须同步改那里"的注释。
自查方式很简单:打开页面,等 JS 挂载完,再看标签页标题和 curl 拿到的是不是同一个。
给工具页之间加相关内链时,最直觉的写法是取同分类的前 N 个:
const related = sameCategory.slice(0, 8)
这在小分类里没问题,但我们的开发工具分类有 30 个工具——结果就是这 30 个页面全部指向同一批 8 个工具,剩下 22 个一条入链都没有。权重全堆在头部,长尾页面依然是孤儿。
改成从自己的位置往后循环取:
const at = sibs.findIndex((t) => t.slug === slug)
for (let k = 1; k < sibs.length && picked.length < 8; k++) picked.push(sibs[(at + k) % sibs.length])
这样每个工具的入链数和出链数都均匀。改完实测:
入链分布 — min: 1 max: 8 零入链页面: 0
min: 1 出现在只有 2 个工具的小分类里,符合预期。
我们的注册表里,有 32 个工具认真写了 keywords 字段。但构建脚本里:
tools.forEach((t) => add(`/tools/${t.slug}`, t.name, t.desc, t.name)) // 只传了 4 个参数
games.forEach((g) => add(`/html-games/${g.slug}`, g.name, g.desc, g.name, g.keywords)) // 传了 5 个
add() 的第 5 个参数是 keywords。游戏和对比页都传了,只有工具页漏了。这 32 组关键词从来没有进过静态 HTML。
这里要说句实话:修这个 bug 的排名收益约等于零——Google 和 Bing 早就不看 meta keywords 了。真正的价值是它暴露了一个思维误区:我们把关键词写进了一个不影响排名的字段,然后以为关键词做完了。
那些词真正该去的地方是 <title> 和 <meta description>。所以我们最后做的不是补传参数,而是把这些词全部重写进 117 个工具页的标题里。
这些命令针对构建产物跑,几秒钟就能发现上面大部分问题:
# 1. h1 是否每页唯一 —— 出现 "117 <h1>同一句话</h1>" 就是注入失败
grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort | uniq -c | sort -rn | head
# 2. title 唯一数是否等于页面数
echo "unique: $(grep -ho '<title>[^<]*</title>' dist/tools/*/index.html | sort -u | wc -l) / $(ls dist/tools | wc -l)"
# 3. 静态壳里到底有没有正文(打印某页 ssr-fallback 开头 600 字)
node -e "const h=require('fs').readFileSync('dist/tools/pdf-merge/index.html','utf8');
const i=h.indexOf('ssr-fallback');console.log(h.slice(i,i+600).replace(/<[^>]+>/g,' '))"
# 4. canonical / hreflang 有没有重复注入
grep -c 'rel="canonical"' dist/tools/pdf-merge/index.html # 应为 1
# 5. 中文页面是不是真的是中文
grep -o "<h1>[^<]*</h1>" dist/zh/index.html
第 3 条最值得养成习惯。"构建成功"和"产出正确"是两件事,而静态壳这类东西,唯一可靠的验证就是去读产物本身。
顺带记一个结论。我们的站是双语的,中文页面在 /zh 下。调研之后决定不为百度投入任何预算,原因是两条硬约束叠加:
所以 /zh 继续做,但目标受众不是百度——是 Google 的中文查询、Bing 中文、海外华人用户,以及 AI 回答引擎的中文语料。
想吃百度流量需要的是国内主体备案 + 国内节点,那是一次基建投入,不是 SEO 能解决的问题。在那之前,任何百度 SEO 动作都是沉没成本。
这次改造里反复用到的几个,都是纯浏览器本地运行、不上传数据:
_headers、_redirects 与 Vercel 的 vercel.json,含 SPA 回退与安全响应头