Skip to content

1.3 npm、package.json 与项目结构

本章解决什么问题:上一章我们只跑了单个 .ts 文件;真实项目(包括 Pi)都是「一个目录 + 一份清单 + 一堆依赖」的组合。本章讲清 npm 是什么、package.json 里每个字段什么意思、npm installnpm run 背后发生了什么,以及拿到一个陌生的 TypeScript 项目该从哪里看起。

前置知识1.1 JavaScript、TypeScript 与 Node.js(已安装 Node.js)、1.2 运行第一个 TypeScript 程序(用过 tsx)。

学习目标:读完本章后你能

  • 说出 npm 的三重身份:包管理器、脚本运行器、registry;
  • 逐字段读懂一份 package.json
  • 解释 npm install 之后目录里多出的 node_modules/package-lock.json 各是什么、为什么一个不进版本管理而另一个必须进;
  • npm run 运行和串联脚本,并解释「脚本里能直接写 tsx」的原理;
  • 打开 Pi 仓库时认出它的 monorepo 结构。

建立直觉

安装 Node.js 时,有一个叫 npm 的命令跟着一起装进了你的电脑(在终端运行 npm --version 可以确认)。npm 这个名字通常被解释为 Node Package Manager(Node 包管理器)的缩写,但它实际上是三样东西的合体:

  1. 包管理器npm install 负责把你的项目声明需要的库下载到本地、摆放整齐;
  2. 脚本运行器npm run xxx 负责执行项目里预先定义好的常用命令;
  3. registry(包仓库)registry.npmjs.org 是一个巨大的线上仓库,世界上所有公开发布的 JavaScript / TypeScript 库都存在这里,npm install 就是从它那里下载的。

一个类比:registry 像手机的应用商店,npm install 像「点击安装」,package.json 则像一张购物清单——写清楚这个项目需要哪些「应用」、每个要什么版本;而 npm run 更像项目自带的快捷按钮面板,把长命令收纳成短名字。

📘 概念package(Package)
package(包)是 npm 世界里代码分发的基本单位:一个带 package.json 的目录,里面装着可复用的代码。你写的项目本身是一个 package,你依赖的每个库(比如 1.2 用过的 tsx)也是一个 package。「依赖某个包」就是「把别人发布的 package 下载进自己的项目里使用」。

没有 npm 会怎样:想用别人写的库,你得自己找官网下载源码、手动拷进项目、再手动解决「这个库又依赖另外三个库」的连环问题,升级时全部重来一遍。包管理器把这些机械劳动全部自动化了——你只声明「要什么」,它负责「怎么拿到、放在哪」。Python 的 pip、Rust 的 cargo、Java 的 Maven 都是同类工具。

package.json:项目的清单文件

每个 npm 项目的根目录都有一份 package.json。下面是本章配套实验的真实清单,我们逐字段读:

json
{
  "name": "02-npm-project",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "tsx src/main.ts",
    "build": "tsx src/build.ts",
    "report": "tsx src/report.ts",
    "pipeline": "npm run build && npm run report"
  },
  "devDependencies": {
    "tsx": "4.22.1",
    "typescript": "5.9.3"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}
  • name:包名。若要发布到 registry,它就是别人 npm install 时输入的名字,全局唯一;带 @xxx/ 前缀的叫作用域包,比如 Pi 的 @earendil-works/pi-ai
  • private: true:声明「这个包不发布」,npm 会拒绝把它传上 registry——写应用(而非库)时的标准防呆措施。
  • version:版本号,遵循语义化版本(Semantic Versioning,简称 semver)约定:主版本.次版本.修订号,破坏性变更加主版本、新功能加次版本、修 bug 加修订号。
  • type: "module":声明本包的 .js/.ts 文件使用 ES 模块语法(import/export)。模块系统是下一章的主角,这里先记住:本书所有实验都写 "type": "module"
  • scripts:脚本表。键是短名字,值是真正执行的终端命令,npm run 短名字 即可运行,下文详讲。
  • devDependencies开发期依赖——只在写代码、构建、测试时用到,程序真正运行时不需要。tsx 就是典型:它负责「运行 .ts 文件」这件开发期的事。
  • dependencies(本例没有):运行期依赖——程序跑起来就离不开的库。区分两者的意义在于:别人把你的包当库安装时,只会连带安装你的 dependencies,不会安装 devDependencies
  • engines:声明对运行时版本的要求。1.1 章我们已经在 Pi 的清单里见过 "node": ">=22.19.0"——就是这个字段。
