模块化与工程化:CJS / ESM / 打包器,到底怎么协作?

问题场景

"为什么 Node 里用 require,前端用 import?""module.exportsexport default 混用为什么报错?""import 明明写在文件中间,为什么说它是静态的?""我明明没用到某个导出,为什么 bundle 里还在?"------这些都是模块生态的"玄学"。

模块化(Module System)+ 打包器(Bundler)是现代前端工程的基石。不理解它们,你写的 import 就是"能用但说不清",遇到双模块混用、循环依赖、tree-shaking 失效就抓瞎。本文把它们讲透,并给出工程化的实际取舍。

原理深入

1. 两种核心模块规范:CJS 与 ESM

CJS(CommonJS)------ Node 的传统标准,同步、运行时

js 复制代码
// a.js (CJS)
const x = require('./b');   // 同步 require
module.exports = { x };     // 导出

ESM(ES Module)------ 语言标准,异步、静态

js 复制代码
// a.mjs (ESM)
import x from './b.mjs';   // 静态 import(必须在顶层)
export default x;           // 默认导出
export const y = 1;         // 命名导出

核心区别速查:

CJS ESM
加载时机 运行时(同步) 编译期(静态)
依赖写法 require() 运行时解析 import 静态顶层
导出写法 module.exports export default / export
tree-shaking 难(运行时确定) 天然支持(静态)
live binding 无(值快照) 有(实时绑定)
使用场景 Node 脚本、老库 浏览器、现代 Node

关键差异点详解:

差异 1:静态 vs 动态。 ESM 的 import/export 必须写在顶层(不能用 if/循环包裹),因为引擎在编译阶段就要确定依赖图 ;CJS 的 require 是函数调用,运行时才执行,位置随意。

js 复制代码
// ❌ ESM 不能条件导入
if (cond) import x from './x';  // 语法错误

// ✅ CJS 可以(但要小心)
if (cond) { const x = require('./x'); }

差异 2:live binding vs 快照。 ESM 是"实时绑定"------导入方看到的是最新的导出值 ;CJS 是"值快照"------require 后改动不影响已有引用。

js 复制代码
// ESM:live binding
// lib.mjs
export let count = 0;
setTimeout(() => count = 99);   // 0.1 秒后

// app.mjs
import { count } from './lib.mjs';
console.log(count);   // 0(第一次)
setTimeout(() => console.log(count), 200);  // 99(live,看到最新)

2. 打包器到底在干什么(Vite/Webpack/Rollup)

浏览器原生不认识 requireimport 也要经转换/合并。打包器负责:

  1. 解析依赖图 :从入口文件出发,把 import/require 拉成一张依赖树。
  2. 转换语法:ESM/TS/JSX → 浏览器能跑的 JS(babel/esbuild/rollup)。
  3. 打包合并 :把所有模块拷贝进一个(或几个)bundle,用"模块注册表"模拟模块系统。
  4. 优化:tree-shaking(删没用导出)、代码分割(code-split)、压缩、hash。
text 复制代码
入口.js ─→ 依赖图 ─→ 转换 → 打包 → dist/bundle.js
  ├ import a ─┐
  ├ import b ─┼→ 全部合并(模块注册表)
  └ import c ─┘

核心概念:模块注册表(模拟 require/import)

打包后的 bundle 里,每个模块被包进一个函数,靠注册表互相引用:

js 复制代码
// 打包后的简化形态
const modules = {
  './a': (module, exports, require) => { /* a 的代码 */ },
  './b': (module, exports, require) => { /* b 的代码 */ },
};
function require(id) { return modules[id](); }  // 模拟加载

3. tree-shaking:ESM 之所以能删代码

因为 ESM 静态,打包器能精确知道"这个模块只用了导出 A,没用 B",于是 B 被标记为 dead code 删掉:

js 复制代码
// utils.js
export function used() { return 1; }
export function unused() { return new Array(1e6); }  // 没人用

// app.js
import { used } from './utils';
console.log(used());
// 打包后:unused 被 tree-shake 掉,bundle 更小

让 tree-shaking 生效的条件:

  • 用 ESM(import/export),别用 module.exports(无法静态分析)。
  • 别写有副作用 的模块(顶层 console.log、改全局、调用 API)。
  • 别用会绕过静态分析的解构方式(有些写法会阻止摇树)。
  • 注意打包器配置(sideEffects: false 声明无副作用模块)。

4. 循环依赖:CJS vs ESM 差异

  • CJSrequire 时若模块还在加载中(循环),拿到的是未完成的 module.exports 快照 ,可能得到 undefined
  • ESM :live binding,循环依赖时用的是最终值,但要小心"暂时性死区"(TDZ)------导入未初始化的变量会报错。
