← 返回

code /

Node.js 包管理:从单个包到 Workspace

本文从 Node.js 项目为什么需要包管理器出发,依次介绍单个包、依赖、脚本、本地命令、锁文件和 Workspace。示例同时给出 npm 与 pnpm 的常用写法,但一个项目只应选择其中一个,并只提交对应的锁文件。

本文以 Node.js 24 LTS、npm 11 和 pnpm 11 为基线。Node.js、npm 与 pnpm 分别发布,行为并不总是同步;遇到与本机不一致的地方,应先确认三者版本,再查看对应版本的官方文档。

包管理器解决什么问题

只有一个 JavaScript 文件时,可以直接运行 node index.js。前端项目变大后,还需要下载框架和构建工具、固定依赖版本、执行测试与打包命令,并协调多个应用和共享库。如果这些规则只存在于口头说明或全局环境中,其他人很难复现项目。

npm 和 pnpm 将这些规则记录在项目中。它们主要负责:

  • 读取和更新 package.json
  • 从 registry 解析并下载依赖;
  • 维护可复现安装所需的锁文件;
  • 将项目内工具暴露为可执行命令;
  • 运行 scripts 中约定的开发、测试和构建任务;
  • 管理包含多个 package 的 Workspace。

包管理器并不是 JavaScript 编译器,也不会自行决定怎样处理 TypeScript、JSX、CSS 或图片。Vite、Rsbuild、TypeScript、ESLint 和测试框架等工具负责具体工作,npm 或 pnpm 负责安装并调用它们。这一点与同时负责依赖解析和 Rust 构建流程的 Cargo 不完全相同。

六个基本概念

Node.js 工具链经常同时出现 runtime、package、registry、dependency、script 和 workspace。它们描述不同层次。

  • Node.js 是运行 JavaScript 和工具命令的 runtime。Node.js 通常附带 npm,但 pnpm 是独立发布的工具。
  • Package 是由一个 package.json 描述的源码或发布单元。应用、组件库和命令行工具都可以是 package。
  • Registry 是发布和下载 package 的服务。默认通常是 npm registry,但 package manager 与 registry 不是同一个事物。
  • Dependency 是当前 package 声明需要的另一个 package;依赖还会继续拥有自己的间接依赖。
  • Scriptpackage.json 中命名的命令入口,例如 devbuildtest
  • Workspace 将多个 package 组织在一起,使安装、锁文件和批量命令可以统一管理。

继续查阅:package.json 字段见 npm package.json;pnpm 与 npm 的设计差异见 pnpm vs npm

选择并固定包管理器

npm 通常随 Node.js 一同安装。pnpm 可以由操作系统的包管理机制安装,也可以在 Corepack 可用时启用。Corepack 是否随 Node.js 提供取决于 Node.js 版本和发行方式:Node.js 25 起不再附带 Corepack,因此不要假定每台机器都有 corepack 命令。

先查看本机实际版本:

node --version
npm --version
pnpm --version

团队项目应明确只使用一种 package manager。支持 packageManager 字段的工具还能据此识别项目期望的工具及精确版本:

{
  "name": "my-project",
  "private": true,
  "packageManager": "pnpm@11.23.0"
}

版本号只是示例,真实项目应填写团队实际验证过的版本。该字段是重要提示,但不是所有环境都会自动执行它;CI 和开发环境仍应显式提供相同版本。

选择 npm 时提交 package-lock.json,选择 pnpm 时提交 pnpm-lock.yaml。不要在同一项目中交替运行两种工具并同时维护两份锁文件。

继续查阅:Corepack 的版本范围和启用方式见 Node.js Corepack;pnpm 的安装方式见 pnpm Installation

管理单个 Package

每个 Node.js package 都以 package.json 为配置入口。它描述包本身、依赖和命令,但源代码目录、构建入口与产物位置通常由具体工具决定,而不是由 npm 或 pnpm 统一规定。

创建项目

用 npm 在当前目录生成配置:

