package.json 的 3 层配置追问:入口、依赖、发布,你能扛几轮?

1 阅读5分钟

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]

各字段的定位表:

字段谁认它定位
mainNode、几乎所有打包器CommonJS 兜底入口,Node 默认走它
modulewebpack、vite、rollupESM 入口,非官方字段但打包器都认
browser浏览器向打包器替换 Node 专有模块,比如把 node-fetch 换成空实现
types、typingsTypeScript类型入口,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只有开发者装构建、测试、Lintvitest、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 做检查?评论区聊聊。

有用的话点个赞。