1. 为什么 package.json 值得你花时间吃透
如果你写 JavaScript 或者 TypeScript,哪怕只是偶尔碰一下前端项目,package.json这个文件你肯定见过。它躺在项目根目录里,看起来就是一堆 JSON 键值对,很多人平时只关心两件事:dependencies里装了什么包,scripts里怎么启动项目。剩下的字段要么视而不见,要么复制粘贴的时候顺手带过来,从来没想过它们是干什么用的。
我刚开始做项目那几年也是这个状态。直到有一次排查一个线上问题,发现构建产物里混进了开发环境的调试代码,查了半天才定位到是dependencies和devDependencies放错了位置;还有一次发布一个工具库,别人装完之后类型提示全丢了,最后发现是types字段没配对。这些坑踩多了之后,我才意识到package.json根本不是一个“配置文件”那么简单,它是整个 Node 生态里包与包之间沟通的契约,是 npm 理解你项目的唯一入口。
这篇文章就是把我这些年跟package.json打交道积累下来的东西系统梳理一遍。从最基础的字段含义,到依赖版本号的语义化规则,再到exports、workspaces这些相对新但越来越重要的能力,我都会结合真实项目里的使用场景讲清楚。不管你是刚入行的新手,还是已经写了几年业务代码想往工程化方向走的人,看完之后应该都能对这个文件有一个更完整的认知,至少下次再看到不认识的字段,你知道该去哪里查、它大概会影响什么。
文章里涉及的所有项目名、包名、人名我都会用虚构代称,但字段名、版本规则、行为逻辑都是真实可验证的。你可以边看边打开自己手头的项目对照着检查,效果会更好。
2. package.json 的整体结构与核心定位
2.1 它在 Node 生态里到底扮演什么角色
要理解package.json,得先搞清楚 npm 是怎么看待一个项目的。在 npm 的世界里,任何一个包含package.json的目录,都可以被当作一个“包”来处理。这个文件承担了三个层面的职责:身份标识、依赖声明、行为定义。
身份标识指的是name和version这两个字段,它们组合起来构成了这个包在 registry 里的唯一坐标。依赖声明就是dependencies、devDependencies、peerDependencies这些字段,告诉 npm 装这个包的时候还需要哪些东西。行为定义则包括scripts、main、exports、bin等,决定了这个包被安装后怎么被调用、入口在哪里、有哪些命令可以执行。
很多人会把package.json和package-lock.json搞混。简单说,前者是你手写的“意图声明”,后者是 npm 根据你的声明实际解析出来的“精确快照”。你写"lodash": "^4.17.0",这是意图;lock 文件里记录的4.17.21加上 integrity 哈希,这是实际结果。两者配合才能保证团队里每个人装出来的依赖树是一致的。
2.2 一个最小可用文件长什么样
理论上,一个合法的package.json只需要name和version两个字段就能通过校验。但实际项目里当然不会这么寒酸。下面是一个我常用的起步模板,你可以直接拿去改:
{ "name": "my-project", "version": "1.0.0", "description": "一个用于演示的项目", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "test": "jest" }, "keywords": ["demo", "template"], "author": "someone", "license": "MIT" }这个模板里每个字段都有明确用途,后面我会逐个展开。这里先提醒一点:name字段有命名规范,不能有大写字母、空格,也不能以点或下划线开头,长度不能超过 214 个字符。如果你打算发布到公共 registry,还要注意名字不能被别人占用。
2.3 字段的分类逻辑
我把package.json里的字段大致分成四类,这样记忆和理解都会清晰很多:
| 分类 | 代表字段 | 作用 |
|---|---|---|
| 元信息 | name, version, description, author, license | 描述这个包是谁、干什么用的 |
| 入口与导出 | main, module, types, exports, browser | 告诉外部怎么引用这个包 |
| 依赖管理 | dependencies, devDependencies, peerDependencies, optionalDependencies | 声明运行和开发所需的其他包 |
| 脚本与工具 | scripts, bin, engines, config | 定义可执行命令和运行环境要求 |
这个分类不是官方标准,但用起来很顺手。你在排查问题时,先判断问题属于哪一类,然后去对应字段里找原因,效率会高很多。
3. 依赖管理字段的深度拆解
3.1 dependencies 与 devDependencies 的边界怎么划
这是最常被问、也最容易出错的一组字段。核心原则只有一句话:运行时需要的放 dependencies,只在开发、构建、测试阶段需要的放 devDependencies。
什么叫“运行时需要”?就是这个包被安装到别人项目里之后,如果缺少这个依赖,代码就会报错或者功能不正常。比如你写了一个 React 组件库,那react就是运行时依赖,必须放dependencies。但如果你只是用typescript来编译代码,编译完的产物里并不包含 tsc 本身,那typescript就应该放devDependencies。
我见过最常见的错误是把构建工具、测试框架、类型定义全都塞进dependencies。这样做本身不会让代码跑不起来,但会带来两个后果:一是别人安装你的包时,会莫名其妙多下载一堆根本用不到的东西,安装体积和耗时都会增加;二是在一些对依赖体积敏感的场景(比如前端打包),可能会把不该打进去的东西打进去。
有一个判断技巧很实用:问自己“如果把这个包删掉,用户在使用我的包的功能时会不会出问题?”会,就是dependencies;不会,就是devDependencies。
3.2 peerDependencies 的真实用途
peerDependencies是很多人一直没搞明白的字段。它的含义是:“我用到了某个包,但我不自己安装它,而是要求使用我的人来提供。”
最典型的场景就是插件体系。假设你写了一个 Webpack 插件,这个插件必须配合 Webpack 使用,但你不应该把 Webpack 打包进插件里,因为使用者自己的项目里已经有 Webpack 了。这时候你就把 Webpack 声明在peerDependencies里。
从 npm 7 开始,peerDependencies的默认行为发生了变化:如果使用者没有安装对应的 peer 依赖,npm 会尝试自动安装。这个变化解决了很多“装完插件发现缺依赖”的问题,但也带来了一些意料之外的版本冲突。所以在声明 peer 依赖时,版本范围要尽量写得宽松一些,比如">=4.0.0"而不是"^4.17.0",给使用者留出选择空间。
3.3 optionalDependencies 与 bundledDependencies
optionalDependencies的语义是“装不上也没关系”。npm 在安装时会尝试安装这些依赖,但如果失败(比如某个平台没有对应的二进制包),不会导致整个安装过程失败。这个字段用得不多,主要出现在一些需要跨平台兼容的底层工具里。
bundledDependencies(也可以写成bundleDependencies)则是把依赖直接打包进你的发布产物里。这个字段现在很少用了,因为现代 npm 的依赖解析已经很可靠,但在一些离线部署或者需要锁定特定版本的场景下仍然有用。
3.4 版本号语义化规则与选择策略
版本号是依赖管理里最容易埋雷的地方。npm 遵循的是语义化版本(SemVer)规范,格式是主版本号.次版本号.修订号。规则很简单:
- 主版本号变化:有不兼容的 API 修改
- 次版本号变化:新增了向后兼容的功能
- 修订号变化:修复了向后兼容的 bug
在package.json里写版本范围时,常用的前缀有这几种:
| 写法 | 含义 | 示例匹配 |
|---|---|---|
^1.2.3 | 允许次版本和修订号升级 | 1.2.3 到 1.x.x |
~1.2.3 | 只允许修订号升级 | 1.2.3 到 1.2.x |
1.2.3 | 精确锁定 | 只有 1.2.3 |
>=1.2.3 | 大于等于 | 1.2.3 及以上 |
* | 任意版本 | 任何版本 |
我的经验是:应用项目用^,工具库用~或精确版本。应用项目不怕自动升级,因为你有 lock 文件和测试兜底;但工具库如果依赖范围太宽,使用者装到的版本可能跟你测试时不一样,容易出兼容性问题。
注意:不要手动去改
package-lock.json里的版本号,那个文件应该由 npm 自动维护。你只需要改package.json,然后重新执行安装命令。
4. 入口与导出字段的实战解析
4.1 main、module、browser 的分工
这三个字段都跟“入口”有关,但面向的环境不同。
main是最传统的字段,指向 CommonJS 格式的入口文件。Node.js 环境默认读这个字段。module是社区约定,指向 ES Module 格式的入口,打包工具(如 Rollup、Webpack)会优先读它,以便做 tree-shaking。browser则用于指定浏览器环境下的替代入口,可以是一个字符串,也可以是一个对象映射,把某些 Node 专用的模块替换成浏览器可用的版本。
一个典型的双格式包会这样写:
{ "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "browser": "dist/index.umd.js" }这样 Node 环境用 CJS,打包工具用 ESM,直接在浏览器里用 script 标签引入时用 UMD。
4.2 types 与类型声明的正确姿势
如果你用 TypeScript 写库,types字段必须指向生成的.d.ts文件。很多人会把它写成源码里的.ts文件路径,这是不对的——使用者装的是你发布后的产物,源码文件通常不在发布范围内。
正确的做法是在tsconfig.json里开启declaration: true,让编译器生成类型声明文件,然后让types指向输出目录里的对应文件。如果你同时提供了 CJS 和 ESM 两套产物,最好用exports字段里的types条件分别指定,避免类型解析出错。
4.3 exports 字段:新一代的入口控制
exports是 Node.js 12.7 之后引入的字段,它比main更强大,也更严格。一旦你定义了exports,Node 就会忽略main,所有外部引用都必须通过exports里声明的路径来访问。
它的基本结构是这样的:
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.esm.js", "require": "./dist/index.cjs.js" }, "./utils": { "import": "./dist/utils.esm.js", "require": "./dist/utils.cjs.js" } } }这个配置做了两件事:一是定义了根入口在不同模块系统下的解析路径,二是暴露了一个子路径./utils,让使用者可以按需引入。
exports的一个重要作用是封装内部结构。在没有exports的年代,使用者可以引用你包里的任意文件,比如require('your-pkg/dist/internal/helper')。这很危险,因为内部文件随时可能重构。有了exports之后,只有你显式暴露的路径才能被访问,其他路径一律报错。这对库作者来说是一个很好的保护机制。
提示:
exports的路径必须以./开头,不能写成dist/index.js。这个细节很容易忽略,但写错了会直接导致解析失败。
5. 脚本、二进制与运行环境配置
5.1 scripts 的进阶用法
scripts字段大家都会用,但有些技巧能让它更好用。首先是pre和post钩子:如果你定义了build脚本,那么prebuild会在它之前执行,postbuild会在它之后执行。这个机制可以用来做清理、校验、通知等事情。
其次是跨平台兼容。scripts里的命令默认由系统的 shell 执行,这意味着 Windows 和 Unix 下的写法可能不一样。比如rm -rf dist在 Windows 的 cmd 里就不管用。解决办法是用rimraf这类跨平台工具,或者用 Node 脚本代替 shell 命令。
还有一个实用技巧是用npm-run-all或者concurrently来并行或串行执行多个脚本。比如:
{ "scripts": { "build": "npm-run-all clean build:*", "build:js": "tsc", "build:css": "sass src:dist", "clean": "rimraf dist" } }这样build会先执行clean,然后依次执行build:js和build:css,逻辑清晰,也方便单独调用某个子任务。
5.2 bin 字段与命令行工具
如果你想让自己的包提供一个命令行命令,就需要用到bin字段。最简单的写法是:
{ "bin": { "my-cli": "./bin/cli.js" } }这样使用者全局安装或者通过npx调用时,就可以直接执行my-cli。对应的cli.js文件开头必须加上 shebang:
#!/usr/bin/env node没有这行,系统不知道用什么解释器来执行这个文件。另外,bin指向的文件在发布时必须有可执行权限,npm 在打包时会自动处理,但如果你手动拷贝文件就要注意这一点。
5.3 engines 与 packageManager
engines字段用来声明你的包对 Node.js 或 npm 版本的要求:
{ "engines": { "node": ">=16.0.0", "npm": ">=8.0.0" } }默认情况下,npm 只会在版本不满足时给出警告,不会阻止安装。如果你希望强制阻止,可以在项目里加一个.npmrc文件,写入engine-strict=true。
packageManager字段是较新的约定,用来指定项目应该使用哪个包管理器及其版本,比如"packageManager": "pnpm@8.6.0"。配合 Corepack 使用,可以确保团队成员用同一套工具链,减少“我这里能跑你那里跑不了”的问题。
6. 常见问题排查与避坑经验
6.1 依赖装不上或版本冲突怎么办
这是最高频的问题。排查思路我一般按这个顺序走:
第一步,删掉node_modules和package-lock.json,重新安装。很多问题是缓存或者 lock 文件损坏导致的,重装能解决一大半。
第二步,如果重装无效,用npm ls <包名>查看这个包被谁依赖了、装了几个版本。版本冲突往往是因为不同的包依赖了同一个包的不兼容版本。
第三步,如果确实存在无法调和的冲突,可以考虑用overrides字段(npm 8.3+)强制指定某个包的版本:
{ "overrides": { "some-package": "2.0.0" } }这个能力要谨慎使用,因为它可能让某个依赖拿到它并不兼容的版本。用之前最好确认一下被覆盖的包在目标版本下是否还能正常工作。
6.2 发布后类型提示丢失
这个问题通常有三个原因:types字段路径写错、.d.ts文件没有被包含在发布产物里、或者exports里没有声明types条件。排查时可以先执行npm pack生成一个 tarball,解压看看里面到底有哪些文件。如果.d.ts不在里面,就要检查files字段或者.npmignore是不是把它排除了。
6.3 files 字段与发布范围控制
files字段决定了哪些文件会被发布到 registry。不写这个字段的话,npm 会发布除了.npmignore和默认忽略列表之外的所有文件,很容易把测试代码、配置文件、甚至密钥文件发出去。
我的习惯是显式声明files:
{ "files": ["dist", "README.md", "LICENSE"] }这样只有dist目录和两个说明文件会被发布,干净利落。发布前用npm pack --dry-run预览一下文件列表,确认没问题再正式发。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 安装时报 peer 依赖缺失 | peerDependencies 未满足 | 手动安装对应 peer 包或调整版本 |
| 打包体积异常大 | 开发依赖放进了 dependencies | 检查依赖分类,移到 devDependencies |
| 引用子路径报错 | exports 未暴露该路径 | 在 exports 中添加对应子路径 |
| 类型提示不生效 | types 路径错误或文件未发布 | 检查 types 字段和 files 字段 |
| 命令执行报权限错误 | bin 文件缺少 shebang 或权限 | 添加 shebang,检查文件权限 |
| 不同环境装出不同版本 | 缺少 lock 文件或版本范围过宽 | 提交 lock 文件,收紧版本范围 |
7. 进阶字段与工程化实践
7.1 workspaces 与 monorepo
workspaces字段让一个仓库可以管理多个包,是 monorepo 的基础。基本写法是:
{ "workspaces": ["packages/*"] }这样packages目录下的每个子目录都会被当作一个独立的包来处理,npm 会把它们之间的依赖关系自动链接起来,不需要手动npm link。对于维护多个相互依赖的包的项目来说,这个能力能省掉大量繁琐的操作。
使用 workspaces 时要注意一点:根目录的package.json通常设为private: true,避免误发布。各个子包的依赖尽量声明在子包自己的package.json里,而不是全部堆在根目录,否则依赖关系会变得难以追踪。
7.2 private 字段与防误发布
"private": true是一个简单但极其重要的字段。加上它之后,npm 会拒绝发布这个包。对于公司内部项目、示例项目、根目录的 monorepo 配置来说,这个字段应该默认加上。我见过不止一次有人不小心把内部项目发到了公共 registry,虽然可以撤回,但处理起来很麻烦。
7.3 条件导出与多环境适配
exports字段支持条件导出,可以根据不同的环境返回不同的文件。除了前面提到的import、require、types,还有node、browser、default等条件。一个完整的例子:
{ "exports": { ".": { "types": "./dist/index.d.ts", "browser": "./dist/index.browser.js", "node": { "import": "./dist/index.node.esm.js", "require": "./dist/index.node.cjs.js" }, "default": "./dist/index.esm.js" } } }条件的匹配是从上到下依次判断的,所以顺序很重要。types必须放在最前面,default放在最后作为兜底。
7.4 版本发布与 changelog 维护
版本号不是随便定的。每次发布前,想清楚这次改动属于哪一类:修 bug 就升修订号,加功能就升次版本号,有破坏性变更就升主版本号。配合CHANGELOG.md记录每个版本的变化,对使用你包的人来说是很大的帮助。
如果团队规模不大,手动维护 changelog 完全够用。如果发布频繁,可以考虑用changesets或者standard-version这类工具来自动化版本管理和 changelog 生成。工具的选择取决于团队习惯,没有绝对的好坏。
8. 我个人的一些使用习惯
写了这么多年package.json,有几个习惯我觉得挺有价值,分享出来供参考。
第一,每次新增依赖都问一句“这是运行时还是开发时”。这个动作只需要几秒钟,但能避免后面大量的依赖分类混乱。
第二,发布前一定跑npm pack --dry-run。这个命令会列出即将发布的文件清单,比任何检查都直观。我靠它拦下过好几次误包含的测试文件和本地配置。
第三,版本范围宁紧勿松。尤其是工具库,依赖范围写宽了,使用者装到的版本可能跟你测试的完全不一样。宁可让使用者偶尔遇到版本冲突手动解决,也不要让他们在不知情的情况下装到一个你没验证过的版本。
第四,把package.json当成代码来维护。它值得被 review,值得有注释(虽然 JSON 不支持注释,但可以在 README 里说明),值得在重构时一起整理。一个混乱的package.json往往是项目工程化水平的一个缩影。
这些经验没有什么高深的理论,都是在一次次踩坑和修坑中攒下来的。你如果刚开始接触这些字段,不用一次性全部记住,先把dependencies、scripts、main、types这几个最常用的搞清楚,其他的遇到再查、再用、再记,慢慢就熟了。