代码 generateBundle 里改 bundle 的 key 在 Rolldown 失效:一次离线包白屏复盘

2026-11-05 21:05:31

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 变量的态度

操作RollupRolldown
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.groups
  • experimentalMinChunkSize:Rollup 支持;Rolldown 报 Invalid key,对应 codeSplitting.minSize(必须配合 groups 才生效)
  • esbuild: {...}(minify 选项):Rolldown 由兼容层翻译,打印 deprecated, use oxc instead
  • oxc: {...}:Vite 8+ 原生
  • rolldownVersion 导出:Rolldown 存在,可用于运行时探测
  • load/transform 返回值:建议加 moduleType: 'js'
复制全文 生成海报 Vite Rolldown 前端构建 插件 Vue

推荐文章

程序员茄子在线接单