别人凭什么敢用你的包
这块知识的特点是:会用的人很多,能讲清楚"为什么这么配"的人很少。比如 exports 字段为什么把 types 写在最前面、peerDependencies 到底解决什么问题——这些都不是配置题,是设计题。下面每节都从一个具体后果倒推。
01从后果倒推配置每个字段都先问"写错了会怎样",这比记字段名有用得多。
02关注升级路径包一旦有人用,破坏性变更的代价就很高。多数设计问题都在这。
03发布前先 dry-runnpm pack --dry-run 是唯一能让你看见"用户到底拿到什么"的手段。
1
package.json 里四个入口字段★
改错一个,用户就装了个跑不起来的东西
main 给 CJS/Node 用,module 给打包器(非标准但事实标准),browser 指定浏览器环境替换,types 指类型声明。exports 是新的统一方案,一旦写了它就接管全部。
写错会怎样只写了 main 打包器只能拿到 CJS,tree-shaking 大打折扣
只写了 module Node 直接 require 会失败
漏了 types 用户装了但全是 any,issue 第一句就是"没类型"
写了 exports 其他所有入口字段对它都失效 ← 最容易踩
面试怎么答main 指向 CJS 入口供 Node 使用,module 指向 ESM 入口供打包器使用,browser 做浏览器环境替换,types 指向类型声明。exports 是新的条件导出方案,优先级最高,一旦定义就会覆盖 main 和 module 的行为,所以里面的条件必须写全。
2
exports 的条件导出与顺序坑★
顺序错了,类型就不生效
exports 的条件是从上往下匹配,命中第一个就停。所以 types 必须写在最前面,否则可能被 import 先接走,TypeScript 就找不到类型了。◐ 可交互:同一个包,四类调用方各拿到哪份文件
调用方
package.json 里有什么
三个常见错误1. types 写在 import 后面
→ TS 拿不到类型,因为匹配顺序是先命中的赢
2. 忘了暴露 ./package.json
→ 有些工具(打包器、插件)会读它,读不到直接报错
3. 没写 ./* 通配,但包里有子路径
→ exports 一旦存在,未声明的子路径<b>全部不可访问</b>
这是刻意的:把内部结构藏起来
- exports 是封闭的:没声明的路径就访问不到。这既是保护(不让用户依赖内部文件),也是常见的"升级后突然报错"的原因。
- 条件不只 import / require:还有
node、browser、development、worker等。写得越具体,越可能出现"某个环境拿不到"的问题,所以要留default兜底。 - 子路径要写全:
"./utils"和"./utils/index.js"是两个不同的入口,用户写哪种你必须都考虑。
面试怎么答exports 的条件按书写顺序自上而下匹配,命中即停,所以 types 必须放在最前面。它一旦定义就是封闭的,未声明的子路径不可访问,这既是封装也是升级踩坑的来源。要记得暴露 package.json 并给 default 兜底。
3
语义化版本与依赖类型★
^ 和 ~ 差一格,后果差很远
^1.2.3 允许升 minor 和 patch(1.x.x),~1.2.3 只允许升 patch(1.2.x)。peerDependencies 是"你得自己装,我不带",插件类包必须用它。
三种依赖的区别
- dependencies:运行时真的要用,会被装进来
- devDependencies:只在开发和构建时用
- peerDependencies:声明"我用你,但由用户提供"
为什么插件必须用 peer
- React 组件库如果自己带一份 React
- 用户项目里就有两份 React 实例
- hooks 报错、context 读不到
- 所以必须 peer,让用户提供
版本范围的实际含义1.2.3 精确,不变
~1.2.3 同 minor,只升 patch 1.2.x
^1.2.3 同 major,升 minor+patch 1.x.x
^0.2.3 0.x 时特殊:只升 patch 0.2.x
* 任意,等于放弃控制
- 0.x 是特例:语义化版本规定 0.x 不算稳定版,所以
^0.2.3的行为和~0.2.3一样,不会升到 0.3.0。 - peer 要配 peerDependenciesMeta:标记
"optional": true才不会在用户没装时报警告。 - 破坏性变更必须升 major:不是"礼貌",是契约。用户靠 major 判断能不能无痛升级,你偷偷在 minor 里改行为,比报错更糟。
面试怎么答插入符允许 minor 和 patch 升级,波浪号只允许 patch,0.x 版本下插入符等价于波浪号。peerDependencies 用于声明由宿主提供的依赖,插件和组件库必须用它避免出现多份实例。破坏性变更必须升 major。
4
lockfile 与 npm ci★
为什么服务器上不能用 npm install
lockfile 锁的是整棵依赖树的精确版本。
npm install 会按 package.json 的范围重新解析、可能更新 lockfile;npm ci 只按 lockfile 装,lockfile 和 package.json 不一致就直接失败。两者的行为差异npm install 解析 + 安装 + <b>可能改写 lockfile</b>
本地开发用它,因为要装新包
npm ci 删除 node_modules,严格按 lockfile 装
lockfile 与 package.json 不匹配 → 直接报错
CI/CD 必须用它:快、可复现、不会偷偷升级
- lockfile 必须提交:它是团队和 CI 之间唯一的确定性来源。忽略它就会变成"我这能跑,你那不能跑"。
- 间接依赖也会被锁:你以为只锁了直接依赖,其实整棵树都在里面。这也是它体积大的原因。
- 不同包管理器的 lock 不通用:npm 的 package-lock.json、yarn 的 yarn.lock、pnpm 的 pnpm-lock.yaml 各管各的。混用会出乱子,所以要在 CI 里锁定一个。
面试怎么答lockfile 记录的是整棵依赖树的精确版本和完整性校验。npm install 会重新解析并可能更新 lockfile,npm ci 严格按 lockfile 安装并且在不一致时直接失败,所以 CI 环境应该用 npm ci 保证可复现。lockfile 必须提交到仓库。
5
双格式产物怎么打★
CJS、ESM、类型声明,一个不能少
同时给 CJS 和 ESM 是最省事的兼容方案。可以用 tsup 或 unbuild 一条命令出三份产物,也可以用 tsc 打两次 + 后处理。
一个容易被忽略的点:双包陷阱同一份代码被打成两份,如果模块里有<b>模块级状态</b>,
两份产物各自持有一份,状态就分裂了:
import { counter } from 'my-lib' // 拿 ESM 那份
const { counter } = require('my-lib') // 拿 CJS 那份
← 两个 counter 不是同一个
<em>解法:有状态的包只发一种格式,或用 globalThis 兜住。</em>
- 类型声明只打一份就够:
.d.ts不区分 CJS/ESM,但要保证 exports 里每个条件的 types 都指向它。 - 产物里不要留 import 语句指向源码:
.d.ts里如果引用了src/路径,用户装完之后类型就断了。打包时要把它内联或一起发出去。 - ESM 里不能有 require:打包工具通常不处理这种情况,运行时才会在浏览器里炸。
面试怎么答双格式产物用 tsup 或 unbuild 一次生成 ESM、CJS 和类型声明,配合 exports 条件导出。要注意双包陷阱:模块级状态在两份产物里是两份,会导致状态分裂,有状态的包最好只发一种格式。类型声明里的路径引用也要保证发布后可用。
6
sideEffects 与它的代价★
这个字段是承诺,不是优化开关
写
"sideEffects": false 等于告诉打包器"删掉没用到的模块是安全的"。但如果包里有纯副作用导入(CSS、polyfill、全局注册),它们会被一起删掉。- false 是"我保证":打包器会信任你。如果保证错了,用户会遇到"样式莫名消失"这种极难排查的问题。
- 只影响消费方的 tree-shaking:它不改变你自己的构建结果,只影响别人打包你的包时能不能摇。
- 和 ESM 一起才有效:如果入口是 CJS,写了 sideEffects 也没用,因为无法静态分析。
面试怎么答sideEffects 声明模块是否含副作用,告诉打包器可以安全地删除未被使用的模块。设成 false 时如果有纯副作用导入(如 CSS、polyfill)会被误删,所以要改成数组列出这些文件。它只在消费方使用 ESM 且开启压缩时才有意义。
7
发布前的检查清单★
publish 是不可逆的
发布前必须做的三件事:确认 files 字段(决定哪些文件进包)、本地 npm pack 看一下、确认版本号没重复。已经发布的版本号不能再复用。
发布流程npm pack --dry-run 先看包里到底有什么(最重要的一步)
npm version patch 改版本号并打 git tag
npm publish 发布
npm view my-lib 确认线上版本
# 发现发错了
npm unpublish my-lib@1.0.1 ← 24 小时内可以,但会留下痕迹
npm deprecate my-lib@1.0.1 "有严重 bug,请升级到 1.0.2" ← 更推荐
- files 是白名单,不是黑名单:写了 files 就只发这些(加上 package.json 和 README 等固定文件)。没写 files 时会用 .npmignore,两者都没有就发全部——包括测试和源码。
- 别把 .env、密钥、测试数据发出去:包内容是公开的,任何人都能下载。发之前一定跑 dry-run 看清单。
- unpublish 不是好方案:过了 24 小时基本发不了,而且已经装了的用户不受影响。正确的做法是 deprecate 并立刻发修复版。
面试怎么答发布前先跑 npm pack --dry-run 确认包内容,用 files 白名单控制发布范围,改版本号用 npm version 让它同时更新 git tag。发错了优先用 npm deprecate 提示用户升级,而不是 unpublish,因为后者有时间限制且已安装的用户不受影响。
8
dist-tag 与预发布
latest 不是唯一的选择
npm publish 默认打 latest 标签。想发 beta 就用 --tag beta,用户要装就必须写 my-lib@beta,不会影响用默认版本的人。- tag 和版本号是两套东西:版本号是身份,tag 是"当前推荐哪个"。latest 只是一个指针,可以随时移动。
- next 是另一个常用约定:框架类包常用
next表示下个大版本的预览。 - 预发布版本不参与范围匹配:
^1.2.0不会装到1.3.0-beta.1,除非显式写。这是刻意的保护。
面试怎么答dist-tag 是版本之外的一层指针,默认发布打 latest。预发布用 --tag beta 或 next,只有显式写 @beta 的用户才会装到,不影响普通用户。正式发布后用 dist-tag add 把 latest 指向新版本。
9
scope、私有源与 monorepo
一个仓库多个包的常见做法
scope 是包名的命名空间(
@myorg/ui),也是私有包的前提。workspaces 让一个仓库管理多个包,互相引用时走软链接而不是从 registry 下载。workspaces 解决了什么以前:改了 utils,要发版 → 再去 ui 里升级 utils → 才能调试
现在:packages/ui 里 import '@myorg/utils' 直接指向
packages/utils 的源码,改完立刻生效
<em>注意:本地能跑不代表发布后能跑。
发布前要用 npm pack 验证真实依赖是否完整。</em>
- 私有包必须带 scope:
@myorg/xxx才能发布到私有 registry,或者用publishConfig.registry指定源。 - publishConfig 可以按包配置:
{"publishConfig": {"access": "public", "registry": "..."}},避免每次发布都要传参数。 - monorepo 的成本在工具链:版本管理、构建缓存、发布编排都要配套(pnpm + turborepo + changesets 是常见组合)。包少的时候可能不值得。
面试怎么答scope 是包名命名空间,也是发布私有包的前提。workspaces 让一个仓库管理多个包,包之间引用走软链接,改完即时生效。要注意本地能跑不等于发布后能跑,发布前应该用 npm pack 验证依赖完整性。
10
供应链安全★
你装的不只是一个包
安装一个包等于执行它的 postinstall 脚本,并且信任它以及它所有间接依赖的作者。
npm audit 只是最低限度,真正的防线是锁版本、审依赖、开 2FA。几个真实的防线npm ci 严格按 lockfile,避免安装时被塞入新版本
npm audit 已知漏洞扫描(只是下限,别当全部)
npm audit signatures (npm 9+)校验包的来源签名
package.json:
"overrides": { "lodash": "4.17.21" } 强制统一间接依赖版本
发布侧:
账号开 2FA
用 provenance(来源证明),让别人能验证产物确实由 CI 构建
- postinstall 是最大的风险点:它在你机器上以你的权限执行任意代码。可以用
npm ci --ignore-scripts禁掉,但很多包会因此装不好——这是个真实的权衡。 - 依赖越少,攻击面越小:一个左 pad 式的工具函数引入一个包,就引入了它背后的整棵树。这是"零依赖"最实际的价值。
- typosquatting 很常见:包名差一个字母的恶意包常年存在。装之前确认包名,尤其是新依赖。
面试怎么答npm 安装依赖时可能执行 postinstall 脚本,所以供应链安全的核心是控制依赖。实践上要提交 lockfile、CI 用 npm ci、定期跑 npm audit、用 overrides 统一间接依赖版本,发布侧开 2FA 并使用 provenance。减少依赖数量本身就是最有效的防御。
11
包怎么设计:API 面越小越好
一个包的价值在于别人用起来不用想
好包的共同点:API 面小、错误信息能直接指导修复、默认行为合理。这三件事比"功能多"重要得多。
该避免的设计
- 默认导出整个对象,用户只能全引入
- 错误只抛 new Error(失败)
- 配置项有二十个必填
- 为了一个小功能引入三个依赖
更好的做法
- 具名导出,天然可 tree-shake
- 错误里带上下文和修复建议
- 提供合理默认值,按需覆盖
- 优先零依赖,把选择权留给用户
- 零依赖是取舍不是教条:工具函数库适合零依赖;复杂功能(日期解析、模板引擎)硬要零依赖反而会写出更差的实现。
- 默认导出在 CJS 互操作时最容易出问题:具名导出在两种格式下都稳定,也更适合 tree-shaking。
- 错误类型要可区分:导出一个错误类,用户就能用
instanceof分类处理,而不是匹配字符串。
面试怎么答好的包设计是 API 面尽量小、全部使用具名导出以便 tree-shaking、错误信息带上实际值和修复建议、提供合理默认值。是否零依赖要看场景,工具库适合零依赖,复杂功能硬做零依赖反而质量更差。
12
版本发布自动化
changesets 解决的是"改了什么"
手动改版本号的问题是:没人记得该升 major 还是 patch。changesets 让每次 PR 附带一个变更说明,发布时自动汇总、自动升版本、自动写 CHANGELOG。
开发时每次改动加一个 changeset 文件,写明 patch/minor/major
→
合并后CI 汇总所有 changeset,开一个版本发布 PR
→
发布时合并那个 PR,自动改版本号 + 写 CHANGELOG + 发布
一个 changeset 长这样---
"@myorg/utils": minor
---
新增 debounce 的 immediate 选项。
<em>关键点:版本决策发生在写代码的时候,那时最清楚这次改动的影响。</em>
- monorepo 里几乎是必需的:多个包互相依赖,手工会漏。changesets 会算出哪些包需要跟着升版本。
- CHANGELOG 的价值被低估:用户升级前会看它。写得好的 CHANGELOG 能挡掉一半的"升级后报错"类 issue。
- 发布走 CI 而不是本地:本地发布容易漏构建、漏打 tag,还可能是脏工作区。
面试怎么答changesets 让版本决策在写代码时就记录:每个 PR 附带一个说明本次改动是 patch、minor 还是 major 的文件,发布时自动汇总生成版本号和 CHANGELOG。monorepo 场景尤其必要,发布应该走 CI 而不是本地手动执行。
13
README 决定了有没有人用
技术选型时,多数人只看前 30 行
README 的结构比文笔重要:一句话说明它解决什么问题 → 安装 → 最小可运行示例 → 常见场景 → 限制。别一上来就贴 API 大全。
一个能用的骨架# my-lib
一句话:把 X 转成 Y,支持 Z 场景。
## 安装
npm i my-lib
## 30 秒上手
(一段能直接复制运行的代码,不要有省略号)
## 常见场景
- 场景 A:...
- 场景 B:...
## 和同类库的区别
(诚实说明,包括你不擅长的地方)
## 限制
(写清楚,减少无效 issue)
- "限制"这一节是加分项:写清不适用的场景,比吹功能更能建立信任,也省掉大量"能不能支持 XX"的 issue。
- 示例必须能直接跑:带省略号的示例等于没有。至少保证 quickstart 那段是复制即可运行的。
- 对比要诚实:说清"什么时候该用别人"。这是成熟度的信号。
面试怎么答README 前 30 行决定别人要不要继续看,结构应该是一句话定位、安装、可直接运行的最小示例、常见场景、与同类库的区别和限制说明。写清限制反而能建立信任并减少无效 issue。
14
速答清单
考前十分钟只看这页
01main / module / browser / types 分别是什么?
main 给 CJS 和 Node,module 给打包器,browser 做浏览器替换,types 指类型声明。
02exports 字段要注意什么?
按顺序匹配命中即停,types 必须放最前;一旦定义就是封闭的,未声明的子路径访问不到。
03^ 和 ~ 的区别?
^1.2.3 允许升 minor 和 patch,~1.2.3 只允许 patch。0.x 时 ^ 等价于 ~。
04为什么要用 peerDependencies?
插件和组件库不能让用户装两份宿主库,否则出现多实例,hooks 和 context 会失效。
05npm ci 和 install 的区别?
ci 严格按 lockfile 装,不一致就失败,可复现;install 会重新解析并可能改 lockfile。
06sideEffects: false 有什么风险?
纯副作用导入(CSS、polyfill)会被误删。应该改成数组列出这些文件。
07发布前最重要的一件事?
npm pack --dry-run,先看包里到底有什么再发。files 是白名单。
08发错了怎么办?
优先 npm deprecate 并马上发修复版。unpublish 有 24 小时限制且对已安装用户无效。
09dist-tag 是什么?
版本之外的推荐指针,默认 latest。预发布用 --tag beta 或 next,不影响普通用户。
10双包陷阱是什么?
同一份代码打成 CJS 和 ESM 两份,模块级状态会各存一份导致分裂。有状态的包只发一种格式。