跳到主要内容

模块系统

模块解决的是 作用域隔离 + 依赖声明。全栈日常踩坑几乎都在: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.exportsimport / 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 怎么判断用哪套​

  1. 文件扩展名:.cjs 一定 CJS,.mjs 一定 ESM
  2. 最近的 package.json 里 "type": "module" → .js 当 ESM;否则 .js 当 CJS
  3. 包的 "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。

要点​

  1. CJS 同步、导出对象;ESM 静态、live binding
  2. Node 用扩展名 + "type" + exports 判定,不要靠猜
  3. 循环依赖先拆图,再谈工具
  4. 动态 import() 是运行时加载和代码分割的交界
  5. 库要被别人 require 时,只发 ESM 会把 CJS 调用方卡住

面试速记​

  1. ESM 和 CJS 核心差别? 加载时机(静态链接 vs 运行到才执行)和导出模型(绑定 vs 对象)。
  2. 为什么循环依赖会 undefined? 求值还没到赋值那一行,另一边已经来读。
  3. Node 里 .js 是 ESM 还是 CJS? 看最近 package.json 的 "type",再看扩展名覆盖。
  4. CJS 能不能加载 ESM? 不能同步 require;用 import()。
  5. Tree-shaking 为什么偏爱 ESM? 导入导出静态可见,打包器能证明某导出没被用到。