背景
最近在做公司 Electron 项目的构建工具迁移——从 Webpack 切换到 Rspack。Rspack 号称"基于 Rust 的高性能 JavaScript 打包工具",API 与 Webpack 高度兼容,大部分配置可以直接平迁。
迁移过程确实很顺利,配置几乎原样搬过来,构建速度也有了明显提升。但打包后一运行,主进程直接白屏崩溃:
ReferenceError: Cannot access '__rspack_default_export' before initialization
诡异的是,完全相同的业务代码,用 Webpack 打包就没有任何问题。
定位过程
1. 确认循环依赖链
根据报错堆栈,定位到了一条循环依赖链:
product.ts → log/index.ts → xbotLog.ts → remoteLog.ts → product.ts
remoteLog.ts 顶层 import 了 product:
import product, { isOversea, isPrivate, isStandalone } from '@root/common/product';
而 product.ts 又通过 log 模块间接依赖了 remoteLog.ts,形成了闭环。
2. 但 Webpack 为什么没事?
这条循环依赖不是新写的,一直存在,Webpack 下跑了很久都没出过问题。两边的配置几乎一模一样:
- 同样的
babel-loader+@babel/preset-typescript - 同样的
splitChunks配置 - 同样的
target: 'electron-main' - 同样的
output.libraryTarget: 'commonjs'
那差异到底在哪?
3. 对比构建产物
把两边的构建产物拉出来一看,答案就在眼前。
Webpack 生成的模块导出代码:
__webpack_require__.d(__webpack_exports__, {
"default": () => (__WEBPACK_DEFAULT_EXPORT__)
});
// ...
var __WEBPACK_DEFAULT_EXPORT__ = product;
Rspack 生成的模块导出代码:
__webpack_require__.d(__webpack_exports__, {
"default": () => (__rspack_default_export)
});
// ...
/* export default */ const __rspack_default_export = (product);
看到了吗?一个用 var,一个用 const。
根因:Temporal Dead Zone(暂时性死区)
这不是什么配置差异,而是 JavaScript 语言层面 var 和 const/let 的本质区别。
var 的行为
console.log(a); // undefined(不报错)
var a = 1;
var 声明会被提升(hoisting) 到函数作用域顶部,变量在声明前就已存在,值为 undefined。
const/let 的行为
console.log(b); // ReferenceError: Cannot access 'b' before initialization
const b = 1;
const/let 虽然也会被提升,但在赋值语句执行之前处于暂时性死区(TDZ),任何访问都会抛 ReferenceError。
循环依赖下的连锁反应
理解了 TDZ,再来看循环依赖场景下发生了什么。
两个打包器都使用 __webpack_require__.d 来注册模块导出,本质是定义了一个 getter:
// 运行时源码(Webpack 和 Rspack 一致)
__webpack_require__.d = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key] // ← 这是一个惰性 getter
});
}
};
当注册 "default": () => (__rspack_default_export) 时,它只是定义了一个 getter 函数,并不会立即求值。真正求值发生在别的模块访问 .default 的时候。
循环依赖的执行流程
1. product.ts 开始执行
2. → import log → log 开始执行
3. → import xbotLog → xbotLog 开始执行
4. → import remoteLog → remoteLog 开始执行
5. → import product → 发现 product 正在执行中,返回当前(未完成的)exports
6. → 访问 product.default → 触发 getter → 读取 __rspack_default_export
7. → 💥 const 还没赋值,处于 TDZ → 报错!
如果是 var:
6. → 访问 product.default → 触发 getter → 读取 __WEBPACK_DEFAULT_EXPORT__
7. → var 已提升,值为 undefined → 不报错,后续 product 执行完毕后值会正确
修复
知道了根因,修复方案就很简单了。Rspack 提供了 output.environment 配置来控制生成代码使用的 ES 特性级别:
// rspack.main.js
module.exports = {
output: {
path: outputFilePath,
filename: '[name].js',
libraryTarget: 'commonjs',
environment: {
const: false, // 使用 var 代替 const/let
},
},
// ... 其他配置不变
};
设置 environment.const: false 后,Rspack 会将生成代码中所有的 const/let 退化为 var,与 Webpack 行为一致。
修改后重新构建,产物中的:
/* export default */ const __rspack_default_export = (product);
变成了:
/* export default */ var __rspack_default_export = (product);
循环依赖不再报错,问题解决。只改了 3 行配置,零业务代码改动。
思考
谁对谁错?
严格来说,Rspack 的做法更"正确"。ES Module 规范中,导入绑定(import binding)本身就不应该在模块初始化完成前被访问。使用 const 能让这类隐患在运行时尽早暴露。
而 Webpack 用 var 的做法更"宽容"——循环依赖下虽然不报错,但你拿到的是 undefined,如果代码恰好在初始化阶段就用了这个值去做判断或调用方法,可能会产生更隐蔽的 bug。
治本之道
output.environment.const: false 是一个低成本的兼容方案,但循环依赖本身才是根源。长远来看,更好的做法是:
- 延迟引用:将产生循环的 import 改为函数内
require(),只在真正需要时才加载 - 解耦模块:重新组织模块结构,打破依赖环路
- 检测工具:在 CI 中集成
circular-dependency-plugin或madge等工具,把循环依赖扼杀在 MR 阶段
总结
| 对比项 | Webpack | Rspack |
|---|---|---|
| default export 声明 | var __WEBPACK_DEFAULT_EXPORT__ | const __rspack_default_export |
| 循环依赖行为 | 提升为 undefined,静默通过 | TDZ 直接报错 |
| 兼容修复 | — | output.environment.const: false |
从 Webpack 迁移到 Rspack 时,如果遇到 Cannot access '__rspack_default_export' before initialization 错误,大概率是已有的循环依赖被 Rspack 更严格的代码生成方式暴露了出来。加一行 environment.const: false 可以快速恢复,但别忘了回头清理那些循环依赖——它们本来就不应该存在。