mkdir my-project
cd my-project
npm init -y

使用 pnpm 时:

mkdir my-project
cd my-project
pnpm init

-y 表示 npm 接受默认值。两条初始化命令都只创建 package.json,不会生成源码或测试;下面是需要继续手工创建的一种常见布局:

my-project/
├── package.json
├── src/
│   └── main.js
└── test/
    └── main.test.js

先创建一个最小入口:

console.log("Hello, Node.js!");

一个适合应用项目的最小 package.json 可以写成:

{
  "name": "my-project",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/main.js",
    "test": "node --test"
  }
}

nameversion 标识 package。private: true 防止意外发布应用。type: "module" 表示 .js 文件默认使用 ECMAScript Modules;如果省略,Node.js 对 .js 的默认解释会不同。scripts 保存项目命令。

库项目若准备发布,需要进一步正确配置 exportsfiles、类型声明和发布内容;这些属于 package API 与发布设计,不能只靠删除 private 完成。

运行项目命令

执行脚本时,两种工具的基本形式相同:

npm run start
npm run test
pnpm run start
pnpm run test

starttest 等少数名称在 npm 中有快捷命令,但教程和团队脚本中统一写 run 通常更清楚。pnpm 在名称不与内置命令冲突时也允许省略 run

pnpm test

向脚本后的程序传递参数时,用 -- 明确分隔 package manager 参数和程序参数:

npm run start -- --port 3000
pnpm run start -- --port 3000

开发、测试与生产构建

Node.js package manager 没有 Cargo devrelease profile 的统一对应物。项目通过脚本名称表达任务,再由具体工具解释模式:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  }
}

运行 npm run buildpnpm run build 只是调用本地 Vite。是否压缩、怎样拆包、输出到哪里,都由 Vite 配置决定。dist/ 是常见产物目录,但不是 npm 或 pnpm 的保证。

继续查阅:脚本的精确行为见 npm Scriptspnpm run。具体构建选项应查看实际使用的构建工具文档。

管理依赖

依赖声明写在 package.json,锁文件记录解析后的完整依赖图,node_modules/ 则是安装结果。三者职责不同,不应手工把 node_modules/ 当作依赖清单。

添加与移除依赖

npm 使用 install 添加依赖:

npm install react
npm install --save-dev vite vitest
npm uninstall react

pnpm 使用 add 添加依赖,裸 install 只安装清单中已有的依赖:

pnpm add react
pnpm add --save-dev vite vitest
pnpm remove react

--save-dev 可以缩写为 -D。命令会同时更新 package.json 和锁文件。生成结果大致如下:

{
  "dependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "vite": "^7.0.0",
    "vitest": "^3.0.0"
  }
}

上面的版本仅用于说明字段,不应复制为新项目的固定推荐。添加依赖时让 package manager 根据当前 registry 信息写入版本范围,再检查结果是否符合项目要求。

依赖类型

最常见的依赖字段有:

  • dependencies:应用运行或库的公开实现正常工作所需的依赖;
  • devDependencies:只用于开发、测试、检查和构建的工具;
  • peerDependencies:库要求最终使用者同时提供的兼容依赖,例如组件库对 React 的要求;
  • optionalDependencies:安装失败时允许继续的可选依赖,使用方必须处理它不存在的情况。

应用使用的运行时库通常放在 dependencies,而 Vite、ESLint 和测试框架通常放在 devDependencies。不要因为构建工具会参与生成生产产物,就把它误判为应用运行时依赖。

peerDependencies 主要用于发布库,不是减少应用安装项的通用手段。包管理器会如何安装和校验 peer dependency 与其版本有关;设计库的 peer 范围时,应按支持矩阵测试,而不是设置一个未经验证的宽范围。

版本范围与实际版本

package.json 中常见的 SemVer 范围不是精确锁定:

  • ^1.2.3 通常允许更新到兼容的 1.x 版本;
  • ~1.2.3 通常允许更新到 1.2.x
  • 1.2.3 表示直接依赖要求该精确版本,但间接依赖仍由完整依赖图决定。