🌱 初学者提示版本号前面的 ^ 和 ~ 是什么
依赖表里的版本可以写成范围:^4.22.1 表示「4.x 里 ≥4.22.1 的任意版本都行」,~4.22.1 表示「只接受 4.22.x 的修订更新」,不带符号则钉死这一个版本。范围写法省心但引入了不确定性——今天装和明天装可能得到不同版本。顺带一提,打开上文提到的 Pi 根 package.json,会看到它的 devDependencies 全部钉死精确版本,还专门写了一个 check:pinned-deps 检查脚本:大型项目常用这种方式拿灵活性换可重现性。
⚠️ 常见误解在 package.json 里写注释或多余的逗号
package.json 是严格的 JSON 格式:不允许 // 注释,最后一项后面不允许多一个逗号,键名必须用双引号。手改这份文件后如果 npm 报 EJSONPARSE 错误,先检查这三点。这也是它和 TypeScript 源码手感不同的地方——JSON 是数据格式,不是编程语言。

npm install 到底做了什么

在实验目录里执行 npm install,作者机器上的真实输出(耗时、告警会因 npm 版本与网络而异):

added 4 packages, and audited 5 packages in 20s

found 0 vulnerabilities

清单里明明只声明了 2 个依赖,为什么装了 4 个包?看一眼装完后的 node_modules/ 目录就明白了:

node_modules/
├── @esbuild/        ← esbuild 的平台二进制
├── esbuild/         ← tsx 依赖它
├── tsx/             ← 我们声明的
├── typescript/      ← 我们声明的
└── .bin/            ← 可执行命令:tsx、tsc……

tsx 自己也有依赖(它靠 esbuild 做 TypeScript 转换),npm 会递归解析这种「依赖的依赖」(称为传递依赖),一并下载。所以 npm install 的完整动作是:读 package.json → 向 registry 询问每个依赖及其传递依赖的可用版本 → 全部下载解压到 node_modules/ → 把「这次实际装了哪些包、每个的精确版本」写进 package-lock.json

图加载中…

图 1.3-1 npm install 的输入与两个产物
阅读顺序:从左到右。左边的 package.json 是你手写的「意图」;右边两个产物都是 npm 生成的——node_modules 是能直接运行的代码本体,package-lock.json 是这次安装结果的精确存档。实验里这两个产物你都会亲手生成出来。

这两个产物的待遇截然相反,值得专门说清:

  • node_modules/ 不进版本管理。它体积大(本章这个最小实验装完就有 34 MB)、完全可以由 npm install 随时重建,所以任何项目的 .gitignore 里都有它。拿到别人的项目发现跑不起来,第一反应永远是:先 npm install
  • package-lock.json 必须进版本管理package.json 里的版本可以是范围,而 lock 文件记下了本次实际安装的每一个包(含传递依赖)的精确版本、下载地址和内容校验和。作者机器上这份 lock 文件有 551 行,其中 tsx 的条目长这样(节选自真实文件):
json
"node_modules/tsx": {
  "version": "4.22.1",
  "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
  "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
  ...
}

有了它,一年后另一台电脑上执行 npm install,npm 会优先按 lock 文件复现出一模一样的依赖树,而不是重新解析出「今天最新」的版本——这就是团队里「在我机器上是好的」问题的主要解药之一。

npm run:项目自带的命令面板

scripts 字段把长命令收纳成短名字。运行 npm run 不带任何名字,npm 会列出全部可用脚本(实验项目的真实输出):

Lifecycle scripts included in 02-npm-project@1.0.0:
  start
    tsx src/main.ts
available via `npm run`:
  build
    tsx src/build.ts
  report
    tsx src/report.ts
  pipeline
    npm run build && npm run report

拿到陌生项目时,这条命令是最好的「说明书目录」。几个要点:

  • npm startnpm run start 的简写test 同理可写 npm test),其他脚本必须带 run
  • 脚本里能直接写 tsx,靠的是 PATH 注入。你在终端直接敲 tsx 多半会报 command not found——因为它没有全局安装;但 npm 执行脚本前会把本项目的 node_modules/.bin/ 临时加进 PATH(终端查找命令的目录列表),装过的命令行工具在脚本里就都可用了。这就是「依赖装在项目里、命令跑在脚本里」的惯例。
  • && 可以把脚本串成流水线pipeline 脚本就是 npm run build && npm run report——&& 是终端语法,「前一条命令以状态码 0 成功退出,才执行下一条」。实验里 report 依赖 build 产出的文件,顺序错了会直接报错退出,你可以亲手触发这个失败来体会状态码的作用。

