Skip to content

Node.js 的模块化、包管理与依赖生态 ​

前端讨论 Node.js,几乎绕不开模块化和包管理。

因为现代前端工程里,下面这些高频动作几乎都和它有关:

  1. 安装依赖
  2. 运行脚本
  3. 解析包入口
  4. 加载模块
  5. 锁定依赖版本

更具体一点,这条主线真正要回答的是:代码如何被拆成模块,依赖如何被声明和安装,包管理工具又如何把这些模块稳定地组织起来。

1. 模块和包分别在说什么 ​

这两个词很容易混在一起,但它们不是一回事。

1.1 什么是模块 ​

模块可以看成 一个有自己边界、可以导入导出的代码单元。

例如:

  1. 一个 js 文件
  2. 一个 ts 文件
  3. 一个通过 import 或 require 暴露能力的代码片段

模块更关注的是:

  1. 代码怎么拆
  2. 模块之间怎么依赖
  3. 模块怎么加载

1.2 什么是包 ​

包可以看成 一组可被发布、安装、复用的模块集合。

它通常至少会包含:

  1. 代码文件
  2. package.json
  3. 版本信息
  4. 对外暴露方式

例如一个发布到 npm 仓库的库,本质上就是一个包。

所以更具体一点:

  1. 模块 更偏代码组织单位
  2. 包 更偏发布、安装和复用单位

2. Node.js 里的模块化在解决什么问题 ​

模块化真正解决的不是“会不会写 require 或 import”,而是:

  1. 代码如何拆分
  2. 依赖如何声明
  3. 模块如何加载
  4. 包如何对外暴露能力

如果没有模块化,项目一旦变大,就很容易出现:

  1. 全局变量污染
  2. 文件顺序难维护
  3. 复用边界不清
  4. 项目结构越来越乱

3. CommonJS 和 ESM 是两条什么主线 ​

3.1 CommonJS ​

Node.js 早期主要使用 CommonJS。

典型写法:

js
const fs = require('node:fs')

module.exports = {
  fs,
}

它更偏:

  1. 运行时加载
  2. require
  3. module.exports

3.2 ESM ​

现代 Node.js 也支持 ES Module。

典型写法:

js
import fs from 'node:fs'

export const name = 'demo'

它更偏:

  1. 静态结构
  2. import/export
  3. 更适合现代工具链分析

3.3 两者差异的核心不只是语法 ​

很多人会把 CommonJS 和 ESM 的区别理解成:

  1. 一个用 require
  2. 一个用 import

这还不够。

更具体一点,它们还会影响:

  1. 模块解析方式
  2. 包入口声明方式
  3. 工具链如何分析依赖
  4. 同一个包在不同环境下如何暴露能力

4. package.json 到底在控制什么 ​

package.json 是 Node.js 项目的核心元信息文件之一。

它不只是依赖列表,更像是在回答:

  1. 这个项目叫什么
  2. 当前版本是什么
  3. 入口文件是什么
  4. 有哪些脚本命令
  5. 依赖如何分类
  6. 模块格式如何声明
  7. 这个项目由哪个包管理器维护

可以看一个简化示例:

json
{
  "name": "demo-project",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "vue": "^3.5.0"
  },
  "devDependencies": {
    "vite": "^5.4.0"
  },
  "engines": {
    "node": ">=18"
  },
  "packageManager": "pnpm@9.0.0"
}

5. package.json 里最常见的字段怎么理解 ​

package.json 的字段非常多,但工程上最该先掌握的是那些真正决定项目行为和协作边界的字段。

5.1 项目身份相关字段 ​

字段含义
name包名或项目名
version当前版本号,通常遵循语义化版本
private是否禁止发布到 npm,前端业务项目里很常见
description项目简介
license开源协议声明

其中:

  1. private: true 很重要
  2. 对于普通前端业务项目,通常会加上它

因为它能减少误发布到公共仓库的风险。

5.2 入口和发布相关字段 ​

字段含义
mainCommonJS 入口文件
module历史上常见的 ESM 入口约定,更多是工具链习惯
exports更现代、更明确的包导出声明
typesTypeScript 类型声明入口
bin命令行可执行入口
files发布到 npm 时允许包含的文件列表

