别让 .mjs 文件毁了你的构建:Vite 与 Webpack 的模块冲突排雷指南
那个让 CI 挂掉的周一早晨
上周一,我们的主分支构建突然红了。错误日志里赫然躺着一行:
SyntaxError: Cannot use import statement outside a module
诡异的是,报错的文件位于 node_modules/lodash-es/lodash.js,而项目已经全面迁移 ESM 半年之久。排查三小时后,元凶浮出水面:某个深层依赖悄悄引入了一个 .mjs 文件,而构建工具对它的解析规则与预期不符。
这不是孤例。随着社区全面拥抱 ES Modules,.mjs 文件在 node_modules 中的存在感越来越强。但 Vite 和 Webpack 对模块的解析逻辑存在微妙差异,稍有不慎就会触发"双包陷阱"或"格式冲突"。
冲突的根源:当 .mjs 遇上边界情况
现代 npm 包通常通过 package.json 的 exports 字段同时暴露 CJS 和 ESM 入口:
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
}
理论上,构建工具应根据上下文自动选择。但现实中的冲突往往来自以下场景:
- 混用导入语法:在 ESM 文件中
require()一个仅提供.mjs入口的包 - 解析优先级陷阱:Webpack 的
resolve.extensions中.js排在.mjs之前,导致错误加载 CJS 版本 - Vite 的预构建边界:某些
.mjs文件使用了 Node.js 内置模块(如pathfs),在浏览器端构建时未被正确标记为 external - 嵌套依赖版本碎片化:monorepo 中不同子包对同一依赖的 ESM/CJS 格式要求冲突
Webpack 配置:精准控制模块格式
Webpack 5 对 ESM 的支持已相当成熟,但默认配置仍有优化空间。
1. 确保 .mjs 优先级
检查你的 webpack.config.js,确保 .mjs 在 extensions 中早于 .js:
module.exports = {
resolve: {
extensions: ['.mjs', '.js', '.jsx', '.json'], // .mjs 必须在 .js 之前
mainFields: ['module', 'browser', 'main'], // 优先读取 module 字段(ESM)
},
};
如果颠倒顺序,Webpack 可能会加载包的 CJS 版本,导致 Tree Shaking 失效或出现双包问题。
2. 针对纯 ESM 包的强制转译
某些现代包(如 strip-ansi chalk v5+)已完全放弃 CJS。如果它们未经转译直接出现在 node_modules,可能需要显式处理:
module.exports = {
module: {
rules: [
{
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto', // 关键:允许 Webpack 自动推断模块类型
},
],
},
};
javascript/auto 是 Webpack 4/5 的默认类型,但如果你在配置中全局设置了 type: 'javascript/esm',就需要为 .mjs 单独回退,否则会遇到 import.meta 解析错误。
3. 处理 Conditional Exports 的边界情况
若依赖的 exports 字段定义混乱(例如缺少 default 条件),可通过 resolve.exportsFields 微调,但通常更建议直接锁定依赖版本或向上游提 PR。
Vite 配置:开发与生产的一致性
Vite 原生基于 ESM,对 .mjs 的支持理论上更顺畅,但开发服务器(Esbuild)与生产构建(Rollup)的差异仍会制造陷阱。
1. 强制预构建包含
当某个 .mjs 包被动态导入,或使用了 Node 内置模块时,Vite 可能无法自动识别需要预构建:
// vite.config.js
export default {
optimizeDeps: {
include: [
' problematic-package > nested-esm-dep', // 深层依赖
'lodash-es', // 纯 ESM 大型库
],
esbuildOptions: {
target: 'es2020', // 确保与浏览器目标一致
},
},
};
典型症状:开发时正常,生产构建报错 __dirname is not defined。这是因为该 .mjs 文件在开发时被 Esbuild 处理,而 Rollup 在生产构建时尝试将其打包进浏览器代码。
2. 处理 SSR 场景的格式冲突
如果你的项目使用 SSR(服务端渲染),需要特别注意 ssr.noExternal 配置:
export default {
ssr: {
noExternal: ['some-esm-only-pkg'], // 强制打包该 ESM 包,避免 Node 端 require 报错
},
};
某些 .mjs 包在 SSR 环境下如果保持 external,Node 会尝试用 require() 加载它,直接抛出 ERR_REQUIRE_ESM。
3. 别名映射绕过冲突
当两个依赖分别要求同一库的不同格式(一个要 CJS,一个要 ESM),可通过 resolve.alias 强制统一:
export default {
resolve: {
alias: {
// 强制所有引用指向 ESM 版本
'lodash': 'lodash-es',
// 或者反向操作,强制 CJS 版本
'problematic-esm-lib': 'problematic-esm-lib/dist/index.cjs',
},
},
};
实战排错清单
遇到构建错误时,按以下顺序排查:
- 定位冲突文件:通过错误堆栈确定是哪个
.mjs文件触发问题 - 检查模块类型:在该文件目录下执行
cat package.json | grep type,确认是否为"type": "module" - 验证 exports 字段:使用
node -e "console.log(require('path/package.json').exports)"查看条件导出定义 - 对比构建工具行为:
- Webpack:检查
stats.modules输出,确认实际加载的是.mjs还是.js - Vite:启动时查看
Optimized dependencies列表,确认目标包是否被预构建
- Webpack:检查
- 隔离测试:新建最小化仓库,仅安装冲突依赖,验证是配置问题还是包本身缺陷
写在最后:防御性配置建议
- 锁定依赖解析:在
package.json中使用overrides(npm)或resolutions(yarn/pnpm)强制统一关键依赖的版本和格式 - CI 构建双重校验:同时跑
vite build和vite build --ssr(或 Webpack 的 SSR 配置),避免单端通过但另一端失败 - 关注 package.json 的演进:安装新依赖时,快速扫一眼其
exports和type字段,对纯 ESM 包保持警觉
工程化的本质是管理复杂度。.mjs 冲突看似是配置细节,实则是 JavaScript 模块生态转型期的阵痛。理解构建工具的解析链路,建立系统性的排错流程,才能避免在凌晨三点调试 SyntaxError。