模块系统
模块解决的是 作用域隔离 + 依赖声明。全栈日常踩坑几乎都在:CJS 和 ESM 两套规矩、循环依赖、以及「源码能 import,产物却按另一种模块格式发出」。类型侧写法见 TypeScript 模块与命名空间;打包时模块怎么变成浏览器文件,见 构建工具与 Vite。
解决什么问题
| 痛点 | 模块怎么帮 |
|---|---|
| 全局变量互相覆盖 | 一文件一模块,导出显式 |
| 不知道依赖从哪来 | import / require 是图,可静态分析 |
| Node 能跑、浏览器报错 | 宿主默认模块格式不同;要打包或标 type="module" |
| 改 A 却影响到不该用 A 的包 | 依赖应走 package 导出,而不是深挖 node_modules 内部文件 |
一句话:ESM 是静态、异步友好的标准;CJS 是 Node 历史默认。混用要按边界处理,不要假装它们是同一种语法。
两套系统
| CommonJS (CJS) | ES Module (ESM) | |
|---|---|---|
| 语法 | require / module.exports | import / export |
| 加载 | 同步,运行到那一行才执行 | 静态声明,先链接再求值;import() 动态 |
| 典型文件 | .cjs、未 "type":"module" 的 .js(Node) | .mjs、"type":"module" 的 .js、浏览器 native |
| 导出形状 | exports 对象,可事后改属性 | 绑定(live binding),导出的是只读绑定 |
| 循环依赖 | 拿到尚未填完的 exports | 先建立绑定,求值顺序仍可能读到暂时的 undefined |
// CJS
const { add } = require("./math");
module.exports = { add };
// ESM
import { add } from "./math.js";
export { add };
浏览器原生 ESM 必须带齐路径或靠 import map;不能省略扩展名还指望像 Webpack 一样猜 .js。Vite 开发期用原生 ESM,所以源码侧更接近标准行为。
Node 怎么判断用哪套
- 文件扩展名:
.cjs一定 CJS,.mjs一定 ESM - 最近的
package.json里"type": "module"→.js当 ESM;否则.js当 CJS - 包的
"exports"/"main"决定别人import pkg进到哪
双模包(自己要同时给 require 和 import 用)靠 exports 条件导出,而不是在 ESM 文件里写 require。
{
"name": "lib",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
import ESM 里不能直接 require。反过来,CJS require 一个纯 ESM 包会报错,要用动态 import()(返回 Promise)。
循环依赖
a.js → b.js → a.js
两边都还没执行完就互相要值,读到的可能是 未初始化的绑定 或 半成品 exports。
| 做法 | 说明 |
|---|---|
把共享状态抽到第三模块 c.js | 打破环 |
| 只导入函数,在函数调用时再读对方 | 推迟到两边都求值完 |
| 能合并就合并 | 假模块边界没有收益 |
出现 Cannot access X before initialization(ESM 的 TDZ)或「导入是 undefined」,先画依赖图,别先怪打包器。
动态 import()
const { init } = await import("./heavy.js");
- 返回 Promise,走异步;适合按路由/按功能切开(见 产物与代码分割)
import()可以写在 CJS 里,作为通往 ESM 的桥- 路径尽量静态可分析(完整字符串),否则打包器切不出 chunk
与打包器的关系
| 阶段 | 模块含义 |
|---|---|
| 源码 | 你写 import,图由打包器或运行时解析 |
| 开发(Vite) | 浏览器直接加载 ESM 转换后的模块 |
| 生产产物 | 常打成少量 chunk(仍可能是 ESM 或 IIFE/CJS,看配置) |
| Tree-shaking | 依赖 ESM 静态结构;CJS 很难摇掉没用到的导出 |
sideEffects、默认导出、export * 都会影响摇树,细节放到产物篇。这里记住:想摇树,先用 ESM。
要点
- CJS 同步、导出对象;ESM 静态、live binding
- Node 用扩展名 +
"type"+exports判定,不要靠猜 - 循环依赖先拆图,再谈工具
- 动态
import()是运行时加载和代码分割的交界 - 库要被别人
require时,只发 ESM 会把 CJS 调用方卡住
面试速记
- ESM 和 CJS 核心差别? 加载时机(静态链接 vs 运行到才执行)和导出模型(绑定 vs 对象)。
- 为什么循环依赖会 undefined? 求值还没到赋值那一行,另一边已经来读。
- Node 里
.js是 ESM 还是 CJS? 看最近package.json的"type",再看扩展名覆盖。 - CJS 能不能加载 ESM? 不能同步
require;用import()。 - Tree-shaking 为什么偏爱 ESM? 导入导出静态可见,打包器能证明某导出没被用到。