主题
第 16 章 · 包管理与模块化
一句话开篇:本章你将学会前端项目"怎么管依赖、怎么拆代码、怎么把别人写好的轮子拿来用"。
0. 生活类比(先建立直觉)
0.1 包管理器 = 外卖平台
想象你开了一家奶茶店,缺了几样东西:
- 缺糖浆 → 找供应商(npm 仓库)
- 缺杯子 → 找供应商
- 缺奶茶机 → 找供应商
每次去市场跑一趟太累了,于是你装了一个"外卖平台 App"——这就是包管理器(npm / yarn / pnpm)。
- 你只需要打开 App 输入要的东西名字(
npm install lodash),它就帮你下载并放到家里的"仓库"(node_modules文件夹) - 它还记得你点过什么(
package.json),下次换个地方开店(新电脑 / 同事克隆代码),照着清单一键复购
0.2 模块化 = 厨房分区
一个大厨房如果只有一张大桌子,所有东西混在一起,找一把刀都要翻半天。聪明的做法是分区:
- 切配区:负责切菜
- 烹饪区:负责炒菜
- 出餐区:负责装盘
每个区域只做一件事,互相通过"传菜口"协作。这就是模块化——把代码按功能拆成独立文件,通过 import / export 互相调用。
0.3 lockfile = 餐厅点单确认单
你点了"奶茶(甜度 7 分、加椰果)",服务员复述并打印一张小票确认。下次你换一家店,把小票递过去,做出来的奶茶口味一模一样。
package-lock.json / pnpm-lock.yaml 就是这张"小票",锁定每个依赖的精确版本和子依赖关系,保证你电脑上跑得通的代码,同事电脑上也跑得通。
1. 概念是什么
1.1 包(Package)
一个被打包发布到 npm 仓库的代码单元。最小形式 = 一个文件夹 + package.json。
1.2 包管理器(Package Manager)
帮你下载、安装、升级、卸载包的工具。三大主流:
- npm:Node.js 自带,最广泛
- yarn:Facebook 出品,并行安装
- pnpm:硬链接复用,省磁盘、Monorepo 神器
1.3 模块化(Module)
把一个大文件拆成多个小文件,每个文件对外暴露接口(export),文件之间通过 import / require 互相引用。
主流规范:
- CommonJS(CJS):Node.js 默认(
require/module.exports),同步加载 - ES Modules(ESM):浏览器和现代 Node.js 标准(
import/export),异步加载、可静态分析
2. 为什么需要它
2.1 没有包管理器会怎样?
想象你做一个项目用到了 lodash、axios、dayjs。如果没有 npm:
- 手动去每个库的 GitHub 下载源码,复制粘贴到项目里
- 每次要升级,再去 GitHub 下载一遍
- 升级 A 库,发现它依赖 B 库的某个版本,B 库又依赖 C 库……手动梳理一棵依赖树
✅ 有了 npm,一行命令搞定:npm install lodash axios dayjs
2.2 没有模块化会怎样?
早期 jQuery 时代,所有 JS 全靠 <script> 一个个引入:
html
<script src="jquery.js"></script>
<script src="util.js"></script>
<script src="login.js"></script>
<script src="dashboard.js"></script>问题:
- 全局污染:所有变量都在
window上,谁覆盖谁谁都不知道 - 依赖混乱:
login.js依赖util.js,必须放在它后面,顺序错了直接崩 - 按需加载难:所有 JS 都要全量下载
✅ 有了模块化,每个文件作用域独立,依赖关系明确,构建工具能自动按需打包。
3. 怎么用(最小示例)
3.1 包管理器:3 个核心命令
bash
npm init -y # 初始化一个 package.json
npm install lodash # 安装一个包到 dependencies
npm install -D vite # 安装到 devDependencies(仅开发用)
npm uninstall lodash # 卸载
npm run dev # 运行 package.json 里 scripts.dev 命令3.2 ESM 模块化最小示例
js
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14;
export default function multiply(a, b) { return a * b; }js
// main.js
import multiply, { add, PI } from './math.js';
console.log(add(1, 2), PI, multiply(3, 4));3.3 CommonJS 最小示例(Node.js 默认)
js
// math.js
function add(a, b) { return a + b; }
module.exports = { add, PI: 3.14 };js
// main.js
const { add, PI } = require('./math.js');
console.log(add(1, 2), PI);4. 进阶要点
4.1 npm vs yarn vs pnpm 对比
| 维度 | npm | yarn (classic v1) | pnpm |
|---|---|---|---|
| 出现年份 | 2010 | 2016 | 2017 |
| 安装速度 | 慢(早期串行) | 快(并行) | 最快(链接复用) |
| 磁盘占用 | 大(每个项目一份完整副本) | 大 | 小(全局存储 + 硬链接) |
| 依赖结构 | 扁平化(v3+ 改善) | 扁平化 | 嵌套(更严格,避免幽灵依赖) |
| Monorepo 支持 | 通过 workspaces | 通过 workspaces | 原生最佳支持 |
| lockfile | package-lock.json | yarn.lock | pnpm-lock.yaml |
| 命令风格 | npm install xxx | yarn add xxx | pnpm add xxx |
| 全局存储 | 无 | 无 | ~/.pnpm-store |
| 适合场景 | 简单项目、教学 | 旧项目维护 | 现代项目、Monorepo |
选型建议(2026 年):新项目首选 pnpm;如果团队没有迁移意愿,npm v10+ 也够用了;yarn classic 已基本被取代。
4.2 pnpm 为什么省磁盘?
传统 npm/yarn:每个项目都把 lodash 完整复制一份
~/projects/A/node_modules/lodash (1.5MB)
~/projects/B/node_modules/lodash (1.5MB) ← 重复
~/projects/C/node_modules/lodash (1.5MB) ← 重复
pnpm:全局存一份,项目里只是硬链接
~/.pnpm-store/v3/.../lodash (1.5MB) ← 唯一一份
~/projects/A/node_modules/lodash → 硬链接
~/projects/B/node_modules/lodash → 硬链接
~/projects/C/node_modules/lodash → 硬链接4.3 package.json 字段全景
json
{
"name": "my-app", // 包名(npm 上唯一)
"version": "1.0.0", // 版本号(语义化)
"type": "module", // "module"=ESM, "commonjs"=CJS
"main": "dist/index.cjs", // CJS 入口(老项目)
"module": "dist/index.mjs", // ESM 入口(打包器优先)
"types": "dist/index.d.ts", // TS 类型入口
"exports": { // 现代多入口(优先级最高)
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": "./dist/utils.mjs"
},
"scripts": { // 自定义命令,npm run xxx
"dev": "vite",
"build": "vite build",
"test": "vitest"
},
"dependencies": { // 生产依赖(npm install 默认装)
"react": "^18.2.0"
},
"devDependencies": { // 开发依赖(构建/测试工具)
"vite": "^5.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": { // 同行依赖(宿主项目必须装)
"react": ">=16.8.0"
},
"engines": { // 限定 Node 版本
"node": ">=18"
}
}4.4 dependencies vs devDependencies vs peerDependencies
| 类型 | 谁会安装 | 典型例子 | 易错点 |
|---|---|---|---|
dependencies | 别人安装你的包时会一起装 | react、axios、lodash | 把构建工具误放这里会让用户多下几十 MB |
devDependencies | 别人安装你的包时不会装 | vite、typescript、eslint、vitest | 业务代码运行时用到的库不能放这里 |
peerDependencies | 别人安装你的包时会警告让用户自己装 | 写 React 组件库时声明 react 为 peer | 防止你的库和用户项目装出两份 React |
4.5 语义化版本号(SemVer)
格式:MAJOR.MINOR.PATCH,例如 2.5.3
- MAJOR(主版本):不兼容的 API 变动,升级要小心
- MINOR(次版本):向下兼容的功能新增,可以放心升
- PATCH(修订):向下兼容的 bug 修复,安全升级
package.json 里的版本范围符号:
| 符号 | 含义 | 示例 ^1.2.3 允许的范围 |
|---|---|---|
^ | 锁定 MAJOR,允许 MINOR/PATCH 升级 | >=1.2.3 <2.0.0 |
~ | 锁定 MAJOR.MINOR,只允许 PATCH | >=1.2.3 <1.3.0 |
* | 任意版本(极不推荐) | 任何版本 |
1.2.3 | 精确锁定 | 只能 1.2.3 |
>=1.2.3 | 大于等于 | 任意更高版本 |
特殊:当 MAJOR=0(如
^0.5.0),^会退化为只允许 PATCH,因为 0.x 视为不稳定 API。
4.6 lockfile 的作用
没有 lockfile 时:
你的电脑:装 react ^18.2.0 实际装到 18.2.0
半年后同事:装 react ^18.2.0 实际装到 18.3.5(升级了)
→ 出现"在你电脑上能跑,在我电脑上崩"的诡异 bug有 lockfile 时:
package.json 写:react ^18.2.0
package-lock.json 锁:react@18.2.0(精确)
→ 任何人 npm ci 都装到完全一样的版本✅ 铁律:lockfile 必须提交到 git!
4.7 CommonJS vs ESM 深度对比
| 维度 | CommonJS | ES Modules |
|---|---|---|
| 加载方式 | 同步 | 异步 |
| 引入语法 | const x = require('x') | import x from 'x' |
| 导出语法 | module.exports = {} | export default {} / export {} |
| 解析时机 | 运行时 | 编译时(静态分析) |
顶层 await | ❌ 不支持 | ✅ 支持 |
| Tree Shaking | ❌ 困难 | ✅ 友好 |
| 浏览器原生支持 | ❌ | ✅(<script type="module">) |
| 文件扩展名 | .cjs 或 .js(type 为 commonjs) | .mjs 或 .js(type 为 module) |
| 循环依赖 | 返回未完成的 exports | 返回 live binding |
| 动态导入 | require(变量) 任意位置 | import('xxx') 返回 Promise |
4.8 模块加载流程图
ESM 加载流程(浏览器/现代 Node):
┌──────────┐
│ 解析阶段 │ → 静态分析所有 import,构建依赖图(不执行代码)
└────┬─────┘
│
┌────▼─────┐
│ 加载阶段 │ → 并行下载所有依赖文件
└────┬─────┘
│
┌────▼─────┐
│ 链接阶段 │ → 建立模块间的 live binding(绑定地址,不是值)
└────┬─────┘
│
┌────▼─────┐
│ 执行阶段 │ → 按依赖顺序执行模块代码
└──────────┘
CJS 加载流程(Node 默认):
require('xxx')
│
▼
找到文件 → 同步读取 → 执行 → 缓存 module.exports → 返回
│
▼(遇到嵌套 require)
重复上述过程(同步阻塞)4.9 动态导入
js
// ESM 动态导入:代码分割、按需加载的核心
button.onclick = async () => {
const { heavyFunc } = await import('./heavy-module.js');
heavyFunc();
};
// CJS 中 require 本身就是动态的
if (process.env.NODE_ENV === 'production') {
const logger = require('./logger-prod');
}4.10 Monorepo 是什么
Monorepo = 一个 Git 仓库管理多个包。例子:Vue 仓库里同时有 @vue/runtime-core、@vue/compiler-sfc、@vue/server-renderer 等几十个子包。
好处:
- 共享配置(ESLint、TS 配置、CI)
- 跨包修改一次 commit 完成
- 依赖联调不用
npm link
pnpm workspace 三步搭建:
- 根目录建
pnpm-workspace.yaml:
yaml
packages:
- 'packages/*'
- 'apps/*'- 目录结构:
my-monorepo/
├── package.json # 根 package.json
├── pnpm-workspace.yaml
├── packages/
│ ├── ui/
│ │ └── package.json # name: @myorg/ui
│ └── utils/
│ └── package.json # name: @myorg/utils
└── apps/
└── web/
└── package.json # 引用 @myorg/ui、@myorg/utils- 跨包引用:
bash
pnpm add @myorg/ui --filter @myorg/web --workspaceTurborepo 简介:构建任务编排器。pnpm 解决"装"的问题,Turborepo 解决"跑(构建/测试)"的问题——智能缓存 + 并行执行 + 跨机器分布式缓存。
json
// turbo.json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"], "outputs": [] }
}
}5. 实战案例
详见 examples/ 目录:
package.json— 完整带注释的示例cjs-example.js— CommonJS 写法esm-example.mjs— ESM 写法pnpm-workspace.yaml— Monorepo 工作区配置dynamic-import.html— 浏览器动态导入示例
6. ⚠️ 易踩的坑
坑 1:把构建工具放进 dependencies
json
// ❌ 错误:用户安装你的包会被迫下载几十 MB 的 vite
"dependencies": { "vite": "^5.0.0" }
// ✅ 正确
"devDependencies": { "vite": "^5.0.0" }坑 2:忘记把 lockfile 提交到 git
.gitignore 里如果误加了 package-lock.json,团队成员每次安装都可能装到不同版本,引发"在我电脑上没问题"事件。
坑 3:CJS 和 ESM 混用 import/require
js
// ❌ Node 报错:require is not defined(在 ESM 中)
// my.mjs
const fs = require('fs');
// ✅ 在 ESM 中要这样写
import fs from 'node:fs';
// ✅ 在 ESM 中确实需要 require?用 createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);坑 4:误用 ^ 导致升级到不兼容版本
某些库(特别是 0.x 版本和不严格遵守 semver 的库)会在 minor 升级里引入破坏性更改。生产项目建议:
- 关键依赖锁精确版本(去掉
^) - 或在 CI 里运行
npm ci(严格按 lockfile)而非npm install
坑 5:exports 字段一旦写了,会屏蔽 main
json
{
"main": "./dist/index.js",
"exports": {
".": "./dist/index.mjs"
}
}⚠️ 此时 require('my-pkg') 走 exports,./dist/index.js 完全失效。exports 是"白名单"——没列出来的子路径无法被外部 import。
坑 6:node_modules 里出现"幽灵依赖"
js
// 你只在 package.json 装了 axios
// 但 axios 依赖了 follow-redirects
// 在 npm/yarn 里你能直接 import follow-redirects(不报错!)
import followRedirects from 'follow-redirects'; // 居然能用
// 一旦 axios 升级换了依赖,你的代码就崩✅ pnpm 的嵌套结构默认禁止"幽灵依赖",安全多了。
坑 7:循环依赖
a.js: import b from './b.js'
b.js: import a from './a.js'ESM 还能勉强用 live binding 工作,CJS 经常拿到 undefined。最佳实践:发现循环依赖立即重构,抽出公共部分到第三个模块。
坑 8:__dirname 在 ESM 里不存在
js
// ❌ ESM 中:__dirname is not defined
console.log(__dirname);
// ✅ ESM 中替代写法
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));7. 最佳实践 Checklist
- [ ] 使用 pnpm 作为包管理器(除非团队约定其他)
- [ ] lockfile 必须提交 到 git
- [ ] CI 中使用
pnpm install --frozen-lockfile而非pnpm install - [ ] 区分清楚
dependencies/devDependencies/peerDependencies - [ ] 写库时使用
peerDependencies声明宿主框架(如 React) - [ ] 新项目
package.json设置"type": "module",使用 ESM - [ ] 写 npm 包时使用
exports字段提供 ESM + CJS 双入口 - [ ] 用
npx npm-check-updates或pnpm outdated定期检查依赖更新 - [ ] 用
pnpm why <pkg>排查依赖来源 - [ ] 大型项目考虑 Monorepo + pnpm workspace + Turborepo
- [ ] 不要把
node_modules提交到 git - [ ] 危险版本符号
*永远不要用
8. 一句话总结
包管理器是前端的"外卖平台",模块化是代码的"分区收纳",lockfile 是保证团队所有人吃到一模一样口味的那张"小票"——三者合起来撑起了现代前端工程化的根基。