npm run pipeline 的真实输出(注意每段开头 npm 都会回显 > 包名@版本 脚本名 和实际命令,那两行不是程序打印的):

> 02-npm-project@1.0.0 pipeline
> npm run build && npm run report

> 02-npm-project@1.0.0 build
> tsx src/build.ts

[build] 统计了 3 个源文件,结果已写入 dist/build-info.json

> 02-npm-project@1.0.0 report
> tsx src/report.ts

[report] 源码统计报告
  build.ts   19 行
  main.ts    24 行
  report.ts  20 行
[report] 共 3 个文件,合计 63 行

如何读懂一个 TypeScript 项目目录

把本章的零件拼起来,一个典型的 TypeScript 项目长这样:

my-project/
├── package.json        ← 清单:名字、脚本、依赖(先看它!)
├── package-lock.json   ← 依赖精确版本存档(npm 生成,进 git)
├── node_modules/       ← 装下来的依赖(npm 生成,不进 git)
├── tsconfig.json       ← TypeScript 编译器配置(1.2 已见过)
├── src/                ← 源码目录(source 的缩写)
│   └── main.ts
├── dist/               ← 构建产物目录(distribution 的缩写,不进 git)
├── test/ 或 *.test.ts  ← 测试(2.9 章讲)
└── README.md           ← 给人读的说明

src/dist/ 这对目录值得单独记住:src/ 放你手写的 TypeScript 源码,dist/ 放构建(比如 1.1 讲过的「编译成 JavaScript」)之后生成的产物。产物随时可以重新生成,所以 dist/node_modules/ 一样不进版本管理。本章实验刻意模拟了这个惯例:build 脚本把统计结果写进 dist/report 脚本从 dist/ 读——虽然是玩具流程,目录分工和真实项目完全一致。

从源码结构看,拿到陌生项目的高效读法是固定的三步:先读 README.md 知道它是干什么的;再读 package.jsonscripts 知道它怎么跑、dependencies 知道它站在哪些库上;最后从 src/scripts 指向的那个入口文件开始读代码。第 4.4 章读 Pi 源码时用的正是这套流程。

Pi 中哪里用到了它:一个真实的 monorepo

1.1 章提到 Pi 是一个 monorepo(单仓库多包),现在你已经具备了理解它的全部零件。所谓 monorepo,就是一个 git 仓库里装着多个 package:仓库根有一份总的 package.json,用 workspaces 字段告诉 npm「我的子包都在哪些目录」:

