# npm 与项目结构：依赖、锁文件和可重现命令

## 目标与前置

你已经能够直接运行模块，本章才引入项目管理。目标不是背熟 npm 子命令，而是回答四个问题：项目声明需要什么，安装器实际选中了什么，团队成员如何执行同一项工作，以及换一台机器后哪些条件仍可能不同。前置是上一章的 ESM 与包边界。业务仍是团队文档与任务助手，但本章不安装任何在线业务依赖，所有实验都能离线完成。

在 Vue 项目里你很可能每天运行 npm scripts，却不一定需要理解这些脚本从哪里取得命令。切换到 Node 开发后，这个细节直接影响后台服务能否在部署机器上启动。开发工具可以在本机全局安装而正常运行，但服务器不会自动拥有你的全局环境。把命令写进项目，并记录相应依赖，才是在描述可交付的工作方式。

## 四层模型：运行时、包管理器、声明与安装结果

Node 是执行 JavaScript 的运行时，npm 是处理包和项目命令的工具。两者通常一起安装，版本却分别存在。Node 版本决定可以使用哪些运行时 API；npm 版本影响安装解析、锁文件格式和命令行为。看到一个包的版本号，不能因此推断当前 Node 或 npm 的版本。排错时至少分别记录三者。

`package.json` 是项目声明，里面的依赖范围表示允许的选择，不一定是实际选择。`package-lock.json` 记录安装解析得到的依赖树及相关信息；`node_modules` 是当前机器上展开后的安装结果。声明、锁定和安装目录可能不一致，例如修改声明后尚未安装，或手动改动了安装目录。理解这三份状态，才能解释为什么仅查看 package.json 还不足以重现环境。

锁文件不是把所有环境差异抹平的魔法文件。操作系统、CPU 架构、Node 版本、原生扩展的编译环境、可选依赖和安装脚本都可能影响运行结果。它解决的主要问题是依赖选择的一致性，而不是保证所有平台上的二进制内容完全相同。团队应同时记录支持的运行环境，并在目标环境验证必要的启动与功能。

## package.json 中真正需要先认识的字段

`name` 标识包，`version` 表示项目版本，`private: true` 可以防止这个项目被 npm 意外发布。`type: module` 明确 `.js` 的模块解释方式。本教材仍使用 `.mjs`，是为了让单文件从项目中拿出来也能运行。`scripts` 是名称到命令字符串的映射，脚本名由项目约定，不要求叫 dev、build 或 start 才有意义。

`dependencies` 通常放运行交付物所需的包，`devDependencies` 通常放构建、检查和本地开发工具。分类依据是交付方式，而不是“写业务代码时有没有 import”。一个编译工具若只在构建阶段使用，通常属于开发依赖；构建产物运行时还会加载的驱动，则不能只放在开发依赖中。`npm ci --omit=dev` 这样的安装方式会把错误分类暴露出来。

`engines` 可以声明所支持的 Node 版本范围，但不要默认它会强制阻止所有不满足条件的安装；具体执行行为还受 npm 配置影响。运行时自己对关键 API 或版本作检查，配合 CI 环境设置，才能形成实际约束。版本约束也不应该无限写宽，只因为目前三个小实验没有用到新功能；它应来自你愿意维护和验证的版本范围。

## npm scripts 是一层可共享的命令接口

`npm run <name>` 从项目的 scripts 中寻找名称，再交给平台 shell 执行，最终把子命令的退出状态反馈给调用方。npm 会把本地依赖的可执行目录加入脚本 PATH，因此脚本中通常可以直接写项目安装的工具名，不必全局安装。这个便利也解释了为何某个命令在 npm script 中能找到，在普通终端中却找不到。

脚本命令仍然经过 shell，不是结构化函数调用。Windows 与类 Unix shell 的变量展开、引号和文件命令存在差异。若脚本开始包含复杂分支、路径处理和跨平台文件操作，写一个 `.mjs` 文件，再由 script 调用它，通常更容易检查。不要把未经验证的用户文本拼接成 shell 命令，后面进程章节会继续解释这种边界。

