Skip to content

第 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 没有包管理器会怎样?

想象你做一个项目用到了 lodashaxiosdayjs。如果没有 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 对比

维度npmyarn (classic v1)pnpm
出现年份201020162017
安装速度慢(早期串行)快(并行)最快(链接复用)
磁盘占用大(每个项目一份完整副本)小(全局存储 + 硬链接)
依赖结构扁平化(v3+ 改善)扁平化嵌套(更严格,避免幽灵依赖)
Monorepo 支持通过 workspaces通过 workspaces原生最佳支持
lockfilepackage-lock.jsonyarn.lockpnpm-lock.yaml
命令风格npm install xxxyarn add xxxpnpm 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别人安装你的包时一起装reactaxioslodash把构建工具误放这里会让用户多下几十 MB
devDependencies别人安装你的包时不会vitetypescripteslintvitest业务代码运行时用到的库不能放这里
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 深度对比

维度CommonJSES 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 三步搭建

  1. 根目录建 pnpm-workspace.yaml
yaml
packages:
  - 'packages/*'
  - 'apps/*'
  1. 目录结构:
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
  1. 跨包引用:
bash
pnpm add @myorg/ui --filter @myorg/web --workspace

Turborepo 简介:构建任务编排器。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-updatespnpm outdated 定期检查依赖更新
  • [ ] 用 pnpm why <pkg> 排查依赖来源
  • [ ] 大型项目考虑 Monorepo + pnpm workspace + Turborepo
  • [ ] 不要把 node_modules 提交到 git
  • [ ] 危险版本符号 * 永远不要用

8. 一句话总结

包管理器是前端的"外卖平台",模块化是代码的"分区收纳",lockfile 是保证团队所有人吃到一模一样口味的那张"小票"——三者合起来撑起了现代前端工程化的根基。


9. 延伸阅读