package.json 表达允许范围,锁文件记录本次解析出的精确版本与来源。初学时应让添加命令生成常见范围;需要改变策略时再查询 SemVer,而不是随意删除 ^ 或把所有依赖写成 latest

锁文件与可复现安装

npm 维护 package-lock.json,pnpm 维护 pnpm-lock.yaml。两者都应由 package manager 自动更新并提交到版本控制,通常不应手工编辑。

本地开发安装:

npm install
pnpm install

CI 中应要求清单与锁文件一致,并禁止静默改写锁文件:

npm ci
pnpm install --frozen-lockfile

npm ci 要求已有锁文件,会先移除现有 node_modules/,不更新 package 清单或锁文件。pnpm 的 CI 默认行为可能随版本与环境变化,显式写出 --frozen-lockfile 更容易表达流水线意图。

升级依赖是一次需要审查的项目变更。升级后应阅读锁文件 diff,并运行项目的测试、类型检查和构建,而不是只确认安装命令成功。

npm 与 pnpm 的安装结构

npm 通常会提升依赖,让许多 package 出现在根 node_modules/。pnpm 使用内容寻址 store、硬链接和符号链接构造隔离的依赖图,项目根通常只直接暴露已声明的依赖。

因此,一段代码在 npm 下可能偶然导入某个间接依赖,在 pnpm 下却报“找不到模块”。正确修复通常是把实际使用的 package 声明为当前 package 的直接依赖,而不是依赖 npm 的提升结果,也不是立即把 pnpm 改成完全平铺模式。

node_modules/ 是可重新生成的安装产物,不应提交到版本控制。pnpm 的全局 store 也不是项目锁文件,不能代替 pnpm-lock.yaml

继续查阅:npm 安装见 npm install;pnpm 添加和安装见 pnpm addpnpm install;pnpm 的链接结构见 Symlinked node_modules structure

使用项目内工具

把 CLI 工具安装到项目的 devDependencies,可以让团队和 CI 使用同一版本,而不是依赖每个人机器上不同的全局安装。

脚本自动找到本地命令

当脚本执行 viteeslintvitest 时,npm 和 pnpm 会将项目的可执行目录加入 PATH

{
  "devDependencies": {
    "eslint": "^9.0.0"
  },
  "scripts": {
    "lint": "eslint ."
  }
}

因此只需运行:

npm run lint
pnpm run lint

不需要把 ESLint 全局安装,也不应在脚本里写某台机器上的绝对路径。

直接执行命令

需要临时调试脚本之外的命令时,可以使用:

npm exec -- eslint --version
pnpm exec eslint --version

pnpm exec 在项目环境中执行已安装依赖提供的命令。npm exec 会优先寻找本地命令,但本地不存在时还可以从 cache 或 registry 获取 package 后执行;它不是“仅本地执行”的可靠边界。若必须保证工具已经由项目声明,应先把它加入 devDependencies,再通过 scripts 调用。

pnpm 在命令名不与内置命令冲突时还允许写成 pnpm eslint --version,但显式 exec 更容易区分 package manager 命令与依赖提供的命令。

下载后一次性执行

如果工具尚未加入项目,又只需执行一次,可以使用:

npm exec --package create-vite -- create-vite my-app
pnpm dlx create-vite my-app

npx 是 npm 提供的常用兼容入口,但它和 npm exec 的参数解析细节并不完全相同。文档和自动化脚本中优先使用语义明确的 npm exec

一次性执行仍会运行从 registry 获取的代码。应确认 package 名、版本和来源;对项目长期依赖的生成器或检查工具,更适合加入 devDependencies 并通过脚本调用。

安装脚本与供应链边界

依赖可能在安装期间声明 preinstallinstallpostinstall 等生命周期脚本。npm 与 pnpm 不同版本对依赖构建脚本的默认策略并不相同;当前 pnpm 会要求显式批准部分依赖脚本,并提供 pnpm approve-builds 等机制。