给业务脚本传参时，使用 `npm run name -- argument` 把后面的参数交给脚本。脚本本身仍然通过 `process.argv` 读取字符串。脚本名称旁的 `pre<name>` 和 `post<name>` 是生命周期约定，可能导致一次命令运行多个脚本。因此查看项目启动行为时，要同时检查对应前后置脚本，不能只看到一行 start 就认为没有其他动作。

## 实验一：构建一个零依赖项目命令

环境 Node 22.22.0、npm 10.9.4；新建空练习目录，把两个文件放在一起。不需要执行在线安装。下面的 JSON 是完整项目配置，不包含注释，因为 JSON 不接受 JavaScript 风格注释。

```json package.json
{
  "name": "team-assistant-foundations",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.22.0" },
  "scripts": {
    "check": "node project-check.mjs",
    "syntax": "node --check project-check.mjs"
  }
}
```

```js project-check.mjs
import assert from 'node:assert/strict';
import process from 'node:process';

const args = process.argv.slice(2);
assert.ok(args.length <= 1, '最多接收一个团队名');
const team = args[0] ?? 'demo';
assert.match(team, /^[a-z][a-z0-9-]{0,19}$/);
console.log(`检查通过：${team}`);
```

```powershell
npm.cmd --version
npm.cmd run syntax
npm.cmd run check
npm.cmd run check -- alpha
```

预期语法检查无错误，后两条分别在 npm 自身的脚本提示后打印 `检查通过：demo` 和 `检查通过：alpha`。脚本的业务输出并不是终端全部输出，因为 npm 也可能打印正在执行的命令；若另一程序需要稳定读取 JSON，通常应直接调用对应 Node 入口，或明确处理 npm 的输出设置。

逐段解释：配置把命令名称固定下来，团队成员不必记住入口文件的具体路径；代码把参数约定变成断言，失败会使 Node 以非零状态退出；`--check` 只检查语法，不执行业务代码，因此它无法证明参数校验正确，也无法证明导入目标在运行时一定可用。语法检查和实际运行是不同证据，不能相互替代。

## 依赖版本范围与锁定结果

常见的语义化版本由主版本、次版本和修订版本组成。版本范围表达的是你愿意接收的更新区间，不能等同于“更新一定兼容”。例如主版本大于零时，插入符范围常用来允许同主版本的后续更新；零主版本有更谨慎的规则。项目中已经有锁文件时，具体安装通常首先受锁定结果影响，而不是每次都重新选最新允许版本。

直接依赖是你在项目中声明的包，传递依赖是这些包继续依赖的包。一个只写了几个直接依赖的项目，可能实际安装很多包。审查依赖时不仅要看数量，还要看包承担的职责、维护情况、安装时是否执行脚本、运行时是否引入原生绑定等。不要把“零依赖”当成所有场景的最佳方案；成熟库可能比手写复杂协议更可靠，但要知道自己引入了什么。

固定精确版本也不等于不需要锁文件。依赖自己的依赖仍可能使用范围，安装树还有去重与解析问题。对于应用项目，通常把锁文件纳入版本控制，让修改依赖的人同时提交声明与解析变化。代码审查时，异常大的锁文件变化可能提示 npm 版本差异或解析策略变化，应先解释原因，不要机械接受。

## 实验二：离线生成并检查锁文件

继续使用实验一的目录。因为没有任何外部依赖，以下命令不需要从注册表下载包。命令显式关闭审计和资助提示，并禁止安装脚本，目的是把实验限制在项目元数据。不要把这些选项误读成真实项目永远应该关闭的功能。

```powershell
npm.cmd install --package-lock-only --ignore-scripts --offline --audit=false --fund=false
node inspect-lock.mjs
npm.cmd ci --ignore-scripts --offline --audit=false --fund=false
npm.cmd run check -- beta
```

运行前先保存下面的完整 `inspect-lock.mjs`。它只读取本实验生成的锁文件，不修改其他文件。

