包管理工具
1 package.json
1.1 概念
package.json
是 Node.js/前端工程的核心配置文件。它是一个 JSON 文件,用来 描述项目的基本信息、依赖清单、脚本命令和工程配置。
相当于项目的“说明书”和“依赖管理账本”。
1.2 作用
项目说明:描述项目名称、版本、作者、许可证等。
依赖管理:记录项目运行/开发所需的第三方库及版本。
脚本管理:定义常用命令(npm run build、npm run test 等)。
模块解析:指定包的入口文件、类型声明文件等。
工程配置:为构建工具、测试工具、编译器等提供内联配置。
团队协作:结合 lock 文件,保证在不同机器安装依赖的一致性。
1.3 常见的字段
1.3.1 基本信息
name
:包名,发布到 npm 时的唯一标识。version
:遵循语义化版本号 (semver)。description
:描述信息。author
:作者信息。license
:许可证。private
:是否为私有包,true 时禁止发布到 npm。
1.3.2 依赖管理
dependencies
:生产环境依赖。devDependencies
:开发环境依赖(构建、测试工具等)。peerDependencies
:宿主项目必须安装的依赖(多用于库开发)。optionalDependencies
:可选依赖,安装失败不影响整体。bundledDependencies
:发布时打包进去的依赖。
INFO
👉 npm 的包通常需要遵从 semver 版本规范,semver 版本规范是 X.Y.Z:
- X 主版本号(major):当你做了不兼容的 API 修改(可能不兼容之前的版本)
- Y 次版本号(minor):当你添加了向下兼容的新功能(新功能增加,但是兼容之前的版本)
- Z 修订号(patch):当你修复了向下兼容的问题(没有新功能,修复了之前版本的 bug)
^1.2.3
:允许更新次版本和补丁版本(1.x.x)。
~1.2.3
:允许更新补丁版本(1.2.x)。
1.2.3
:锁定版本。
*
:允许任意版本。
1.3.3 脚本
scripts
:定义可运行命令。
{
"scripts": {
"start": "node index.js",
"build": "webpack",
"test": "jest"
}
}
运行方式:npm run build
/ yarn build
/ pnpm build
。
INFO
内置的 4 个命令(start、test、restart、stop) → 可以直接用,不写 run
。
其他脚本(如 build、lint 等) → 必须写 npm run xxx
。
如果写 npm build
,npm 会调用它自己的 内部命令(构建 npm 包),而不是执行 scripts.build
。
👉 所以 npm build 是错的,应该写 npm run build
。
yarn 的命令解析更宽松:
yarn <script>
会优先查找 package.json
里的 scripts
。
找到就执行;没找到才去执行 yarn 自己的命令。
因此 yarn build
✅ 可以直接运行 scripts.build。
yarn run build
也能用,但 run
可以省略。
和 yarn 类似:
pnpm <script>
会直接执行 package.json
里的脚本。
所以 pnpm build
✅ 可以直接运行 scripts.build
。
pnpm run build
也可以,但 run
可以省略。
工具 | 执行 scripts.build 的写法 | 是否可省略 run |
---|---|---|
npm | npm run build ✅ / npm build ❌ | ❌ 不能省略 |
yarn | yarn build ✅ / yarn run build ✅ | ✅ 可以省略 |
pnpm | pnpm build ✅ / pnpm run build ✅ | ✅ 可以省略 |
1.3.4 模块解析
main
:CommonJS 入口文件。module
:ESM 入口(给现代打包器用)。exports
:推荐方式,精细控制导出路径。bin
:命令行工具入口。types
/typings
:TS 类型声明文件入口。
👉 还常见的一个就是:
type:用来声明整个包是 CommonJS 还是 ES Module。
"type": "commonjs"
(默认值)"type": "module"
→ 表示 .js 文件走 ESM 语法,.cjs
文件才是 CJS。
1.3.5 工程配置
engines
:指定 Node.js/npm 的版本。browserslist
:目标浏览器范围(babel/postcss 使用)。工具相关配置:
eslintConfig
、babel
、jest
等,可直接写在package.json
内。
1.4 模块解析流程举例(★)
现在完整的梳理一个引入第三方库的模块解析流程:
用一个具体的例子(import axios from "react"
)说明从 代码写法 → 查找 → 解析 → 最终引入 的全过程。
1.4.1 确认模块名
"react"
是一个 裸模块(bare module specifier),不是路径(如./utils.js
),也不是绝对地址(如https://
)。这类模块解析规则是:去
node_modules
找对应的包。
1.4.2 从 node_modules
开始查找
- Node.js / bundler(如 webpack、Vite)会先在当前项目目录下查找:
./node_modules/react
- 如果没找到,会向上一级目录查找:
../node_modules/react
../../node_modules/react
...
直到找到或到达磁盘根目录。
1.4.3 读取包的 package.json
找到 node_modules/react/
后,会先读取它的 package.json
。 里面有几个关键字段决定用哪个文件作为入口:
- main(CommonJS 默认入口):
{ "main": "index.js" }
- module(ESM 入口,现代打包器优先使用):
{ "module": "index.module.js" }
- exports(推荐写法,可精细控制导出):
{
"exports": {
".": {
"import": "./index.modern.mjs",
"require": "./index.js"
}
}
}
👉 React 实际上在它的 package.json
中写了:
{
"main": "index.js",
"exports": {
".": {
"react-server": "./react.shared-subset.js",
"default": "./index.js"
}
}
}
所以默认会走到 node_modules/react/index.js
。
1.4.4 解析入口文件
入口文件 index.js
可能是一个“转发文件”,它会把核心 API 从别的路径导出,例如:
"use strict";
module.exports = require("./cjs/react.development.js");
这样最终就会定位到 react.development.js
,里面包含 React 的核心实现。
1.4.5 bundler 的优化(如 webpack/Vite)
优先使用 exports
/module
字段 → 选择 ESM 版本,支持 tree-shaking,减少打包体积。
如果没有 exports
/module
,才会回退到 main
字段。
如果 package.json
没写入口,默认尝试 index.js
。
1.4.6 最终加载到代码中
Node.js 环境下 → require('react')
会拿到导出的对象(React API)。
浏览器打包后 → bundler 会把 React 相关代码打进 bundle,供 import React
使用。
🔎 总结为“模块解析流程”
识别模块名(裸模块 → 去 node_modules)。
逐级向上查找 node_modules。
读取对应包的 package.json。
- 优先看 exports 字段。
- 如果没有 exports,再看 module 字段。
- 如果没有 module,再看 main 字段。
定位入口文件。
如果入口再转发,就继续跟进直到真实文件。
最终导出 API 给你的代码使用。
当我写 import React from "react"
时,Node.js 或 bundler 会去当前目录的 node_modules 查找 react 包,读取它的 package.json,根据 exports/module/main
字段确定入口文件。如果入口文件再导出其他内容,则继续解析。最终将 React 的 API 暴露给我的代码。
2 npm
2.1 什么是 npm
npm (Node Package Manager):Node.js 官方的包管理工具。
下载/安装第三方库(如 React、Express)。
管理项目依赖版本。
执行脚本命令(如构建、测试)。
发布/共享自己的包。
其中 node_modules
目录就是 npm
用来局部安装依赖的地方,使得不同的项目可以使用不同版本的包,而不会互相干扰。
- 随着 npm 的快速成长,一些问题也随之而来,比如
node_modules
随着依赖的嵌套,体积越来越大。
2.2 npm 的嵌套依赖模型
在 npm2 及以前,每个包会将其依赖安装在自己的 node_modules 目录下,这意味着每个依赖也会带上自己的依赖,形成一个嵌套的结构,结构如下:
这样的结构虽然解决了版本冲突、依赖隔离等问题,但却有几个致命的缺点:
:每个依赖都会安装自己的依赖,导致了大量的重复,特别是在多个包共享同一依赖的场景下。
:这种嵌套结构在文件系统中造成了非常长的路径,然而大多数 Windows 工具、实用程序和
shell
最多只能处理长达 260 个字符的文件和文件夹路径。一旦超过,安装脚本就会开始出错,而且无法再使用常规方法删除node_modules
文件夹。相关 issue:github.com/nodejs/node…:每次安装或更新依赖时,npm 需要处理和解析整个依赖树,过程非常缓慢。
2.3 npm3 架构升级
为了解决这些问题,npm 在第三个版本进行了重构,引入了扁平化的依赖模型。 通过将依赖扁平化,尽可能地减少重复的包版本,有效减少了项目地总体积,同时也避免了 npm 早期的深层嵌套问题。 扁平化结构如下:
可以看到还是会有一定可能产生嵌套问题,因为根目录只能存放某个包的一个版本,而某个包的依赖可能会有多个版本,所以会产生嵌套问题。
2.4 npm5 架构升级
为了解决 npm3 中的一些问题,npm 在第五个版本进行了重构,引入了 package-lock.json
文件。
这个锁文件确保了依赖的一致性。无论是在哪个环境下运行 npm install
,都能确保安装相同版本的依赖,解决了因版本不匹配导致的问题。
2.5 幽灵依赖
2.5.1 什么是幽灵依赖
新建一个文件夹,在终端初始化项目,执行 npm init -y
,然后安装一个包,比如 lodash
,执行 npm install express
,这个时候node_modules
文件夹下会发现什么呢?如下图所示:
我只安装了express
,为什么 node_modules
下会出现那么多的包呢?那是因为 express
依赖了一些包,而依赖的这些包又会依赖其它包...npm 则是把这些包拍平了放到了 node_modules
下,这也就导致 node_modules
里出现了这么多包。
那么问题又来了,那 node_modules 下的这些包我没有去主动安装我能不导入呢?答案是肯定的。比如新建一个 index.js
,然后导入 node_modules
下的 body-parser
。
import bd from "body-parser";
console.log(bd);
执行一下这个文件(注意:这里需要在 package.json
中加个字段"type": "module"
,这样才能用 es6 语法)
发现是可以使用的,但是我们并没有去安装 body-parser
。而这种依赖包就被称为为 。
2.5.2 幽灵依赖存在哪些问题?
首先幽灵依赖会带来 。 举个例子:
假设 小李 在项目中安装了依赖包 A1,而 A1 又依赖 B1。 后来 小王 在开发时需要使用 B1,发现项目里能直接引用,于是就没有在 dependencies 中显式声明。
某一天,小李将 A1 升级为 A2,而 A2 要么依赖的是新版 B2,要么已经不再依赖 B1。 这样一来:
B1 可能会被移除(因为不再被任何依赖声明)。
或者 B1 被替换为与现有代码不兼容的 B2。
结果就是:小王的代码会因为依赖丢失或版本不兼容而报错。
由于该模块未在 package.json 中显式声明,在其他环境(如测试、生产或其他开发者的机器)部署时,无法自动安装所需模块,导致环境不一致,从而引发运行错误。
未明确声明依赖版本,不同环境可能安装到不同的版本,造成功能在某些环境下无法正常运行,甚至出现不可预期的错误。
开发人员无法通过 package.json 明确了解项目实际依赖,增加了代码理解和维护的难度。
INFO
此时 node_modules
就会有两个一样的 B 包分别在 A 文件和 D 文件下,这样就会造成磁盘空间的浪费。这也就是后面 npm 升级成扁平化包管理的方式的原因(导致幽灵依赖出现)。
那么有没有方案既能解决幽灵依赖,有能节省磁盘空间呢? 答案是肯定的,这时候就要介绍一下大名鼎鼎的包管理器 pnpm 是如何解决这些问题的。
参考链接:
3 pnpm
3.1 什么是 pnpm
pnpm 是一个快速、节省磁盘空间的包管理器。它通过 和 的方式来管理依赖,避免了 npm 中出现的幽灵依赖问题。