4. 模块化工程实践

在企业级 JavaScript/TypeScript 项目中,模块化不是“选一种语法”这么简单。真实工程往往同时存在三类代码:新写的 ESM 源码、历史遗留的 CommonJS 包、以及需要按需加载的大型功能模块。理解它们之间的互操作规则、解析顺序和打包器行为,才能避免“本地能跑、构建报错、线上白屏”这类问题。

一、ESM 与 CommonJS 的本质差异

ESM(ECMAScript Modules)使用 import/export,是静态结构,导入导出关系在编译阶段即可确定,因此支持 tree-shaking、顶层 await 和循环引用的“活绑定”。CommonJS(CJS)使用 require/module.exports,是运行时加载,导出的是值的拷贝,模块对象在首次 require 时执行并缓存。

关键差异在于:ESM 导出的是绑定(binding),CJS 导出的是对象属性。这决定了互操作时“默认导出”如何映射。Node.js 在加载 ESM 时,会把 CJS 的 module.exports 整体作为 ESM 的 default 导出,同时尽力做静态分析,把可识别的属性挂成命名导出。因此:

// legacy.cjs
module.exports = { name: 'legacy', version: '1.0.0' };

// modern.mjs
import legacy from './legacy.cjs';        // 得到整个对象
import { name } from './legacy.cjs';      // Node 静态分析后可命名导入
console.log(legacy.name, name);           // legacy 1.0.0

反过来,ESM 被 CJS 引入时不能直接 require,因为 ESM 是异步的。Node 22+ 提供了 require(esm) 的实验能力,但工程上更稳妥的做法是:在 CJS 中用动态 import() 加载 ESM,或让打包器统一转换。

不要依赖“命名导入一定成功”。如果 CJS 模块使用 module.exports = function(){} 或运行时动态赋值,Node 无法静态识别属性,命名导入会失败,此时只能使用默认导入再解构。

二、动态导入:按需加载与代码分割

动态 import() 返回 Promise,是 ESM 规范的一部分,也是打包器做代码分割的入口。它适合路由级懒加载、重型图表库、仅在特定条件下使用的功能。

// 路由级懒加载(React 示例)
const ChartPanel = React.lazy(() => import('./ChartPanel'));

// 条件加载:只有用户点击“导出 PDF”才加载大依赖
async function exportPdf(data) {
  const { jsPDF } = await import('jspdf');
  const doc = new jsPDF();
  doc.text(JSON.stringify(data), 10, 10);
  doc.save('report.pdf');
}

打包器遇到 import() 会生成独立 chunk。需要注意:动态导入的路径如果是变量拼接,打包器无法静态分析,会退化为运行时解析甚至报错。推荐使用可静态分析的字符串字面量,或配合 /* webpackChunkName: "chart" */ 这类魔法注释命名 chunk。

三、模块解析策略

解析策略决定“import './utils' 到底加载哪个文件”。TypeScript 通过 moduleResolution 控制,常见取值:

  • node:传统 Node 解析,支持扩展名省略、index 文件、package.json main。
  • node16/nodenext:严格遵循 Node 的 ESM 规则,要求显式扩展名,读取 exports 字段。
  • bundler:面向 Vite/webpack 等打包器,允许省略扩展名,支持 exports 条件导出。

企业项目推荐 moduleResolution: "bundler" 配合 module: "ESNext",既保留打包器的灵活性,又能获得类型检查。若发布 npm 包,则应使用 nodenext 并正确配置 exports 字段:

{
  "name": "@corp/ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/styles.css"
  }
}

exports 字段一旦声明,未列出的子路径将无法被外部导入,这既是封装手段,也是常见的“升级后导入失败”根因。排查时先确认目标路径是否在 exports 白名单内。

四、打包工具集成要点

Vite 开发态基于原生 ESM,构建态用 Rollup;webpack 5 原生支持 ESM 并处理 CJS 互操作。集成时重点关注:

  • 格式输出:库项目同时输出 es 与 cjs,通过 exports 条件分发。
  • external:把 React、lodash 等 peer 依赖标记为 external,避免重复打包。
  • tree-shaking:确保 sideEffects: false 且源码使用具名导出,避免 export default { a, b } 这种整体对象。
  • 类型声明:用 tsc --emitDeclarationOnly 或 vite-plugin-dts 生成 .d.ts,并在 exports 中声明 types 条件。

下面这张图展示了从源码到产物的模块解析与打包链路,以及 ESM/CJS 在其中的分流关系:

源码层 ESM 源码 import / export CJS 依赖 require / module.exports 动态导入 import() → Promise 解析层 TypeScript moduleResolution node / node16 / nodenext bundler(推荐) package.json exports 打包层 Vite / Rollup 原生 ESM · tree-shaking 动态导入 → 独立 chunk webpack 5 ESM/CJS 互操作 splitChunks · external 产物层 dist/index.js ESM 格式 dist/index.cjs CJS 格式 dist/chunk-*.js 按需加载 chunk dist/index.d.ts 类型声明 互操作规则 CJS → ESM: module.exports = default ESM → CJS: 用 import() 或打包转换

五、常见故障与排查路径

互操作问题通常表现为三类错误,可按以下顺序定位:

现象可能原因处理方式
ERR_REQUIRE_ESM CJS 中 require 了 ESM 包 改用动态 import(),或升级到支持 require(esm) 的 Node 版本
命名导入为 undefined CJS 导出无法被静态分析 改为默认导入后解构,或让包提供 ESM 入口
子路径导入失败 package.json exports 未声明该路径 在 exports 中补充映射,或改用公开入口
类型找不到 exports 缺少 types 条件 在 import/require 前声明 types 字段
排查时先用 node --input-type=module -e "import('pkg').then(m=>console.log(Object.keys(m)))" 观察实际导出形状,再对照打包器配置,比盲改 tsconfig 更高效。

六、工程落地建议

对于新项目,统一使用 ESM 源码,moduleResolution: "bundler",构建产物同时输出 ESM 与 CJS,并在 exports 中显式声明 types、import、require 三个条件。对于存量 CJS 项目,不必一次性重写,可先通过动态 import 引入 ESM 依赖,逐步迁移。打包器层面,把 peer 依赖设为 external,开启 tree-shaking,动态导入用于路由和重型功能,最终形成“源码 ESM 化、产物双格式、加载按需化”的稳定模块体系。