```js inspect-lock.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

const text = await readFile(new URL('./package-lock.json', import.meta.url), 'utf8');
const lock = JSON.parse(text);
assert.equal(lock.name, 'team-assistant-foundations');
assert.equal(lock.packages[''].version, '1.0.0');
assert.ok(Number.isInteger(lock.lockfileVersion));
console.log(`锁文件已记录根项目，格式版本=${lock.lockfileVersion}`);
```

在 npm 10.9.4 中预期格式版本为三，最后打印 `检查通过：beta`。其他受支持 npm 版本的具体提示文字可能不同，实验断言关注项目身份与锁文件结构。`readFile` 在找不到文件时拒绝，`JSON.parse` 在内容不是合法 JSON 时同步抛错；顶层 await 将读取完成与后续解析串联，不能在文件生成之前运行检查入口。

`npm ci` 适合根据已有锁文件进行干净安装，要求声明与锁文件满足其一致性条件。它不会像日常安装那样帮你顺手更新锁文件，并且会移除现有 node_modules 后重新安装。因此本教材只在新建的空练习目录演示，不要求你对正在工作的项目随意执行。依赖树若使用了影响解析的配置，团队也需要保持这些配置一致。

离线选项不代表任何项目都能离线安装。有外部依赖时，所需内容还必须已经存在本地缓存中。这个实验能离线成功，是因为根本没有外部包，而不是因为 npm 可以凭空重建所有依赖源码。缓存是下载内容的复用层，不是项目依赖声明的替代品；手动清缓存也不该成为遇到任意错误的第一反应。

## 从本地开发到服务部署

团队助手在开发时可能需要热重载、格式检查和测试工具，部署时只需要构建产物及运行依赖。先写清交付物到底是源码还是构建结果，再决定服务器需要哪些脚本。如果 start 脚本依赖一个仅在本地全局安装的工具，部署机器会找不到它；如果构建发生在服务器却省略了开发依赖，构建也会失败。这些不是 npm 随机故障，而是阶段边界没有定义。

环境变量属于运行配置，不应该通过把本机密钥写进 package.json 来共享。脚本里写固定密钥会进入版本历史，也会随着项目传播。对普通配置可以提供非敏感示例，对真正的凭据则使用部署环境的秘密管理机制。本章没有连接外部服务，因此所有示例不需要任何凭据。

依赖安装过程也能执行代码。生命周期脚本可能编译原生模块、下载二进制或完成其他初始化。`--ignore-scripts` 会阻止这些脚本，但有些包因此不能工作；选择这个选项后应知道自己跳过了什么，并验证运行能力。包存在于 node_modules 只能证明文件被安装，不能证明安装后行为完整。

出现问题时按层定位：先执行 `node --version` 和 `npm.cmd --version`，再确认目录中读到的是哪个 package.json，随后查看完整错误与锁文件差异。网络错误、权限错误、引擎不匹配、脚本退出非零和模块解析失败需要不同处理。重复安装若不改变任何前提，通常只会重复失败，而不会产生新的诊断信息。

## 版本环境为什么也需要成为项目约定

想象两位同事使用同一个锁文件，一位用较新的 Node，另一位用旧版。依赖选择可以一致，但新代码中的某个稳定 API 可能尚未出现在旧版中。结果是安装成功、语法也可能通过，却在调用时出现方法不存在。解决办法应是更新支持范围或改用兼容实现，而不是无目的地重新安装包。安装器确认的是包关系，运行时负责提供执行能力，两者不能互相代替。

环境约定至少包含运行时主版本、维护版本的最低要求，以及包管理器的使用方式。团队可以使用版本管理工具，也可以通过受控开发环境提供版本；工具选择并不改变最终必须能够验证当前进程版本的事实。不要因为编辑器右下角显示某个版本，就忽略真正执行脚本的终端可能使用另一个路径。