看到“build scripts were ignored”一类提示时,不应盲目允许全部脚本。先确认是哪个依赖、脚本为何必要,再按当前 pnpm 文档配置允许或忽略列表。CI 中应提交并审查这类策略,使开发机和流水线保持一致。

继续查阅:命令执行见 npm execpnpm execpnpm dlx;pnpm 构建脚本批准见 pnpm approve-builds

使用 Workspace

当 Web 应用、组件库和内部工具分别成为 package 后,各自独立安装会产生多份锁文件和重复配置。Workspace 让 package manager 共同解析它们,并把本地 package 链接起来。

Workspace 常用于 Monorepo,但两者不是同一个概念:Monorepo 描述多个项目存放在同一仓库,Workspace 描述 package manager 如何共同管理其中一组 package。

虚拟项目结构

下面的项目包含一个应用和两个共享 package:

my-project/
├── package.json
├── package-lock.json       # 使用 npm 时存在
├── pnpm-lock.yaml          # 使用 pnpm 时存在
├── pnpm-workspace.yaml     # 使用 pnpm 时存在
├── apps/
│   └── web/
│       ├── package.json
│       └── src/main.js
└── packages/
    ├── ui/
    │   ├── package.json
    │   └── src/index.js
    └── config/
        └── package.json

图中同时列出两种工具的文件只是为了对照。真实项目应保留 package-lock.json,或者保留 pnpm-lock.yamlpnpm-workspace.yaml,不能把两套配置混合使用。

根 package 通常不发布,并保存跨成员运行的命令:

{
  "name": "my-project",
  "private": true,
  "scripts": {
    "build": "echo run workspace builds",
    "test": "echo run workspace tests"
  }
}

用 npm 声明 Workspace

npm 在根 package.json 中使用 workspaces

