ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入解析 package.json:从依赖管理到工程化实践

深入解析 package.json:从依赖管理到工程化实践

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这几个最常用的搞清楚,其他的遇到再查、再用、再记,慢慢就熟了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进