模块化与包管理
从全局函数到 ES Module,模块化是前端工程化的基石。包管理工具的演进则围绕「依赖安装速度」「磁盘空间」「幽灵依赖」三个核心问题不断迭代。
1. 模块化演进
1.1 历史脉络
全局函数(2005-)
└─ 命名冲突、依赖顺序手动维护、污染全局
IIFE(2008-)
└─ 闭包隔离、仍无法声明依赖关系
CommonJS(2009-)
└─ 服务端模块化、同步加载、不支持浏览器
AMD/RequireJS(2010-)
└─ 浏览器异步模块定义、define/require、回调嵌套
CMD/Sea.js(2011-)
└─ 按需执行、就近定义、玉伯出品
UMD(2012-)
└─ CJS + AMD + 全局变量三合一兼容
ES Module(2015-)
└─ 语言标准、静态分析、Tree Shaking 基础
1.2 设计动机分析
| 方案 | 核心问题 | 解决方案 | 代价 |
|---|---|---|---|
| 全局函数 | 命名冲突 | 命名空间(const App = {}) |
不强制、易覆盖 |
| IIFE | 变量泄露全局 | 闭包 + 立即执行 | 依赖顺序手动保证 |
| CJS | 浏览器不支持 | 同步 require + 模块作用域 | 不适合前端异步场景 |
| AMD | 异步加载 | define 提前声明依赖 | 语法复杂、体积大 |
| UMD | 多种环境兼容 | 运行时环境检测 | 模板代码冗余 |
| ESM | 标准缺失 | 语言级支持 | 老旧浏览器需转译 |
2. CommonJS
2.1 require 加载流程
require('./module.js')
↓ 1. 路径分析
尝试: ./module.js → ./module.json → ./module.node → ./module/index.js
查找过程: 当前目录 → node_modules → 全局 node_modules
↓ 2. 文件定位
定位到绝对路径 /path/to/module.js
↓ 3. 检查缓存(require.cache)
命中 → 直接返回 module.exports
↓ 4. 编译执行
Node 将模块代码包裹在函数中:
(function(exports, require, module, __filename, __dirname) {
// 模块源码
})
↓ 5. 执行后 module.exports 返回
↓ 6. 写入缓存 require.cache[resolvedPath] = module
Node 模块包装器:
// 实际执行时的包裹
(function(exports, require, module, __filename, __dirname) {
// 你的代码在此
const a = require('./a');
module.exports = { a };
});
2.2 module.exports vs exports
// exports 是 module.exports 的引用
const module = { exports: {} };
const exports = module.exports; // 引用
// ✅ 正确:给 exports 添加属性
exports.a = 1;
exports.fn = () => {};
// ❌ 错误:重新赋值 exports 会断开引用
exports = { a: 1 }; // module.exports 仍然是空对象
// ✅ 正确做法:直接赋值 module.exports
module.exports = { a: 1 };
2.3 循环引用处理
// a.js
console.log('a 开始');
const b = require('./b'); // → 暂停 a.js 执行,进入 b.js
console.log('b 中的 val:', b.val);
module.exports.val = 'A';
console.log('a 结束');
// b.js
console.log('b 开始');
const a = require('./a'); // → 从缓存中获取 a 的「当前已执行的 exports」
console.log('a 中的 val:', a.val); // 这里 a.val = {}(尚未赋值)
module.exports.val = 'B';
console.log('b 结束');
// 执行结果:
// a 开始
// b 开始
// a 中的 val: {} ← 关键:a 尚未执行完,exports 为初始空对象
// b 结束
// b 中的 val: B
// a 结束
规则:CJS 循环引用返回的是已执行部分的 module.exports,未执行的导出部分是不可见的。
2.4 require.cache
// 查看所有缓存
console.log(Object.keys(require.cache));
// 删除缓存(强制重新加载模块)
delete require.cache[require.resolve('./module.js')];
const freshModule = require('./module.js');
// 缓存 key 是模块的绝对路径
require.resolve('./foo.js') // → /Users/.../foo.js
2.5 运行时加载特性
CJS 的 require 是运行时加载:
// 条件加载(运行时决定)
let lib;
if (process.env.NODE_ENV === 'production') {
lib = require('./prod-lib');
} else {
lib = require('./dev-lib');
}
// 动态 key
const lang = getLanguage();
const i18n = require(`./i18n/${lang}`);
// 变量路径(运行时解析,Tree Shaking 无法分析)
const name = 'module-' + getVersion();
const mod = require('./' + name);
3. ES Module
3.1 静态编译时分析
ESM 的 import/export 必须在模块顶层声明,不能嵌套在条件块中:
// ✅ 顶层声明
import { readFile } from 'fs';
// ❌ 语法错误:不能条件导入
if (true) { import { a } from './a'; }
// ❌ 语法错误:不能在函数内
function load() { import { b } from './b'; }
静态分析的意义:
- 构建工具在编译时即可确定模块依赖图,无需执行代码
- 支持 Tree Shaking:标记未使用的导出,安全删除
- 支持循环引用的正确解析(比 CJS 更可靠)
- 支持
import()代码分割(构建时已知分割点)
3.2 导入导出语法
具名导入导出:
// export
export const a = 1;
export function fn() {}
export class Klass {}
export { a as alias };
// import
import { a, fn, Klass } from './module';
import { a as renamed } from './module';
import * as All from './module'; // 命名空间导入
默认导入导出:
export default function() {} // 匿名导出
export default { a: 1, b: 2 }; // 对象默认导出
import anyName from './module'; // 默认导入(任意命名)
import defaultExport, { named } from './module';
动态 import:
// import() 返回 Promise
const module = await import('./lazy-module');
const { namedExport } = await import('./foo');
// 模板变量路径
const page = 'home';
const PageModule = await import(`./pages/${page}.js`);
import.meta:
// 当前模块的元信息
console.log(import.meta.url); // 模块文件 URL
console.log(import.meta.resolve); // 解析模块路径(实验性)
// Vite 环境变量
const isDev = import.meta.env.DEV;
3.3 浏览器端
<!-- 标记 type="module" -->
<script type="module">
import { createApp } from './app.js';
createApp();
</script>
<!-- nomodule 降级 -->
<script type="module" src="main.js"></script>
<script nomodule src="main-legacy.js"></script>
ESM 在浏览器中的行为:
defer是默认行为(延迟执行至文档解析完毕)- 每个模块只执行一次(防止重复副作用)
- CORS 限制:跨域模块需要服务端设置
Access-Control-Allow-Origin - 裸模块路径需要处理(浏览器不认识
import 'lodash')
3.4 ESM 与 CJS 互操作
// 从 CJS 导入 ESM(Node 环境)
// cjs-module.js
module.exports = { a: 1 };
// esm-module.mjs
import cjs from './cjs-module'; // ✅ 默认导入获取 module.exports
import { a } from './cjs-module'; // ✅ 具名导入由 Node 静态分析确定
__esModule 兼容:
// 被 Babel/webpack 转译后的 CJS 导出 ESM 时:
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = {};
// 互操作逻辑:
// - 如果检测到 __esModule 标记,default 导出需要 .default 访问
// - 如果未标记,整个 module.exports 当作 default 导出
// - webpack 的 interopRequireDefault 辅助函数实现此逻辑
// webpack 生成的 interop 辅助函数
function interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
CJS 如何加载 ESM:
// Node 22+ 通过 createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const esm = await require('./esm-module.mjs'); // ❌ 错误: require 不能加载 ESM
// ✅ 正确:使用动态 import()
async function loadEsm() {
const esm = await import('./esm-module.mjs');
return esm;
}
4. 动态 import 与代码分割
4.1 import() 语义
const module = await import('./async-module.js');
// 返回的 module 等同于 import * as module from '...'
// 包含: module.default, module.namedExport
作为代码分割触发器的原理:
- Webpack/Rollup 在编译时看到
import()调用,自动将其作为一个 分割点(split point) - 被
import()引用的模块(及其依赖)被提取为独立的 async chunk - 运行时通过 JSONP / 动态 script 标签加载该 chunk
4.2 预加载指令
// webpack 魔法注释
import(/* webpackChunkName: "admin" */ './admin.js');
import(/* webpackPrefetch: true */ './analytics.js');
import(/* webpackPreload: true */ './critical-lib.js');
// 等价于添加 <link rel="prefetch" href="...">
| 指令 | 加载时机 | 优先级 | 场景 |
|---|---|---|---|
prefetch |
浏览器空闲时加载 | 低 | 下个页面需要 |
preload |
父 chunk 加载时并行加载 | 高 | 当前页面即将需要 |
5. 包管理
5.1 npm
语义版本
^1.2.3 → >=1.2.3 <2.0.0(允许兼容更新)
~1.2.3 → >=1.2.3 <1.3.0(只允许补丁版本)
1.2.3 → =1.2.3(精确版本)
* → 任意版本
| 符号 | 锁定范围 | 安全风险 | 适用场景 |
|---|---|---|---|
^ |
大版本不变 | npm audit 扫描补丁漏洞 | 大多数依赖 |
~ |
中间版本不变 | 低 | 严格兼容要求 |
| 精确 | 固定版本 | 最低 | 生产关键依赖 |
* |
不锁定 | 最高 | 开发期试验 |
package-lock.json
lockfileVersion: 2
├── packages["node_modules/react"]:
│ version: 18.2.0
│ resolved: https://registry.npmjs.org/react-18.2.0.tgz
│ integrity: sha512-...
│ dependencies:
│ loose-envify: ^1.1.0 ← 递归锁定传递依赖
└── ...
作用:
- 锁定所有依赖的精确版本(包括传递依赖)
- 确保团队成员和 CI 环境安装的依赖树完全一致
- 通过
integrity字段校验包完整性
hoisting(依赖提升)
项目依赖: A → B@1.0, C → B@2.0
无 hoisting: hoisting 后:
node_modules/ node_modules/
├── A/ ├── B@1.0/ ← 提升
│ └── node_modules/ ├── A/
│ └── B@1.0/ └── C/
└── C/ └── B@2.0/ ← 嵌套保留
└── B@2.0/
幽灵依赖(Phantom Dependency):
// package.json 未声明 dependency,但通过 hoisting 可以 require 到
const react = require('react'); // 如果 react 被其他依赖提升到顶层
这是 hoisting 算法引入的最大问题:你可以使用未在 package.json 中声明的依赖,导致项目隐式依赖、可复现性受损。
5.2 yarn
workspaces
// package.json(根目录)
{
"private": true,
"workspaces": ["packages/*"]
}
packages/
├── ui/ → package.json name: @myapp/ui
├── server/ → package.json name: @myapp/server
└── web/ → package.json name: @myapp/web
安装后:
node_modules/
├── @myapp/ui/ → 软链接到 packages/ui
└── react/ → hoisted
Workspaces 解决的问题:
- 复用一个
node_modules,节省磁盘空间 - 通过软链接实现本地包间引用(开发时实时同步)
npx命令在所有 workspace 中执行
Plug'n'Play(PnP)
yarn install 后不生成 node_modules,而是:
├── .pnp.cjs ← JavaScript 文件,包含依赖查找表
└── .pnp.data.json ← 元数据
PnP 原理:
// .pnp.cjs 简化版
const locatorToZip = new Map([
['lodash@4.17.21', '/path/to/.yarn/cache/lodash-4.17.21.zip'],
]);
function resolveToUnqualified(packageName, issuer) {
const locator = findLocator(packageName);
return `${locatorToZip.get(locator)}/node_modules/${packageName}/`;
}
PnP 优点:
- 无
node_modules,安装速度提升 70%+ - 使用 Zip 存储,极大减少文件数(几万个文件 → 几十个 zip)
- 严格依赖声明:未在 package.json 声明的包无法
require(幽灵依赖消除) - 更快的模块解析:不需要 I/O 操作遍历文件夹
PnP 代价:
- 兼容性问题:某些工具和原生模块需要额外适配
- IDE 需要集成 PnP SDK
- 调试时无法直接查看 node_modules 源码
5.3 pnpm
核心架构:硬链接 + 软链接
全局存储 ~/.pnpm-store/
└── v3/files/00/... (content-addressable)
项目 node_modules/
├── .pnpm/ ← 内部目录
│ ├── react@18.2.0/node_modules/
│ │ ├── react → 硬链接到 store
│ │ └── loose-envify → 软链接到 .pnpm/loose-envify@1.4.0
│ └── loose-envify@1.4.0/node_modules/
│ └── loose-envify → 硬链接到 store
└── react → 软链接到 .pnpm/react@18.2.0/node_modules/react
分层架构:
三层结构:
1. Store(硬盘):content-addressable,所有版本只存一份
2. .pnpm(项目):嵌套的 node_modules 结构,将每个版本的依赖分别装好
3. 顶层(项目):软链接到 .pnpm 中的具体版本
为什么能消除幽灵依赖?
传统 npm: node_modules/react ← 可以 require
pnpm: node_modules/react ← 软链接到 .pnpm/react@18.2.0
但子依赖的依赖不会在顶层出现
只有 package.json 声明的依赖在 node_modules/ 顶层
pnpm 的 node_modules 中顶层只有 package.json 声明过的依赖,而非 hoisting 后所有传递依赖,因此『未声明无法导入』。
磁盘空间优化
# 同一个包的不同版本在 store 中只存一次差异
pnpm store status # 检查 store 完整性
pnpm store prune # 清理未被引用的包
- 硬链接:文件在磁盘上只有一份物理 copy,所有项目指向同一 inode
- 内容寻址:文件以内容 hash 命名,确保唯一性
- 跨项目共享:不同项目使用同一个包的相同版本时,共享 store
5.4 Bun
架构:Zig 编写 + JavaScriptCore 引擎。
Bun 包管理 = npm registry 客户端 + 全局缓存 + 极致并行(256 并发请求)
├── 安装速度: 比 npm 快 30 倍
├── 无 lockfile 争用: 使用 bun.lockb(二进制格式)
└── 原生 TypeScript: 安装时无需 tsc 编译
为什么快:
- Zig 可直接操控内存分配和系统调用,无 GC 停顿
- 256 个并发 HTTP 请求下载包
- 使用 mmap 加速包文件解压
- 全局缓存策略(与 pnpm 类似但更激进)
- 内置 JSX/TypeScript 解析,无需额外转译层
5.5 Bun 的原生 TypeScript 支持
bun run index.ts # 直接运行 TypeScript,无需 ts-node
bun build ./src/index.ts --outdir ./dist
bun test # 内置 test runner(兼容 Jest API)
内部实现:Bun 使用 Zig 实现的 TypeScript 解析器(基于 swc 的 Rust bindings),解析速度是 tsc 的 20 倍+,但只做语法转译(transpile),不做类型检查(type-check)。
6. Monorepo
6.1 工具对比
| 工具 | 构建缓存 | 增量计算 | 依赖图调度 | 核心语言 |
|---|---|---|---|---|
| npm workspaces | 无 | 无 | 无 | JS |
| Yarn workspaces | 无 | 无 | 无 | JS |
| Lerna | 无 | 无 | 拓扑排序 | JS |
| Turborepo | LRU + 远程 | 文件 hash | 任务依赖图 | Go |
| Nx | LRU + 远程 | 文件 hash + 计算缓存 | 任务依赖图 + 项目图 | TS/Go |
| Rush | 本地缓存 | 基于 pnpm | 拓扑排序 | TS |
| Bazel | 内容寻址 + 远程 | 细粒度 | 构建图(沙盒) | Java/Starlark |
6.2 Turborepo 核心原理
turbo run build --cache-dir=.turbo
每次任务执行:
1. 计算输入 hash(源文件 hash + 环境变量 + 配置文件)
2. 检查本地缓存(.turbo/cache/)
3. 未命中 → 执行 → 产物 hash 存入缓存
4. 命中 → 从缓存恢复产物(replay)
远程缓存: 多 CI 共享缓存(S3/Vercel)
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"], // 依赖包的 build 任务
"inputs": ["src/**/*.ts"], // 影响 hash 的文件
"outputs": ["dist/**"], // 缓存产物
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.ts", "test/**/*.ts"],
},
}
}
为什么 Turborepo 比 Lerna 快:
- Go 实现,直接系统调用
- 细粒度的 hash-based 缓存(而非全量重新执行)
- 任务级并行(根据依赖拓扑自动确定并行度)
6.3 Nx 的核心差异化
Nx 比 Turborepo 多了:
├── 项目图(Project Graph):基于 AST 分析的依赖关系(自动发现)
├── affected 命令:根据 git diff 自动计算受影响的包
├── 计算缓存 + 分布式任务执行
└── 代码生成器(CLI 脚手架)
nx affected:test --base=main # 只测试受本次提交影响的项目
nx graph # 可视化项目依赖图
依赖图自动推导:
Nx 通过分析 package.json / tsconfig 路径映射 / import 语句
自动构建出完整的项目依赖图,用于增量构建和 affected 检测