{
  "name": "my-project",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

packages/ui/package.json 声明自己的名称和版本:

{
  "name": "@example/ui",
  "version": "0.1.0",
  "type": "module"
}

apps/web/package.json 可以用满足本地 package 版本的普通 SemVer 范围引用它:

{
  "name": "@example/web",
  "private": true,
  "dependencies": {
    "@example/ui": "^0.1.0"
  }
}

npm 安装时会识别并链接匹配的 Workspace package。npm 不使用 pnpm 的 workspace: 协议,因此不要把 pnpm 示例原样复制到 npm 项目。

在根目录安装全部成员:

npm install

给指定成员添加依赖:

npm install react --workspace @example/web

运行单个成员或全部成员的脚本:

npm run dev --workspace @example/web
npm run build --workspaces --if-present
npm test --workspaces --if-present

用 pnpm 声明 Workspace

pnpm 使用根目录的 pnpm-workspace.yaml 选择成员:

packages:
  - "apps/*"
  - "packages/*"

根 package 总是属于 Workspace,即使 packages 没有显式包含根目录。成员 package 可以用 workspace: 协议要求依赖必须来自当前 Workspace:

{
  "name": "@example/web",
  "private": true,
  "dependencies": {
    "@example/ui": "workspace:^"
  }
}

workspace:^ 表示链接本地 package,并在发布时转换为相应的 caret 版本范围。应用通常不会发布,但显式协议仍能防止本地 package 不匹配时悄悄改从 registry 下载。

在根目录安装全部成员:

pnpm install

给指定成员添加依赖:

pnpm --filter @example/web add react

运行单个成员或递归运行脚本:

pnpm --filter @example/web run dev
pnpm --recursive --if-present run build
pnpm --recursive --if-present run test

--filter 不只支持 package 名,还能按路径、依赖关系和变更范围选择 package。复杂过滤器应先阅读文档并用无副作用命令确认选择结果,避免对错误的一组成员批量修改依赖。

共享依赖版本

npm 与 pnpm 的 Workspace 都会共享根锁文件,但“锁文件中只有一个版本”并不意味着每个成员声明了相同范围。成员仍应只声明自己真实使用的依赖。

pnpm 还提供 catalog,可以在 pnpm-workspace.yaml 中集中命名常用版本:

packages:
  - "apps/*"
  - "packages/*"

catalog:
  react: ^19.0.0
  typescript: ^5.8.0

成员使用 catalog: 引用:

{
  "dependencies": {
    "react": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:"
  }
}

Catalog 适合确实需要统一维护的版本,不应成为把所有依赖集中到根目录的理由。每个成员的 package.json 仍然是判断其直接依赖的入口。

继续查阅:npm Workspace 见 npm Workspaces;pnpm Workspace、过滤和 catalog 见 pnpm WorkspacesFilteringCatalogs

在 CI 中安装与验证

可靠的流水线应从仓库中的清单和锁文件重新安装,而不是复用开发机的 node_modules/。最小流程通常是:

# npm 项目
npm ci
npm run lint
npm test
npm run build

或者:

# pnpm 项目
pnpm install --frozen-lockfile
pnpm run lint
pnpm test
pnpm run build

Workspace 可以改为前文的 --workspaces--recursive 命令。流水线还应固定 Node.js 与 package manager 版本,并缓存 package manager 的下载缓存或 pnpm store,而不是把 node_modules/ 当作唯一缓存边界。

安装成功只证明依赖图可以落盘,不证明源代码正确。至少应运行项目已经声明的类型检查、静态检查、测试和生产构建;库项目还应验证打包结果和公开 API。

遇到问题时怎么查

npm、pnpm、Node.js 和构建工具各有自己的版本。排错时先判断问题发生在哪一层,再进入对应文档,比反复删除锁文件更可靠。

从版本与本机帮助开始

node --version
npm --version
pnpm --version

npm help install
npm help run-script
pnpm help install
pnpm help recursive

本机帮助与已安装 CLI 一致,适合确认参数名称。网上文章可能针对另一个大版本,复制命令前应先核对版本。

观察依赖图

npm 常用:

npm ls
npm explain react
npm outdated

pnpm 常用:

pnpm list
pnpm why react
pnpm outdated

lslist 查看安装图,explainwhy 回答“为什么安装了这个 package”,outdated 查看可更新项。重复版本不必然是错误;先检查各成员和间接依赖的版本范围是否允许统一。

按现象定位

  • 找不到模块:先确认实际导入它的 package 是否在自己的 package.json 中声明了直接依赖。
  • 锁文件不一致:使用项目指定的 package manager 更新依赖并提交锁文件,不要在 CI 中关闭冻结检查。
  • npm 可以、pnpm 不行:检查是否依赖了被 npm 提升但未声明的 package,以及依赖安装脚本是否被 pnpm 拦截。
  • 脚本找不到命令:确认工具已加入当前 package 的依赖,并通过 runexec 进入项目环境。
  • Workspace 改错成员:确认当前目录和 --workspace--filter 的实际选择范围。
  • 构建行为不一致:继续检查 Vite、TypeScript、测试框架等具体工具版本;package manager 只负责调用它们。

删除 node_modules/ 后重新安装可以验证安装结果是否可重建,却不会自动修复错误的依赖声明。删除锁文件会重新选择整张依赖图,可能引入更多变化,不应作为没有分析的第一步。

选择正确的官方资料

  • npm CLI 文档:查询 npm 命令、package.json、锁文件和 Workspace;
  • pnpm 文档:从 Motivation 了解设计,再进入 CLI、Settings 和 Workspace;
  • Node.js API 文档:查询模块系统、runtime 行为和 Corepack;
  • 实际构建工具的官方文档:查询 devbuild、产物目录和配置文件。

最后可以保留一个简单顺序:先确认 Node.js 与 package manager 版本,再阅读本机 help;依赖问题查看锁文件和依赖图,命令问题查看 scripts,构建问题进入真正执行构建的工具文档。这样无需把所有 CLI 参数背下来,也能知道下一步应去哪里验证。