这里最容易混的是 main、module、exports。

可以这样理解:

  1. main 更偏传统入口声明
  2. module 更多是历史工具链约定,不是最核心标准字段之一
  3. exports 是现代包更推荐的显式导出方式

5.3 模块格式相关字段 ​

字段含义
type默认把当前包内 .js 按什么模块格式解释
sideEffects告诉打包器哪些文件可能有副作用,影响 Tree Shaking

其中 type 很关键:

  1. type: "commonjs" 表示默认按 CommonJS 理解
  2. type: "module" 表示默认按 ESM 理解

所以它影响的不只是语法风格,还会影响 Node.js 怎么解释同名扩展文件。

5.4 脚本相关字段 ​

最常见的是:

字段含义
scripts定义项目里的命令脚本

例如:

json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "test": "vitest"
  }
}

这意味着你可以通过下面这些命令调用对应脚本:

bash
npm run dev
npm run build
pnpm lint
yarn test

5.5 依赖分类相关字段 ​

字段含义
dependencies运行时真正要依赖的包
devDependencies开发、构建、测试阶段使用的包
peerDependencies由使用方提供的宿主依赖或同级依赖
optionalDependencies安装失败也不一定阻断项目的可选依赖

这几个字段的边界特别值得单独理解。

dependencies ​

更偏项目运行时真正需要的依赖。

例如:

  1. 前端业务代码直接运行时依赖的库
  2. 服务端应用启动后必须存在的库

devDependencies ​

更偏开发阶段、测试阶段、构建阶段使用的依赖。

例如:

  1. vite
  2. webpack
  3. eslint
  4. typescript
  5. vitest

peerDependencies ​

它最容易被误解。

可以看成 这个包需要和宿主项目共享某个依赖版本,但它自己不想私自装一份。

在插件、组件库、框架扩展里很常见。

例如一个 React 组件库,常常会把 react 放在 peerDependencies,因为它希望:

最终使用方项目自己提供 React,而不是库内部偷偷再装一份。

optionalDependencies ​

它适合那些:

  1. 有更好
  2. 没装上也不想让整个安装流程直接失败

的依赖场景。

5.6 环境约束相关字段 ​

字段含义
engines限定 Node.js、npm 等运行环境版本
packageManager显式声明项目推荐使用的包管理器及版本

例如:

json
{
  "engines": {
    "node": ">=18"
  },
  "packageManager": "pnpm@9.0.0"
}

这类配置的意义在于:

  1. 降低团队环境不一致的概率
  2. 避免有人用错包管理器
  3. 减少锁文件混乱和安装结果漂移

6. 为什么 package.json 不是“装完依赖就不用看”的文件 ​

很多人刚接触前端工程时,会把 package.json 理解成:安装依赖时顺手生成出来的一个配置文件。

这其实会低估它的重要性。

更具体一点,它至少承担了 4 类职责:

  1. 项目元信息描述
  2. 依赖分类与版本范围声明
  3. 构建和开发脚本入口
  4. 模块格式与发布边界说明

所以它更像:Node.js 项目的工程入口说明书。

7. 锁文件为什么重要 ​

锁文件不是附属文件,它是在描述:这次安装时,项目最终到底解析出了哪些具体依赖版本。

如果只有 package.json,你通常只能知道:

  1. 依赖名字是什么
  2. 允许的版本范围是什么

但你未必知道:

  1. 最终装到磁盘上的到底是哪个精确版本
  2. 依赖的依赖又被解析成了什么版本

锁文件就是在补这个精确结果。

8. package-lock.json、yarn.lock、pnpm-lock.yaml 分别是什么 ​

这三个文件本质上都属于:包管理器用于锁定依赖树的结果文件。

但它们分别对应不同工具。

8.1 package-lock.json ​

它通常对应:

  1. npm

它的作用是:

  1. 锁定 npm 安装后的精确依赖树
  2. 让团队和 CI 更容易复现同一套安装结果

8.2 yarn.lock ​

它通常对应:

  1. Yarn

