"掌握模块化"不等于"会写 import / export"。很多人写了三年 ESM,遇到循环依赖、Tree Shaking 失效、Monorepo 包引用、微前端隔离就说不清楚。真正掌握模块化,是理解模块化要解决什么问题、每种规范的设计取舍、以及如何在工程中落地模块边界。
一句话判断标准:
给你一个中型项目,你能设计出清晰的模块边界,选择合适的模块化方案,解释循环依赖为什么发生、Tree Shaking 为什么失效,并能用 Monorepo 或微前端组织多包/多团队协作。
一、先建立知识地图:模块化包含什么
模块化不只是语法,它覆盖语言层、工程层、架构层三个层面:
| 层次 | 解决的问题 | 核心内容 | 重要程度 |
|---|---|---|---|
| 语言层 | 代码如何拆分和引用 | CommonJS、AMD、CMD、UMD、ESM | 必须熟练 |
| 工程层 | 模块如何被解析、打包、优化 | 模块解析、Tree Shaking、副作用、动态导入、循环依赖 | 必须熟练 |
| 架构层 | 项目如何组织模块边界 | 目录分层、Monorepo、共享包、微前端、模块联邦 | 理解原理 |
| 设计层 | 模块如何高内聚低耦合 | 单一职责、依赖方向、接口设计、封装边界 | 必须熟练 |
其中ESM vs CommonJS、Tree Shaking、循环依赖、模块边界设计是分水岭。
二、语言层:五种模块化规范,重点掌握两种
1. CommonJS(CJS):Node.js 的默认规范
- 语法 :
require()/module.exports/exports。 - 特点 :运行时加载 、值拷贝 、同步加载、模块缓存。
- 适用:Node.js 服务端、Webpack 配置、旧工具链。
js
// 导出
module.exports = { a: 1 }
// 或
exports.a = 1
// 导入
const { a } = require('./module')
关键理解 :require 是运行时执行,返回的是值的拷贝 (对原始类型)。模块第一次被加载后会被缓存,后续 require 返回同一份导出对象。
2. ESM(ES Modules):现代标准
- 语法 :
import/export/export default/import()。 - 特点 :编译时静态分析 、值引用 、异步加载 、顶层
await、严格模式。 - 适用:浏览器、现代构建工具、新项目默认选择。
js
// 具名导出
export const a = 1
export function fn() {}
// 默认导出
export default { a: 1 }
// 导入
import { a, fn } from './module.js'
import defaultExport from './module.js'
import * as ns from './module.js'
关键理解 :import 是静态声明 ,在编译时确定依赖关系,不能放在条件语句里(除非用动态 import())。导出的是值的引用(live binding),模块内部变量变化会反映到导入方。
3. ESM vs CommonJS:必须能说清楚的差异
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时 | 编译时(静态分析) |
| 加载方式 | 同步 | 异步 |
| 导出值 | 值拷贝 | 值引用(live binding) |
this |
指向 module.exports |
undefined |
顶层 await |
不支持 | 支持 |
| 循环依赖 | 返回未完成的导出对象 | 返回未初始化的绑定(TDZ) |
| Tree Shaking | 难(动态) | 容易(静态) |
| 浏览器直接支持 | 否 | 是(type="module") |
循环依赖的行为差异是高频考点:
js
// a.js (CJS)
exports.done = false
const b = require('./b')
console.log('in a, b.done =', b.done)
exports.done = true
// b.js (CJS)
exports.done = false
const a = require('./a')
console.log('in b, a.done =', a.done) // false,因为 a 还没执行完
exports.done = true
ESM 的循环依赖返回的是未初始化的绑定,访问会报 TDZ 错误,但函数声明会被提升,所以函数引用通常能工作。
4. AMD / CMD / UMD:知道即可
- AMD (RequireJS):浏览器端异步加载,
define()/require()。 - CMD(SeaJS):类似 AMD,延迟执行。
- UMD:兼容 CJS、AMD、全局变量,常用于库的产物格式。
现代项目基本不用 AMD/CMD,但读老库源码或 UMD 产物时会遇到,知道概念即可。
5. 动态导入:import()
js
const module = await import('./heavy-module.js')
- 返回 Promise,用于懒加载 、条件加载 、代码分割。
- 构建工具会把动态导入的模块拆成独立 chunk。
学到什么程度:能解释 ESM 和 CJS 在加载时机、导出值、循环依赖上的差异,能手写一个循环依赖的例子并说出两种规范下的行为。
三、工程层:模块解析、Tree Shaking、循环依赖
1. 模块解析:import 'foo' 到底找的是谁
- 相对路径 :
./foo、../bar。 - 裸模块 :
import Vue from 'vue',从node_modules解析。 - 路径别名 :
@/utils,通过 Vite/Webpack 的resolve.alias配置。 package.json的exports字段 :现代包用exports定义条件导出(import/require/browser/node),解析优先级高于main/module。main/module/browser字段:老包的入口声明。
关键理解 :同一个包可能对 import 和 require 返回不同文件(双格式包),这会导致同一模块被加载两次的问题(dual package hazard)。
2. Tree Shaking:为什么有些代码摇不掉
Tree Shaking 依赖 ESM 的静态结构 和副作用标记。
能摇掉的条件:
- 使用 ESM 的
import/export。 - 构建工具能静态分析出未使用的导出。
- 模块或函数被标记为无副作用 (
sideEffects: false或/*#__PURE__*/)。
摇不掉的常见原因:
- 使用 CommonJS(动态
require,无法静态分析)。 - 包没有声明
sideEffects: false,构建工具保守地保留所有代码。 - 顶层有副作用(如修改全局变量、立即执行的代码)。
- 导入后整体使用(
import * as ns后动态访问属性)。 - 类或对象的方法被动态调用,无法确定是否使用。
sideEffects 字段:
json
{
"sideEffects": false
}
或指定有副作用的文件:
json
{
"sideEffects": ["*.css", "./src/polyfill.js"]
}
学到什么程度 :能解释 Tree Shaking 的原理,能判断一个包为什么摇不掉,能通过 sideEffects 和 /*#__PURE__*/ 优化。
3. 循环依赖:为什么会发生、怎么排查
发生原因:A 依赖 B,B 又依赖 A。
CJS 下的表现 :require 返回未完成的 module.exports,可能拿到 undefined。
ESM 下的表现:返回未初始化的绑定,访问报 TDZ 错误;但函数声明提升,函数引用通常可用。
排查方法:
- 用
madge或dependency-cruiser分析依赖图,检测循环。 - 构建工具通常会警告。
- 重构:提取公共模块、依赖倒置、延迟引用(函数内
require)。
学到什么程度:能画出循环依赖的模块图,能解释两种规范下的行为差异,能用工具检测并重构。
四、架构层:模块边界设计
这是"掌握模块化"的最高层次,也是区分"会写代码"和"会设计系统"的分界线。
1. 目录分层:按职责划分模块
text
src/
├── api/ # 接口层
├── components/ # 通用组件
├── composables/ # 组合式函数
├── features/ # 业务模块(按领域拆分)
│ ├── user/
│ ├── order/
│ └── system/
├── shared/ # 跨模块共享
│ ├── utils/
│ └── types/
├── stores/ # 全局状态
└── views/ # 页面
原则:
- 高内聚:一个模块内部的东西放在一起。
- 低耦合:模块之间通过明确的接口通信。
- 依赖方向:上层依赖下层,业务模块不互相依赖,共享层不依赖业务层。
2. 模块边界:什么该暴露,什么该隐藏
- 每个模块有明确的入口 :
index.ts导出公共 API。 - 内部实现不对外暴露:外部不能直接 import 模块内部的私有文件。
- 用 ESLint 约束依赖方向 :
eslint-plugin-boundaries或import/no-restricted-paths禁止跨层依赖。
js
// .eslintrc 中限制 features 之间不能互相 import
'import/no-restricted-paths': [
'error',
{
zones: [
{ target: './src/features/user', from: './src/features/order' },
{ target: './src/features/order', from: './src/features/user' }
]
}
]
3. Monorepo:多包/多项目的模块组织
当项目规模变大,单仓库单包不够用时,用 pnpm workspace 组织 Monorepo:
text
packages/
├── ui/ # 组件库
├── utils/ # 工具库
├── shared/ # 共享类型和常量
├── web-app/ # Web 应用
└── admin-app/ # 后台应用
- 共享包 通过
workspace:*引用,本地开发实时生效。 - Turborepo / Nx 做任务编排和缓存,避免重复构建。
- 版本管理 :用
changesets管理版本和 CHANGELOG。
4. 微前端:多团队协作的模块化
当多个团队独立开发、独立部署时,用微前端组织:
- qiankun / micro-app:主应用加载子应用。
- Module Federation:Webpack 5 的模块联邦,运行时共享模块。
- iframe:最简单但隔离最彻底。
关键理解 :微前端解决的是组织问题,不是技术问题。小团队、单项目不需要微前端。
学到什么程度:能设计一个模块边界清晰的目录结构,能用 ESLint 约束依赖方向,能搭一个 pnpm workspace + 共享包的最小 Monorepo,能说出微前端的适用场景和不适用场景。
五、学习方法:读规范、画依赖图、造轮子
1. 读规范
- MDN 的 ESM 文档:理解静态结构、live binding、循环依赖。
- Node.js 的 CJS 文档 :理解
require的缓存和解析机制。 package.json的exports字段规范:理解条件导出。
2. 画依赖图
- 用
madge生成项目的依赖图,找循环依赖和耦合点。 - 用
dependency-cruiser可视化模块关系。 - 手动画一个业务模块的依赖图,问自己:依赖方向合理吗?有没有循环?边界清晰吗?
3. 造轮子
- 手写一个极简的 CJS 加载器 :实现
require的缓存和解析。 - 手写一个极简的 ESM 加载器:理解静态分析和 live binding。
- 搭一个 pnpm workspace :包含
ui、utils、web-app三个包,跑通共享引用和构建。
4. 删配置实验
- 删掉
sideEffects: false,看产物体积变化。 - 把 ESM 改成 CJS,看 Tree Shaking 是否失效。
- 制造一个循环依赖,看构建工具和运行时的表现。
六、学习路径(建议 4-6 周)
| 阶段 | 时间 | 重点 | 检验标准 |
|---|---|---|---|
| 1. 语言层 | 第 1 周 | CJS vs ESM、循环依赖、动态导入 | 能解释两种规范的行为差异,能手写循环依赖例子 |
| 2. 工程层 | 第 2 周 | 模块解析、Tree Shaking、sideEffects、exports 字段 |
能判断一个包为什么摇不掉,能优化产物 |
| 3. 架构层 | 第 3-4 周 | 目录分层、模块边界、ESLint 约束、Monorepo | 能设计清晰的模块结构,能搭一个 pnpm workspace |
| 4. 综合 | 第 5-6 周 | 用 madge 分析现有项目,重构一个耦合严重的模块 |
依赖图清晰,无循环依赖,边界明确 |
七、常见误区
- "会写 import/export 就是掌握模块化":语法只是入口,理解解析、Tree Shaking、边界设计才是核心。
- "ESM 一定比 CJS 好":Node.js 生态大量依赖 CJS,双格式包有 dual package hazard,需要理解取舍。
- "Tree Shaking 是构建工具自动做的":它依赖 ESM 和副作用标记,包作者不配合就摇不掉。
- "循环依赖很少见":中型项目里很常见,不检测就会在运行时炸。
- "Monorepo 就是把所有包放一个仓库":还需要 workspace 配置、任务编排、版本管理、CI 策略。
- "微前端能解决所有耦合问题":微前端解决组织问题,不解决代码质量问题,小项目上微前端是灾难。
- "模块边界靠自觉":要靠目录约定 + ESLint 约束 + CR 检查,否则很快腐化。
八、验证自己"掌握"了的标志
能全部做到,说明模块化已经掌握:
- 能解释 ESM 和 CJS 在加载时机、导出值、循环依赖上的差异。
- 能手写一个循环依赖的例子,并说出两种规范下的运行时行为。
- 能解释 Tree Shaking 的原理,能判断一个包为什么摇不掉,并能通过
sideEffects优化。 - 能解释
package.json的exports、main、module、browser字段的优先级和作用。 - 能设计一个模块边界清晰的目录结构,并用 ESLint 约束依赖方向。
- 能搭一个 pnpm workspace + 共享包的最小 Monorepo,跑通引用和构建。
- 能用
madge或dependency-cruiser分析项目依赖图,找出循环依赖和耦合点。 - 能说出微前端的适用场景和不适用场景,并解释为什么小项目不需要微前端。
- 能解释动态
import()如何实现代码分割,以及它和静态import的区别。 - 能向一个新人讲清楚:从写一个
import到最终产物,中间经过了哪些模块化处理环节。
掌握模块化的本质,是理解"拆分"和"连接"的取舍。拆得太细,依赖复杂;拆得太粗,耦合严重。好的模块化设计,是让每个模块高内聚、低耦合、边界清晰、依赖方向可控,而语法只是实现手段。