1.3 npm、package.json 与项目结构
本章解决什么问题:上一章我们只跑了单个
.ts文件;真实项目(包括 Pi)都是「一个目录 + 一份清单 + 一堆依赖」的组合。本章讲清 npm 是什么、package.json里每个字段什么意思、npm install和npm 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 包管理器)的缩写,但它实际上是三样东西的合体:
- 包管理器:
npm install负责把你的项目声明需要的库下载到本地、摆放整齐; - 脚本运行器:
npm run xxx负责执行项目里预先定义好的常用命令; - registry(包仓库):
registry.npmjs.org是一个巨大的线上仓库,世界上所有公开发布的 JavaScript / TypeScript 库都存在这里,npm install就是从它那里下载的。
一个类比:registry 像手机的应用商店,npm install 像「点击安装」,package.json 则像一张购物清单——写清楚这个项目需要哪些「应用」、每个要什么版本;而 npm run 更像项目自带的快捷按钮面板,把长命令收纳成短名字。
package.json 的目录,里面装着可复用的代码。你写的项目本身是一个 package,你依赖的每个库(比如 1.2 用过的 tsx)也是一个 package。「依赖某个包」就是「把别人发布的 package 下载进自己的项目里使用」。 没有 npm 会怎样:想用别人写的库,你得自己找官网下载源码、手动拷进项目、再手动解决「这个库又依赖另外三个库」的连环问题,升级时全部重来一遍。包管理器把这些机械劳动全部自动化了——你只声明「要什么」,它负责「怎么拿到、放在哪」。Python 的 pip、Rust 的 cargo、Java 的 Maven 都是同类工具。
package.json:项目的清单文件
每个 npm 项目的根目录都有一份 package.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 检查脚本:大型项目常用这种方式拿灵活性换可重现性。 // 注释,最后一项后面不允许多一个逗号,键名必须用双引号。手改这份文件后如果 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的条目长这样(节选自真实文件):
"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 start是npm 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.json 的 scripts 知道它怎么跑、dependencies 知道它站在哪些库上;最后从 src/ 里 scripts 指向的那个入口文件开始读代码。第 4.4 章读 Pi 源码时用的正是这套流程。
Pi 中哪里用到了它:一个真实的 monorepo
1.1 章提到 Pi 是一个 monorepo(单仓库多包),现在你已经具备了理解它的全部零件。所谓 monorepo,就是一个 git 仓库里装着多个 package:仓库根有一份总的 package.json,用 workspaces 字段告诉 npm「我的子包都在哪些目录」:
workspaces在 workspaces 模式下,根目录执行一次 npm install 会统一安装所有子包的依赖,并把子包互相「链接」起来——一个子包可以像依赖普通 registry 包一样依赖隔壁的兄弟包。Pi 的 packages/ 下有 ai、agent、tui、coding-agent、server、storage、evals 七个方向的包,其中你日常敲的 pi 命令本体是 coding-agent,它的清单明确依赖了三个兄弟包:
dependencies图 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 字段声明「安装本包时,请把这个名字注册成终端命令,指向这个文件」:
你敲下 pi 之后发生的完整故事是第 5.1 章的内容;每个子包各自负责什么、依赖图长什么样,详见 4.3 monorepo 与 package 地图。本章你只需要建立一个印象:Pi 仓库 = 一份根清单 + packages/ 下一组互相依赖的普通 npm 项目,本章学的每个字段在那里都会反复出现。
实践任务
labs/typescript-basics/02-npm-project目标:亲手完成一个 npm 项目的完整生命周期——安装依赖、观察产物、运行脚本、串联流水线。实验目录 labs/typescript-basics/02-npm-project(各实验的索引见实践任务索引)。
步骤:
- 进入实验目录,先只看不跑:打开
package.json,对照本章逐字段确认你能说出每个字段的作用; - 执行
npm install,然后ls看一眼目录:确认多出了node_modules/和package-lock.json,再看看node_modules/.bin/里有没有tsx; - 执行
npm run(不带名字)列出所有脚本,再执行npm start; - 故意跑一次
npm run report——观察它如何失败; - 执行
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.json 的 workspaces 与 scripts 字段——装好依赖后,你在 Pi 仓库根执行 npm run 同样能列出它的全部脚本。
本章小结
- npm 是包管理器(
npm install)、脚本运行器(npm run)和 registry(registry.npmjs.org)的合体。 package.json是项目清单:name/version定身份,type定模块语法,scripts收纳命令,dependencies与devDependencies按「运行期 / 开发期」区分依赖,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_modules、package-lock.json、npm run / PATH 注入、src / dist、monorepo、workspaces
关键源码索引:根 package.json 的 workspaces(5–13 行);packages/coding-agent/package.json 的 dependencies(41–44 行)与 bin(9–11 行)
自测问题:
dependencies和devDependencies的本质区别是什么?tsx为什么属于后者?package.json和package-lock.json都描述依赖,为什么有了前者还需要后者?哪个应该提交进 git?- 脚本里写
"start": "tsx src/main.ts"能跑通,而直接在终端敲tsx src/main.ts却报command not found,原因是什么? - Pi 仓库根的
package.json靠哪个字段把packages/下的子目录变成子包?coding-agent 依赖兄弟包 pi-ai 时,写法和依赖一个普通 registry 包有区别吗?
下一章预告:1.4 模块系统:import 与 export——本章代码开头反复出现的 import { readFileSync } from "node:fs" 到底是什么语法?文件与文件之间如何共享代码?"type": "module" 背后是 JavaScript 模块系统的一段曲折历史。