package.json 不是依赖清单,是入口、依赖、发布三套规则。入口 exports 说了算,组件库 React 必须进 peerDependencies,工程化字段负责防发错包。
五件套只是入场券,追问从第一轮才开始
被问「package.json 了解多少」,背出 name、version、scripts、dependencies、devDependencies,面试官只会点点头,然后开始追问。
这五个字段证明你用过 npm,证明不了你发过包。真正的分水岭在三层:模块入口怎么声明、依赖怎么分类、工程化字段怎么防事故。
追问一:入口字段谁说了算?exports 是裁判
package.json 有两个身份:项目身份证,和模块入口说明书。身份证那部分没什么好聊的,入口说明书才是重灾区。
同一份 package.json 里可能同时躺着 main、module、browser、types、exports 五个入口字段。它们不是并列关系,是优先级 + 环境适配的关系。
flowchart TD
A[解析 import 请求] --> B{package.json 有 exports}
B -->|有| C[按 conditions 顺序从上到下匹配]
C --> D[命中即停 返回对应文件]
B -->|没有| E{有 module 字段}
E -->|有| F[打包器优先走 ESM 入口]
E -->|没有| G{有 main 字段}
G -->|有| H[Node 按 CommonJS 入口解析]
G -->|没有| I[报错 ERR_MODULE_NOT_FOUND]
各字段的定位表:
| 字段 | 谁认它 | 定位 |
|---|---|---|
| main | Node、几乎所有打包器 | CommonJS 兜底入口,Node 默认走它 |
| module | webpack、vite、rollup | ESM 入口,非官方字段但打包器都认 |
| browser | 浏览器向打包器 | 替换 Node 专有模块,比如把 node-fetch 换成空实现 |
| types、typings | TypeScript | 类型入口,TS 里优先级最高 |
| exports | 现代 Node、现代打包器 | 唯一权威,声明后其他字段降级为兼容 |
一句话:exports 是裁判,其余字段是替补。
一份能同时被 Node ESM、CJS 和 TS 正确加载的 exports 长这样:
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}
三个必须记住的坑:
types 必须放条件对象的第一位。 exports 的条件匹配是从上到下、命中即停,types 排在 import 后面,TS 就永远找不到类型声明,用户只能看到隐式 any。
import 和 require 要分开写。 只写一个 entry,ESM 用户可能加载到 CJS 产物,import 拿到的是 { default: ... } 包一层,SyntaxError: Cannot use import statement outside a module 之类的问题就来了。
exports 是白名单。 一旦声明,没列出来的路径全部不可导入。用户写 my-ui/dist/src/button 会被直接拒绝——这正是封装内部目录、防止深层导入的正确姿势。没写 exports 的包,用户想导你哪一层就导哪一层,重构一次就是一次 breaking change。
追问二:六个依赖分类,判据只有一条
依赖字段的身份牌,取决于它在什么场景下被安装、被发布。
| 字段 | 谁装 | 什么时候用 | 典型例子 |
|---|---|---|---|
| dependencies | 用户装,随包发布 | 运行时真正依赖 | lodash、dayjs |
| devDependencies | 只有开发者装 | 构建、测试、Lint | vitest、typescript |
| peerDependencies | 宿主环境提供,只声明范围不主动装 | 组件库、插件 | react、vue |
| optionalDependencies | 装失败不阻塞 | 平台专用二进制 | fsevents |
| bundledDependencies | 打包进 tarball 一起发 | 私有依赖 | 内网包 |
| peerDependenciesMeta | — | 标记某个 peer 依赖可选 | 插件生态 |
判据只有一条:这个包在用户机器上跑的时候需不需要它。 需要 → dependencies;只在构建期需要 → devDependencies。
组件库的 React 是最经典的翻车点:
flowchart TD
A[组件库把 react 写进 dependencies] --> B[npm 安装两份 react 实例]
B --> C[组件库内部 Hooks 调用自己那份 React]
B --> D[业务代码 Hooks 调用宿主那份 React]
C --> E[两份 React 的 dispatcher 不一致]
D --> E
E --> F[运行时报 Invalid hook call]
Hooks 依赖 React 内部的全局 dispatcher,两份 React 实例 = 两套 dispatcher,报错只是时间问题。
正确做法是双份声明:peerDependencies 里写版本范围告诉宿主「你给我提供」,devDependencies 里装一份给本地开发和测试用。
{
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"react": "^18.3.1"
},
"peerDependenciesMeta": {
"react-dom": { "optional": true }
}
}
peerDependenciesMeta 解决的是插件生态的问题:某个 peer 只有用某个功能时才需要,标成 optional,没装的用户不会看到满屏 warning。
追问三:工程化字段,出事成本最高的一层
这一层最容易被忽略,但发错包、样式被摇掉、团队 npm 版本打架,都在这儿。
{
"type": "module",
"sideEffects": ["*.css"],
"packageManager": "pnpm@9.1.0",
"overrides": { "lodash": "^4.17.21" },
"engines": { "node": ">=18" },
"files": ["dist"],
"private": true,
"publishConfig": { "access": "public" }
}
逐个说:
sideEffects: false 告诉打包器这个包无副作用,可以放心 tree shaking。但如果包里有 CSS 导入,必须显式写 "sideEffects": ["*.css"],否则 webpack 会把样式一起摇掉——现象是组件渲染出来了,但没有样式。
type: module 让包里所有 .js 默认按 ESM 解释。不写的话 Node 按 CJS 处理,import 语法直接报错。这个字段是双刃剑,老项目加之前先数清楚有多少 CJS 文件。
packageManager 锁定包管理器及其版本,配合 corepack 让团队所有人用同一个 pnpm 版本。少了它,lockfile 会因为 npm / pnpm / yarn 的解析差异天天漂移。
overrides(npm)/ resolutions(yarn) 强制指定子依赖版本,用来修安全漏洞和版本冲突。典型场景是某个间接依赖被锁在有漏洞的旧版本上,上游还没修。
engines 约束 Node、npm 版本。注意:它默认只警告不拦截,要在 .npmrc 里配 engine-strict=true 才真正强制。
files 是发布白名单,只发 dist,别把源码和测试一起推上去。private: true 防止手滑 npm publish。publishConfig 控制发布目标和 access,发 scoped 包到公共源必须带 "access": "public"。
monorepo 场景再加两个:workspaces(npm / yarn)或 pnpm-workspace.yaml 管包,overrides 统一版本,再配 syncpack 做依赖对齐,CI 里卡版本漂移。
把三层压成三句话
package.json 这三层,能压成三句可背诵的结论:
入口用 exports 统一,条件顺序别写错,types 永远放最前。
依赖按角色分类,组件库的 React 走 peerDependencies,devDependencies 补一份本地开发。
工程化字段锁版本、控发布、防幽灵依赖——files 白名单加 private,是发错包的最后一道闸。
写在最后
回到最初那道题:五件套是入门,三层追问才是真考点。你们团队的 monorepo 是怎么对齐依赖版本的——靠 overrides 硬压,还是上 syncpack 做检查?评论区聊聊。
有用的话点个赞。