WEBKT

别让 .mjs 文件毁了你的构建:Vite 与 Webpack 的模块冲突排雷指南

66 0 0 0

那个让 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.jsonexports 字段同时暴露 CJS 和 ESM 入口:

{
  "exports": {
    ".": {
      "import": "./index.mjs",
      "require": "./index.cjs"
    }
  }
}

理论上,构建工具应根据上下文自动选择。但现实中的冲突往往来自以下场景:

  1. 混用导入语法:在 ESM 文件中 require() 一个仅提供 .mjs 入口的包
  2. 解析优先级陷阱:Webpack 的 resolve.extensions.js 排在 .mjs 之前,导致错误加载 CJS 版本
  3. Vite 的预构建边界:某些 .mjs 文件使用了 Node.js 内置模块(如 path fs),在浏览器端构建时未被正确标记为 external
  4. 嵌套依赖版本碎片化:monorepo 中不同子包对同一依赖的 ESM/CJS 格式要求冲突

Webpack 配置:精准控制模块格式

Webpack 5 对 ESM 的支持已相当成熟,但默认配置仍有优化空间。

1. 确保 .mjs 优先级

检查你的 webpack.config.js,确保 .mjsextensions早于 .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',
    },
  },
};

实战排错清单

遇到构建错误时,按以下顺序排查:

  1. 定位冲突文件:通过错误堆栈确定是哪个 .mjs 文件触发问题
  2. 检查模块类型:在该文件目录下执行 cat package.json | grep type,确认是否为 "type": "module"
  3. 验证 exports 字段:使用 node -e "console.log(require('path/package.json').exports)" 查看条件导出定义
  4. 对比构建工具行为
    • Webpack:检查 stats.modules 输出,确认实际加载的是 .mjs 还是 .js
    • Vite:启动时查看 Optimized dependencies 列表,确认目标包是否被预构建
  5. 隔离测试:新建最小化仓库,仅安装冲突依赖,验证是配置问题还是包本身缺陷

写在最后:防御性配置建议

  • 锁定依赖解析:在 package.json 中使用 overrides(npm)或 resolutions(yarn/pnpm)强制统一关键依赖的版本和格式
  • CI 构建双重校验:同时跑 vite buildvite build --ssr(或 Webpack 的 SSR 配置),避免单端通过但另一端失败
  • 关注 package.json 的演进:安装新依赖时,快速扫一眼其 exportstype 字段,对纯 ESM 包保持警觉

工程化的本质是管理复杂度。.mjs 冲突看似是配置细节,实则是 JavaScript 模块生态转型期的阵痛。理解构建工具的解析链路,建立系统性的排错流程,才能避免在凌晨三点调试 SyntaxError

工程化老张 ViteWebpackES Modules

评论点评