generateBundle 里改 bundle 的 key 在 Rolldown 失效:一次离线包白屏复盘
发生时间:2026-05-02 影响范围:Vite 8 + @lhx-kit/vite-plugin + @lhx-kit/offline 严重程度:P0 —— 离线包沉默缺失页面 HTML,容器下发后白屏。
Vite 从 7 升到 8,底层 bundler 由 Rollup 换成 Rolldown。generateBundle 钩子里一段从 Rollup 时代留下来的写法撞上了 Rolldown 的硬性约束,per-page 重定位全部失效,而离线包校验照样输出 valid: yes。
现场:11 次告警和缺失的页面
9:03:13 PM [vite] warning: `esbuild` option was specified by "lhx-kit" plugin.
- For the "experimentalMinChunkSize".
✓ 109 modules transformed.
[信号 3] generateBundle 钩子尝试赋值 bundle,被拒绝 11 次
[plugin lhx-kit] Error: This plugin assigns to bundle variable.
This is discouraged by Rollup and is not supported by Rolldown.
This will be ignored. (x11)
at renameBundleEntry (packages/vite-plugin/dist/index.js:857:18)
产物里实际缺的东西:
- 没有
home/index.html—— 页面入口不存在 - 没有
home/assets/*.js—— 页面代码缺失 - 没有
settings/index.html—— 另一个页面也缺 manifest.json里仍然写着pages[0].file = "home/index.html",而assets[]里根本没有这一条
写盘流程是这样的:writeBundle → 离线打包器扫描 dist/ 构建 manifest → 白名单过滤器只发现 shared/ 加几个顶层文件 → manifest.assets 仅 11 项 → inspectOfflineOutput 检查每个 asset 文件都存在 → valid: true ✅。漏掉的一条关键校验是:manifest.pages[i].file 必须出现在 assets 里。
Rollup 与 Rolldown 对 bundle 变量的态度
| 操作 | Rollup | Rolldown |
|---|---|---|
delete bundle[key] | 接受 | 忽略 + 警告 |
bundle[newKey] = obj | 接受 | 忽略 + 警告 |
bundle[key].fileName = 'new/path' | 接受 | 接受 |
bundle[key].code = '...' | 接受 | 接受 |
bundle[key].source = '...' | 接受 | 接受 |
this.emitFile({type, fileName, source}) | 官方推荐 | 官方推荐 |
心智模型一句话:bundle 是只读映射,asset 对象本身可写。不要增删 key,可以原地修改 asset/chunk 的内部字段,新增文件走 this.emitFile。
Rollup 下 delete + 赋值能工作只是历史包袱 —— Rollup 官方文档同样标着 "DANGER: modifying the bundle variable is discouraged",Rolldown 把它升级成了硬约束。
writeBundle 只看 asset.fileName
写盘时 Rolldown(以及 Rollup)只读 asset 对象自己的 fileName,不看 bundle 映射的 key:
for (const asset of Object.values(bundle)) {
writeFile(join(outDir, asset.fileName), asset.source ?? asset.code);
}
也就是说重定位根本不需要改 bundle 的 key,改每个 asset 的 fileName 就够了。
修复:原地改 fileName
修复前:
function renameBundleEntry(bundle, fromName, toName, asset) {
if (fromName === toName) return;
const next = {...asset, fileName: toName};
delete bundle[fromName]; // Rolldown 忽略
bundle[toName] = next; // Rolldown 忽略
}
修复后:
/**
* Rename a bundle entry *in place*.
* 两栈兼容:Rolldown 和 Rollup 在 writeBundle 阶段都只读 asset.fileName,bundle 映射的 key 只是一个索引名。
* 后续 iterate bundle 的地方必须用 asset.fileName(而不是 Object.entries() 的 key)—— 因为 key 会 stale。
*/
function renameBundleEntry(_bundle, fromName, toName, asset) {
if (fromName === toName) return;
(asset as { fileName: string }).fileName = toName;
}
函数签名保留 _bundle 只是为了让调用点不用改,实现里已经完全用不到它。所有后续遍历 bundle 的代码都必须改读 asset.fileName,Object.entries() 拿到的 key 在改名后就是过期值。
顺带撞上的 chunk 拆分差异
Rolldown 里 codeSplitting.minSize 必须和 groups 一起用才生效。而 manualChunks 和 codeSplitting 互斥 —— 一旦设置 codeSplitting,manualChunks 就被忽略。
因此放弃了在 Rolldown 下自动合并小于 10KB 的 chunk:要做得把 200 多行的 nodeModulesPerPackageChunker 重写成 codeSplitting.groups 形式,而 vendor 缓存策略比合并 10KB 更重要。最终保留 manualChunks。
校验为什么给出 valid: true
原来的判定过松:只要 manifest.assets[i].path 列出的文件都存在就算 valid。但 manifest 本身就是从 dist/ 扫出来的 —— dist/ 一开始就缺 HTML,manifest 里压根不会有这条 asset,自然不算 missing,于是永远 valid。
补上交叉校验:
const assetPaths = new Set(manifest.assets.map(a => a.path));
for (const page of manifest.pages) {
if (!assetPaths.has(page.file)) {
missing.push(`(page "${page.name}" declared file) ${page.file}`);
}
}
页面声明必须落在资产清单里,缺一条就报出来。
其他 API 差异速查
manualChunks:Rollup 支持;Rolldown 兼容但已 deprecated,推荐codeSplitting.groupsexperimentalMinChunkSize:Rollup 支持;Rolldown 报Invalid key,对应codeSplitting.minSize(必须配合groups才生效)esbuild: {...}(minify 选项):Rolldown 由兼容层翻译,打印deprecated, use oxc insteadoxc: {...}:Vite 8+ 原生rolldownVersion导出:Rolldown 存在,可用于运行时探测load/transform返回值:建议加moduleType: 'js'