它的作用和锁文件本质目标相同,也是:

  1. 锁定依赖解析结果
  2. 提高环境一致性

8.3 pnpm-lock.yaml ​

它通常对应:

  1. pnpm

它记录的是 pnpm 解析出来的精确依赖结果,配合 pnpm 的依赖存储和链接机制一起工作。

8.4 为什么一个项目通常只该保留一种锁文件 ​

因为锁文件和包管理器是绑定的。

如果同一个项目里同时混着:

  1. package-lock.json
  2. yarn.lock
  3. pnpm-lock.yaml

往往说明:

  1. 团队成员在混用不同包管理器
  2. 安装结果可能开始漂移
  3. CI 和本地环境可能越来越不一致

🌟 更稳的做法通常是:一个项目选定一种主要包管理器,并保留与之对应的那一种锁文件。

9. 包管理器安装依赖时到底发生了什么 ​

看一条简化主线:

mermaid
flowchart TD
    A[读取 package.json] --> B[解析依赖范围]
    B --> C[结合锁文件确定精确版本]
    C --> D[从远端仓库获取包]
    D --> E[写入本地依赖目录和缓存]
    E --> F[生成或更新锁文件]

这条流程最重要的意思是:

  1. package.json 决定你想要什么
  2. 锁文件决定你最终拿到了什么
  3. 包管理器负责把这个过程稳定地执行出来

10. npm、yarn、pnpm 的共同点是什么 ​

这几个工具虽然风格不同,但核心目标是一样的:

  1. 安装依赖
  2. 管理锁文件
  3. 运行脚本
  4. 帮助团队保持依赖一致性

你日常最常见的命令,本质上也很接近:

bash
npm install
yarn
pnpm install

它们都在尝试完成一件事:把项目声明的依赖,稳定地还原到当前开发环境里。

11. npm、yarn、pnpm 的核心差异在哪里 ​

差异主要集中在:

  1. 依赖安装策略
  2. 磁盘占用方式
  3. 依赖边界是否严格
  4. monorepo 支持体验
  5. 团队协作时的稳定性和使用习惯

12. npm 的特点、优缺点 ​

npm 是 Node.js 官方生态里最基础、最默认的包管理工具。

12.1 优点 ​

  1. 默认可用,几乎所有 Node.js 环境都自带
  2. 学习成本低,社区资料最丰富
  3. 和 npm registry 生态天然贴合
  4. 对中小项目来说足够直接

12.2 缺点 ​

  1. 历史上安装速度和磁盘利用率并不总是最优
  2. 对依赖边界的约束相对没那么严格
  3. 在大型 monorepo 场景里,很多团队会更倾向 pnpm

12.3 更适合什么场景 ​

  1. 小中型项目
  2. 团队希望尽量减少额外工具心智负担
  3. 追求“默认就能跑”的协作方式

13. Yarn 的特点、优缺点 ​

Yarn 最早流行起来的一个重要原因,是它在一段时间里明显改善了 npm 早期的一些体验问题。

13.1 优点 ​

  1. 历史上安装体验和速度曾明显优于早期 npm
  2. 锁文件机制较早被大量前端团队接受
  3. 脚本执行体验简洁
  4. 在部分团队里仍有较深历史积累

13.2 缺点 ​

  1. Yarn 生态内部又分 Classic 和 Berry,团队认知成本可能上升
  2. 不同版本之间行为差异较大时,迁移和维护心智会增加
  3. 在今天的新项目里,很多团队会直接在 npm 和 pnpm 之间做选择

13.3 更适合什么场景 ​

  1. 历史项目已经稳定使用 Yarn
  2. 团队已有成熟 Yarn 工作流
  3. 不希望因为更换包管理器引入额外迁移成本

14. pnpm 的特点、优缺点 ​

pnpm 在现代前端工程里越来越常见,尤其是在大型项目和 monorepo 里。

14.1 优点 ​

  1. 磁盘复用效率高,安装多个项目时更省空间
  2. 安装速度通常表现不错
  3. 依赖边界更严格,能更早暴露错误依赖关系
  4. 对 monorepo 和 workspace 体验友好