package.json · workspaces
earendil-works/pi@c13ffe1第 5–13 行在 GitHub 查看 ↗
Pi 根 package.json 的 workspaces 字段:packages/* 通配符把 packages/ 目录下的每个子目录都注册为一个 workspace 子包;第二行的 packages/storage/* 再把 storage 里的子目录也注册进来(storage 本身又装着多个小包),其余几行是额外补充的几个示例扩展目录。

在 workspaces 模式下,根目录执行一次 npm install 会统一安装所有子包的依赖,并把子包互相「链接」起来——一个子包可以像依赖普通 registry 包一样依赖隔壁的兄弟包。Pi 的 packages/ 下有 aiagenttuicoding-agentserverstorageevals 七个方向的包,其中你日常敲的 pi 命令本体是 coding-agent,它的清单明确依赖了三个兄弟包:

earendil-works/pi@c13ffe1第 41–44 行在 GitHub 查看 ↗
coding-agent 的 dependencies 前三项就是同仓库的兄弟包 pi-agent-core、pi-ai、pi-tui——monorepo 内部依赖长得和普通依赖一模一样。
图加载中…

图 1.3-2 Pi monorepo 的 workspaces 结构与内部依赖
阅读顺序:从上往下。根 package.json 通过 workspaces 管辖 packages/ 下的子包(图中略去 server、storage、evals);底部三条「依赖」边对应上面第二个 SourceRef 里 coding-agent 的 dependencies 字段。每个子包目录里都有自己的 package.json、src/ 和测试——上一节的「读目录三步法」在每个子包里同样适用。

顺带回答一个你可能已经产生的疑问:为什么装完 Pi 之后终端里会多出 pi 这个命令?答案也在 package.json 里——bin 字段声明「安装本包时,请把这个名字注册成终端命令,指向这个文件」:

earendil-works/pi@c13ffe1第 9–11 行在 GitHub 查看 ↗
bin 字段把终端命令 pi 映射到构建产物 dist/cli.js——注意指向的是 dist 而不是 src:用户运行的是编译后的 JavaScript。

你敲下 pi 之后发生的完整故事是第 5.1 章的内容;每个子包各自负责什么、依赖图长什么样,详见 4.3 monorepo 与 package 地图。本章你只需要建立一个印象:Pi 仓库 = 一份根清单 + packages/ 下一组互相依赖的普通 npm 项目,本章学的每个字段在那里都会反复出现。

实践任务

🛠 实践任务从 npm install 到脚本流水线labs/typescript-basics/02-npm-project

目标:亲手完成一个 npm 项目的完整生命周期——安装依赖、观察产物、运行脚本、串联流水线。实验目录 labs/typescript-basics/02-npm-project(各实验的索引见实践任务索引)。

步骤

  1. 进入实验目录,先只看不跑:打开 package.json,对照本章逐字段确认你能说出每个字段的作用;
  2. 执行 npm install,然后 ls 看一眼目录:确认多出了 node_modules/package-lock.json,再看看 node_modules/.bin/ 里有没有 tsx
  3. 执行 npm run(不带名字)列出所有脚本,再执行 npm start
  4. 故意跑一次 npm run report——观察它如何失败;
  5. 执行 npm run pipeline,对照 expected-output.txt 检查输出,并打开生成的 dist/build-info.json 看看两个脚本之间传递的到底是什么。

预期现象:第 4 步 report 提示「找不到 dist/build-info.json —— 请先运行 npm run build」并以状态码 1 退出;第 5 步输出与 expected-output.txt 一致,合计 63 行。

如何判断成功:实验 README 的「完成标准」四条全部满足,尤其是能回答「为什么 tsx 在 devDependencies 而不是 dependencies」。

常见错误

  • sh: tsx: command not found:没有先 npm install——脚本里的 tsx 来自 node_modules/.bin/
  • 在错误的目录里运行:所有命令都要在实验目录(有 package.json 的那层)下执行,npm 靠当前目录找清单;
  • 改了 src/ 下的文件后行数对不上 expected-output.txt:这是正常的,build 统计的就是你磁盘上的真实文件。

对应源码位置:Pi 根 package.jsonworkspacesscripts 字段——装好依赖后,你在 Pi 仓库根执行 npm run 同样能列出它的全部脚本。

本章小结

  • npm 是包管理器(npm install)、脚本运行器(npm run)和 registry(registry.npmjs.org)的合体。
  • package.json 是项目清单:name/version 定身份,type 定模块语法,scripts 收纳命令,dependenciesdevDependencies 按「运行期 / 开发期」区分依赖,engines 声明 Node 版本要求。
  • npm install 递归解析传递依赖,产出可运行的 node_modules/(不进 git)和精确存档 package-lock.json(进 git)。
  • npm run 执行脚本前会把 node_modules/.bin/ 注入 PATH,所以脚本里可以直接写 tsx 这类项目内安装的命令;&& 依靠退出状态码串联脚本。
  • 读陌生项目三步法:README → package.json 的 scripts 与依赖 → src/ 入口文件。
  • Pi 是 npm workspaces 组织的 monorepo:根清单的 workspaces: packages/* 管辖各子包,子包之间以普通依赖的形式互相引用,pi 命令由 coding-agent 包的 bin 字段注册。

关键术语:npm、package(包)、registry、package.json、语义化版本(semver)、dependencies / devDependencies、传递依赖、node_modulespackage-lock.jsonnpm run / PATH 注入、src / dist、monorepo、workspaces

关键源码索引:根 package.jsonworkspaces(5–13 行);packages/coding-agent/package.jsondependencies(41–44 行)与 bin(9–11 行)

自测问题

  1. dependenciesdevDependencies 的本质区别是什么?tsx 为什么属于后者?
  2. package.jsonpackage-lock.json 都描述依赖,为什么有了前者还需要后者?哪个应该提交进 git?
  3. 脚本里写 "start": "tsx src/main.ts" 能跑通,而直接在终端敲 tsx src/main.ts 却报 command not found,原因是什么?
  4. Pi 仓库根的 package.json 靠哪个字段把 packages/ 下的子目录变成子包?coding-agent 依赖兄弟包 pi-ai 时,写法和依赖一个普通 registry 包有区别吗?

下一章预告1.4 模块系统:import 与 export——本章代码开头反复出现的 import { readFileSync } from "node:fs" 到底是什么语法?文件与文件之间如何共享代码?"type": "module" 背后是 JavaScript 模块系统的一段曲折历史。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)