先说结果:站上线 37 天,能跑,但中间有三次我以为"这么简单的东西不可能出错",结果全错在同一个地方------我信了本地开发环境的判断。
技术上没什么花活。Vite + React 写 SPA,构建的时候把每篇文章预渲染成静态 HTML,丢到 nginx 后面。就是这套很普通的组合,让我踩了下面这些坑。
第一个坑:所有不存在的 URL 都返回 200
这个坑最要命,而且是我自己写出来的。
SPA 的 nginx 配置是抄来的,长这样:
nginx
location / {
try_files $uri $uri/ /index.html;
}
看起来天经地义:路由交给前端嘛。但它带来的后果是------/asdfgh、/letter/xxx、任何乱敲的路径,服务器都返回 200 和一个完整的首页 HTML。
对用户没影响,对搜索引擎是灾难。Google 抓到这些页面,会归进「已抓取,但未编入索引」,白烧抓取预算;更糟的是垃圾页面和真页面在同一个响应码下,等于告诉爬虫"我这儿有无限多个页面"。
我是翻了后台的索引报告才发现这个的。修法是给 SPA 路由加白名单:
nginx
location ~ ^/(app|plaza|chat|mine|inbox|write|draw|match|coin|letter|block|doc)(/|$) {
try_files $uri /index.html;
}
location / {
return 404;
}
error_page 404 /404.html;
改完拿 26 个真实路由和 5 个垃圾路径逐个 curl,才敢确认。

第二个坑:标题里的双引号,吃掉了整页的 meta
Bing 后台一直提示我「Meta descriptions on many pages are too short」。我以为是描述写得太敷衍,就写了一版更长的,重新构建,再提交。
没变化。
于是我把 40 个页面全抓下来,用 HTML 解析器(不是正则)读 description 的值。结果有一页是空字符串。
原因在这篇的标题:《"树洞"这个说法从哪来》。
预渲染脚本干的事很简单:拿标题和描述,替换模板里的占位符。
js
html = html.replace(
/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/>)/,
`$1${description}$2`
);
description 里带一个英文双引号,插进去之后 content=" 就提前闭合了,浏览器读到的值是空串。剩下的半截变成了这行 meta 上的垃圾属性------页面照常渲染,肉眼看不出任何异常。
修法是所有属性值统一转义:
js
function escAttr(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
顺便说一句,这个坑我踩了一整天才反应过来。因为"描述太短"这个提示太具体了,具体到让人不去怀疑别的地方。
第三个坑:og:type 写死成 website
模板里 og:type 一直是 website。博客文章应该是 article,还要配 article:published_time。不改的话,Google 和 Bing 都按普通页面处理,不当作时效性内容。
这个改起来一分钟,但前提是你知道要改。
第四个坑:原生 ESM 不做扩展名解析
构建脚本是个 .mjs,直接 import 站点数据:
js
import { BLOG_POSTS } from './src/pages/content/blogPosts';
在 Vite 里这么写完全没问题,esbuild 会帮你补 .js。但同一个文件被 node scripts/generate-sitemap.mjs 跑的时候,直接报 ERR_MODULE_NOT_FOUND。
因为原生 ESM 不做扩展名解析,Vite 会做的事 node 不会。
加上 .js 就好:
js
import { BLOG_POSTS } from './src/pages/content/blogPosts.js';
我的教训是:构建脚本里 import 的东西,一律写全后缀。你不知道它哪天会被 node 直接跑一次。
第五个坑:rsync --delete 删掉了三个目录
这个是纯操作失误,但破坏力最大。
部署命令我图省事写成了:
bash
rsync -a --delete dist/ /www/wwwroot/site/
--delete 的意思是"目标目录里多出来的东西也删掉"。听起来合理------保持和构建产物一致。但我的 dist/ 里不会有后台管理面板、不会有 APK 下载目录、不会有 .well-known/(那个是后来手工放的)。
于是部署完成的瞬间,/admin/、/download/、/.well-known/ 全没了,包括一个 1.34MB 的安卓包。
好在部署前我习惯先打个 tar。从备份里单独解出这几个目录恢复,逐个 curl 确认 200,花了十分钟。
现在的部署流程固定成:
bash
rsync -a --dry-run dist/ /www/wwwroot/site/ # 先看要动什么
rsync -a dist/ /www/wwwroot/site/ # 不加 --delete
--dry-run 这一步不能省。 它输出的那一屏,是唯一能在动手前看见"会删什么"的地方。
第六个坑:String.replace 的 $ 序列
接着第二个坑说。转义做完之后我还留了个隐患:替换字符串里用了 $1、$2 这种分组引用。
js
html.replace(re, `$1${value}$2`);
如果 value 里恰好出现 $&、$'、````` 这类特殊序列,`replace` 会把它们当成分组引用展开,内容当场变形。概率很低,但我的描述是从文章正文生成的,谁都说不准哪天冒出个 ``。
换成函数式替换,彻底没这个问题:
js
html.replace(re, (_m, a, b) => a + value + b);
这种改法没什么技术含量,纯粹是吃过一次亏之后的老实。
第七个坑:路由白名单会漏
第一个坑的修法留了个尾巴:白名单是正则,新增路由就得同步改 nginx。
我加 /letter/:id 的时候忘了。表现是本地一切正常,线上直接 404。
这种事没有优雅解法,我的土办法是让路由清单只有一份来源------把 SPA 路由集中写在 NewApp.jsx 里,改完顺手 grep 一遍 nginx 配置对比。丑,但有效。
站现在长这样


站现在 55 个页面,全部预渲染,sitemap 55 条。
但有个我没解决的问题:55 个页面一张图都没有。不是忘了,是我不知道配什么图------这类内容站配图很容易沦为占位图,反而拉低观感。如果你有做过类似站点的配图方案,很想听听。
上面这些坑里,第五个(rsync)是我最不想再犯的。前端问题可以慢慢调,一个 --delete 能让你半小时的活儿变成排查两小时。