14.2 缺点 ​

  1. 对刚入门的同学来说,依赖结构和链接机制理解成本更高
  2. 某些依赖如果偷偷依赖未声明包,迁移到 pnpm 时更容易暴露问题
  3. 极少数历史工具链对它的兼容细节可能需要额外处理

14.3 更适合什么场景 ​

  1. 中大型前端项目
  2. monorepo
  3. 团队希望更严格约束依赖边界
  4. 希望降低重复安装带来的磁盘浪费

15. 一张对比表 ​

维度npmYarnpnpm
默认可得性很高需要额外约定需要额外约定
学习成本低中中
历史积累最深很深越来越高
磁盘利用率一般一般更好
依赖边界严格性一般中更强
monorepo 体验可用可用更常被优先考虑
适合场景通用项目历史 Yarn 项目中大型项目与 monorepo

如果压缩成一句话,可以这样判断:

  1. 想要默认、直接、少折腾,npm 很自然
  2. 历史项目已经稳定用 Yarn,就没必要为了换而换
  3. 新项目、团队项目、monorepo,pnpm 往往更有吸引力

16. 工程上怎么选包管理器 ​

真正选择时,通常不只是看“谁更快”,而是一起看:

  1. 团队现有习惯
  2. 项目规模
  3. 是否是 monorepo
  4. 是否希望更严格约束依赖边界
  5. 是否能接受迁移成本

16.1 小项目怎么选 ​

如果是普通单体前端项目:

  1. npm 完全够用
  2. pnpm 也可以直接用

16.2 团队项目怎么选 ​

如果是多人协作项目,更重要的是:

  1. 尽早统一一种工具
  2. 明确锁文件策略
  3. 在 package.json 里写清楚 packageManager

16.3 monorepo 怎么选 ​

如果是 monorepo,现代前端团队里更常见的倾向通常是:

  1. 优先考虑 pnpm

因为它在工作区管理和依赖边界上往往更省心。

17. 工程里最常见的误区 ​

17.1 误把 package.json 当成纯依赖清单 ​

它其实同时还在描述:

  1. 脚本入口
  2. 模块格式
  3. 发布边界
  4. 环境约束

17.2 误以为锁文件可有可无 ​

如果随意忽略锁文件,就很容易出现:

  1. 本地安装结果不一致
  2. CI 和开发机结果不一致
  3. 问题难复现

17.3 一个项目混用多个包管理器 ​

这通常不是灵活,而是混乱来源。

17.4 只会区分 dependencies 和 devDependencies ​

但一遇到组件库、插件系统、宿主依赖场景,就会被 peerDependencies 绕住。

17.5 只会装包,不理解包入口和模块格式 ​

这样一到:

  1. ESM / CommonJS 兼容问题
  2. 包导出问题
  3. 工具链解析问题

就很容易陷入“会用但讲不清”的状态。

18. 一份检查清单 ​

理解 Node.js 模块化和包管理时,至少可以自查:

  1. 当前项目是 CommonJS 还是 ESM
  2. package.json 里哪些字段真正影响运行、构建和发布
  3. 当前依赖应该放进 dependencies、devDependencies,还是 peerDependencies
  4. 当前项目到底约定用 npm、yarn,还是 pnpm
  5. 锁文件是否和选定的包管理器保持一致
  6. 团队成员和 CI 是否在同一套 Node.js 与包管理器版本上工作

19. 小结 ​

如果把这一篇压缩成几句话,可以记住:

  1. 模块化解决的是代码如何拆分和加载
  2. 包管理解决的是依赖如何安装、锁定和复用
  3. package.json 是 Node.js 项目的工程入口说明书,不只是依赖清单
  4. package-lock.json、yarn.lock、pnpm-lock.yaml 都是在锁定精确依赖树
  5. npm、yarn、pnpm 没有绝对赢家,关键是按项目规模和团队协作方式选对工具

如果只压缩成一句话,可以记住:

Node.js 的模块系统、package.json、锁文件和包管理器,共同构成了现代前端工程依赖管理的基础设施。

基于 VitePress 构建的个人技术笔记。