js 复制代码
// CJS 循环依赖经典坑
// a.js
const b = require('./b');
console.log(b);   // 可能 undefined(b 还没完全加载)

// 缓解:把公共依赖抽取成独立模块,避免环;或在 require 后访问属性而非顶层解构
console.log(b.someProp);  // 若 b 模块后面才赋值 someProp,这里仍可能 undefined

根治循环依赖:把公共依赖抽取成独立模块,消除环;或在依赖方向设计上避免双向引用。

5. 工程化实践:Vite / 双模块混用

  • 现代项目一般用 Vite(ESM 优先)+ 构建时降级
  • 混用报错:某库只支持 CJS(module.exports),你在 ESM 项目里 import 它,打包器会做 interop(转换),但有些边界(default 导入拿不到)会踩坑。
  • .mjs(ESM)/ .cjs(CJS)显式声明文件类型,避免歧义。
  • package.jsontype: "module" 标识项目为 ESM。

实战代码:手写一个极简打包器(理解核心)

js 复制代码
// 极简 bundler:把 ESM import 的模块合并成一个可运行的 bundle
const fs = require('fs');
const path = require('path');

function getCode(file) { return fs.readFileSync(file, 'utf8'); }

function bundle(entry) {
  const modules = {};   // id -> 包装函数
  const queue = [entry];

  // 1. 广度优先收集依赖图
  while (queue.length) {
    const id = queue.shift();
    const code = getCode(id);
    const deps = [...code.matchAll(/import .* from ['"](.+?)['"]/g)].map((m) => m[1]);
    modules[id] = { code, deps };
    deps.forEach((d) => queue.push(path.resolve(path.dirname(id), d)));
  }

  // 2. 生成模块注册表字符串(示意:把 import 转成 require 风格)
  const entries = Object.entries(modules).map(
    ([id, m]) =>
      `"${id}": function(module, exports, require) { ${m.code.replace(
        /import .* from ['"](.+?)['"]/g,
        (_, p) => `const dep = require('${path.resolve(path.dirname(id), p)}')`
      )} }`
  );
  return `const modules = { ${entries.join(',')} }; console.log('bundle 生成');`;
}

console.log(bundle(path.resolve('entry.js')));

这是极简示意,真实打包器(Rollup/esbuild)会做 AST 解析、tree-shaking、作用域分析,但核心"收集依赖图 + 合并 + 模拟模块系统"就是这个思路。

要点总结

  • CJSrequire + module.exports,同步运行时;难静态分析、值快照。
  • ESMimport + export,异步静态、语言标准;天然 tree-shaking、live binding。
  • 打包器:收集依赖图 → 转换 → 合并 → 优化(tree-shake/code-split/压缩)。
  • tree-shaking 依赖静态 ESM :用 import/export,避免副作用模块,配 sideEffects: false
  • 循环依赖:CJS 拿快照可能 undefined;ESM live binding 较稳但注意 TDZ;最好抽公共依赖消除环。
  • 双模块混用报错:多半是库只支持 CJS 或文件类型(.mjs/.cjs)歧义,用 type: "module" 规避。
  • 排查依赖:npm ls 查依赖树、bundle-buddy/source-map-explorer 看 bundle 内容、tree-shaking 不生效先查副作用。

一句话:CJS 运行时同步、ESM 编译期静态------打包器把 ESM 拍平成浏览器能跑的模块注册表,tree-shaking 就是吃到"静态"红利。搞懂这套,工程化底层不再神秘。

相关推荐
hunterandroid1 小时前
[鸿蒙从零到一] ArkTS Sendable 数据模型与跨线程共享实战
前端
AICoder码农王1 小时前
Vitest 实战:从一个真实项目看懂测试套件
前端·前端框架
律宏阔1 小时前
Electron Forge 在 macOS M4 打包踩坑:Node.js 26 导致 make 异常中断
前端
木木爱研究1 小时前
elpis-里程碑四-基于Vue完成动态组件库建设
前端·后端
深念Y1 小时前
Nuxt 项目 Docker 构建:从 pnpm+node 迁移到全 bun
java·前端·docker
qq_426003961 小时前
UI自动化元素定位不到解决方案
前端·pycharm·自动化
EthanChou20201 小时前
AES67协议笔记
前端·笔记·es6
马优晨1 小时前
pom.xml 中 jquery webjars 依赖解释
xml·前端·jquery·jquery webjars·webjars依赖
里欧跑得慢2 小时前
AI 驱动的 UI 国际化方案自动适配:从单一语言到多语言布局的智能转换
前端·css·flutter·web