Skip to content

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

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

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

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

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

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

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

1.1 什么是模块

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

例如:

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

模块更关注的是:

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

1.2 什么是包

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

它通常至少会包含:

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

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

所以更具体一点:

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

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

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

  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 两者差异的核心不只是语法

很多人会把 CommonJSESM 的区别理解成:

  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 时允许包含的文件列表

这里最容易混的是 mainmoduleexports

可以这样理解:

  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.jsonyarn.lockpnpm-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. npmyarnpnpm 的共同点是什么

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

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

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

bash
npm install
yarn
pnpm install

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

11. npmyarnpnpm 的核心差异在哪里

差异主要集中在:

  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. 在今天的新项目里,很多团队会直接在 npmpnpm 之间做选择

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 只会区分 dependenciesdevDependencies

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

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

这样一到:

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

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

18. 一份检查清单

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

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

19. 小结

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

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

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

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

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