从 Webpack 迁移到 Rspack 后,循环依赖为什么炸了?一个 const vs var 引发的血案

4 阅读4分钟

背景

最近在做公司 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 语言层面 varconst/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 是一个低成本的兼容方案,但循环依赖本身才是根源。长远来看,更好的做法是:

  1. 延迟引用:将产生循环的 import 改为函数内 require(),只在真正需要时才加载
  2. 解耦模块:重新组织模块结构,打破依赖环路
  3. 检测工具:在 CI 中集成 circular-dependency-pluginmadge 等工具,把循环依赖扼杀在 MR 阶段

总结

对比项WebpackRspack
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 可以快速恢复,但别忘了回头清理那些循环依赖——它们本来就不应该存在。