构建与部署的增量双双失灵:9 张图片和 1915 个文件背后的三个坑
连续跑两次 npm run deploy,输出几乎一模一样:
[postbuild] 图片: 处理 9 张, 缓存跳过 359 张
[postbuild] 完成 (8.4s): ... 水印 0 张
[deploy-oss] 完成: 上传 1915, 跳过 0, 删除 11
两个”增量”全失灵了:postbuild 的图片缓存每次都有 9 张要重处理,OSS 部署则 1915 个文件一个不少全部重传。逐个排查,最后发现是三个互不相干的坑叠在一起:一个被静默吞掉的 Windows 文件锁错误、一个恒为假的 if 条件、一个非确定性的混淆器。完整记录一下。
坑 1:那 9 张图片为什么永远处理不完
postbuild 的图片缓存是内容寻址的:先算文件 md5,拿 md5_版本号.扩展名 去 node_modules/.astro/postbuild-images/ 里找处理好的成品,命中就 copyFileSync 回 dist,否则重新压图加水印并存缓存。设计上,只要某张图成功处理过一次,下次 astro build 重建出原始文件后必然命中缓存。
所以”每次都有 9 张被处理”只有一种解释:这 9 张从来没成功过,永远写不进缓存。用诊断脚本把 368 张图全部过一遍,找出没有缓存命中的 9 个:
images\2021\01\1706928628.png 0B ← 零字节
images\2023\05\2397264479.png 0B ← 零字节
images\2024\04\img_20240413_...-300x225.jpg
images\2025\03\B4572CD5...-135x300.jpg
images\2025\03\B4572CD5...-150x150.jpg
posts\happy-new-year2022\images\1641018313861.jpeg
posts\loom\images\3064128888.jpg
posts\start\images\963228996.jpg
_astro\image_editor_output_...CYf17VBA.jpg
两个现象立刻对上:7 张 jpg + 2 张零字节 png,而构建日志里”水印 0 张”——水印计数只在处理成功时 +1,0 说明这 9 张全军覆没。再往 optimizeImage 的 catch 里补一行日志,真相浮出水面:
PROCESS ERR: 3064128888.jpg -> UNKNOWN: unknown error, open 'D:\...\dist\posts\loom\images\3064128888.jpg'
7 张 jpg 全是同一个错误:按文件路径打开失败。 而奇怪的是,同样这几张图,先读进内存再喂给 sharp 就完全正常;同一张图换个时机再按路径打开也偶尔能成功。这是 Windows 上典型的瞬时文件锁问题(防病毒实时扫描、句柄竞争都会触发),原代码 catch { return null } 把错误静默吞掉,不写缓存,于是每次构建都重新踩一遍。
另外 2 张是 public/ 里的零字节 png,处理函数里有 size < 512 直接 return 的保护,同样不写缓存,每次构建都进”处理”计数。这两个文件在线上会返回空文件,属于源数据问题,顺手把它们单独列出来警告。
修法:全程改用内存 buffer 处理(读一次、哈希一次、sharp 吃 buffer,不再反复按路径开文件),加 3 次重试,失败时打印真实错误而不是吞掉。改完连续构建两次,输出变成:
[postbuild] 图片: 处理 0 张, 缓存跳过 366 张
[postbuild] 跳过过小图片 0B: images\2021\01\1706928628.png
[postbuild] 跳过过小图片 0B: images\2023\05\2397264479.png
9 → 0。之前没修好的 7 张 jpg 也一次性补上了缓存。
坑 2:deploy-oss 的跳过条件恒为假
再看部署脚本,跳过 0 直接指向这一行:
const full = path.join(distDir, rel); // ← 路径字符串,永远为真
if (!full && remote && remote.size === size) { // ← !full 永远为假!
变量名 full 把本地文件的完整路径和 --full 全量上传的标志位搞混了。路径字符串非空,!full 恒为 false,整个”大小相同再比 etag、相同就跳过”的分支永远不会执行——所以每次部署都把 1915 个文件原样重传一遍。etag 比对逻辑本身是好的(简单上传的 OSS etag 就是内容 md5,比它和本地 md5 相等即可跳过)。
修法:条件改成 absPath && remote && remote.size === size && !fullFlag,路径存在、远端大小一致、md5 一致才跳过;--full 时强制不跳过,语义也终于落实了。
坑 3:混淆器是非确定性的,这才是增量失效的真凶
修完前两个坑,做了个更严格的验证:连续构建两次,逐文件对比 md5。结果仍然有 1200+ 个 HTML 在变。增量部署直接不可能——因为 HTML 每次都在变。
顺着 HTML 的引用链找,资源重命名 21 个 每次都把 theme JS 改成带新 hash 的名字。JS 每次字节都不同,是谁在写?是 obfuscate。javascript-obfuscator 没设 seed 时,标识符生成用的是真随机数:
输出1: 6416 输出2: 6423 完全一致: false
同一份输入,两次输出不一样。于是:混淆输出变 → 文件名 hash 变 → 所有 HTML 里的引用变 → 全站 HTML 重传。增量部署是”文件没变就不传”,而它每次都在变,部署自然只能全量。
修法一行:混淆选项里加固定 seed(seed: 20240806),输出变为确定:
输出1: 6421 输出2: 6421 完全一致: true
验证:字节级稳定 + 增量生效
修完三个坑,连续构建两次,逐文件比对:
两次构建文件差异数: 2
=> dist\posts\test-astro-encrypted\index.html|38433732...
<= dist\posts\test-astro-encrypted\index.html|D4BF80F3...
1915 个文件,只剩 1 个不同——test-astro-encrypted 那篇加密文章,它的内容本身带动态成分,每次构建真的在变,属于正确行为。其余 1914 个文件字节级一致,远程 etag 比对会全部跳过。
经验总结
- “每次都在重试的活” = 永远没成功过的活。内容寻址缓存下,只要成功一次就不可能反复处理;反复处理必然意味着每次都在失败,而且失败被吞了。水印 0、跳过 0 这类”结果计数器”,是判断是不是白干的第一信号;
- 静默 catch 是调试黑洞。
catch { return null }省一时日志,欠下的是每次构建 8 秒的隐性重试和事后 40 分钟的排查。生产脚本里处理失败至少要打一行; - 变量名别用业务标志位。
full同时指”完整路径”和”—full 全量标志”,一个笔误让跳过分支 6 个月没执行过。absPathvsfullFlag这类命名不是洁癖,是防呆; - 任何带随机数的处理都不配谈增量。混淆、压缩、图片编码……只要输出不确定,下游的”内容没变就不传”就全部失效。要确定性,给随机源加 seed,并在 CI 里用”两次构建字节级 diff”兜底;
- Windows 上 sharp 按路径打开文件会偶发失败,读进内存再处理更稳,还能顺手省掉多次打开同一文件的 IO。