再考虑依赖升级。合理的升级流程应该让声明变化、锁文件变化和行为验证一起出现。只修改版本范围但没有更新锁定结果，可能让同事继续得到旧版本；只更新锁文件但不理解范围，则可能误判这次到底升级了哪些直接和间接依赖。审查时先确认变化意图，再看安装树差异和受影响功能，而不是把锁文件当作永远无需阅读的机器噪声。

如果将来把团队助手拆成多个包，工作区可以帮助管理相互依赖，但它不会自动解决运行时边界。共享包是否需要编译、输出文件是否存在、包导出是否声明正确，仍然需要契约。基础阶段先用单包把入口与命令固定下来，可以减少同时出现的未知因素。拆包应来自明确的复用或交付需求，而不是因为目录数量看起来更专业。

最后区分安装和执行。通过临时执行工具调用一个未安装的包，可能触发下载甚至运行新的代码；不能因为命令很短就认为它只是查看信息。本章全部使用已安装的 Node、npm 和本地文件，就是为了让你先看清机制。以后使用在线工具时，应明确包名、版本和命令目的，再把它加入可复现的项目流程。

## 练习：让检查脚本成为稳定的团队入口

扩展项目，加入 `verify` 脚本指向下面的验证入口。要求它读取当前项目声明，确认项目禁止发布，确认 `check` 与 `syntax` 都存在，并验证检查入口文件确实在模块目录中。它不运行任意脚本文本，不安装依赖。提示：声明中的字符串存在，不等于实际文件存在；两者都要验证。

<details><summary>参考答案：完整验证入口与配置改动</summary>

在实验一完整 package.json 的 scripts 对象中增加 `"verify": "node verify-project.mjs"`，注意相邻属性之间的逗号。下面文件可以直接执行，也可通过 `npm.cmd run verify` 执行。

```js verify-project.mjs
import assert from 'node:assert/strict';
import { readFile, stat } from 'node:fs/promises';

const root = new URL('./', import.meta.url);
const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8'));
assert.equal(pkg.private, true, '练习项目必须禁止发布');
for (const name of ['check', 'syntax']) {
  assert.equal(typeof pkg.scripts?.[name], 'string', `缺少脚本：${name}`);
}
const entry = await stat(new URL('project-check.mjs', root));
assert.ok(entry.isFile(), '检查入口必须是普通文件');
console.log('项目声明与入口文件验证通过');
```

预期打印通过信息。这个实现只验证本题约定，不是假装能够判断所有脚本命令是否安全或跨平台。若要检验实际行为，仍须运行具体脚本并检查退出码。

</details>

## 本章实际验证范围

实际使用 npm 10.9.4 执行 syntax、check -- alpha、离线 package-lock-only 安装及离线 ci，全部退出零；锁文件检查得到格式版本三，项目声明与入口文件检查通过。未连接 npm 注册表或安装第三方依赖。

## 验收、自测与官方参考

验收应包含三份证据：scripts 能传入团队参数；空项目能离线生成锁文件并完成 ci；故意传入非法团队名会使检查脚本失败。不要只把 node_modules 目录出现当成完成。恢复合法输入后再次运行，让最终练习目录保持可运行状态。

自测一：有锁文件能否保证不同操作系统运行结果完全相同？答案：不能，原生依赖和系统环境仍可能不同。自测二：为什么本地 npm script 能找到普通终端找不到的工具？答案：npm 为脚本增加了本地依赖可执行目录。自测三：为什么 ci 不适合用来自动修复声明与锁文件不一致？答案：它用于按已有锁定结果进行严格安装，而不是更新依赖选择。

项目字段见 [package.json 文档](https://docs.npmjs.com/cli/v10/configuring-npm/package-json)，安装区别见 [npm ci](https://docs.npmjs.com/cli/v10/commands/npm-ci)，脚本环境见 [npm run-script](https://docs.npmjs.com/cli/v10/commands/npm-run-script)，锁定机制见 [package-lock.json](https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json)。本章命令选择 npm 10 文档，与实际验证的 npm 10.9.4 一致。
