把一段可复用的逻辑沉淀成 npm 包开发与 npm 包发布的标准流程,是每一个前端与 Node.js 工程师迟早要跨过的门槛。相比把工具函数复制粘贴到每个项目,一个发布到 npm 的包能让你用一条 install 命令复用能力、用语义化版本管理变更、用自动化流水线保证质量。本文从零讲清楚:如何初始化一个现代 npm 包、如何同时产出 ESM 与 CJS、如何本地联调,以及如何用 changesets 加 GitHub Actions 把”发布”这件事彻底自动化。
一、为什么要把代码做成 npm 包
当同一段校验、格式化或请求封装在三个项目里各写一遍时,修复一个 bug 就要改三处,迟早会出不一致。npm 包的价值不只是”复用”:它强制你定义清晰的输入与输出边界、用版本号表达破坏性变更、用 README 沉淀用法。对一个团队来说,把公共能力收敛到一个包,远比在仓库之间搬运代码更可持续。下面这条命令就能初始化一个包的骨架。
二、初始化:package.json 的关键字段
很多人只改 name 和 version,结果别人安装后无法正常引用。一个能”被正确使用”的包,至少要写对这些字段:type 决定默认的模块系统,main/module/exports 决定不同场景下的入口,files 控制发布时打进 tarball 的文件。下面是一份可直接照抄的模板。
还有一个容易被忽略的字段是 peerDependencies:当你的包依赖 React、Vue 这类”宿主”框架时,应该把它放进 peerDependencies 而不是 dependencies,避免用户项目里出现两份框架实例导致难以排查的 bug。sideEffects 字段则告诉打包器哪些文件有副作用,配合 tree-shaking 能把未使用的导出全部摇掉,显著减小消费方的最终体积。这两个字段写对了,包才真正”可被安全复用”。
{
"name": "@your-scope/awesome-utils",
"version": "0.1.0",
"description": "一组可复用的前端工具函数",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"release": "changeset publish"
},
"engines": { "node": ">=18" },
"license": "MIT"
}
三、构建方案:用 tsup 一把梭
手写 rollup 配置对一个小包来说太重。tsup 基于 esbuild,零配置就能同时打出 ESM、CJS 和类型声明,是当下 npm 包开发最省心的选择。它的配置几乎只有三行,却能覆盖 90% 场景,和前端构建工具演进里提到的 Vite 同源(都依赖 esbuild)。
esbuild 之所以快,是因为它用 Go 编写并跳过了类型检查——这正是 tsup 的定位:类型检查交给 tsc,纯打包交给 esbuild。对于绝大多数工具包,你不需要为了追求极致构建速度去手写一整套 Rollup 插件;tsup 的 preset 已经覆盖了 code splitting、banner 注入、外部依赖排除等常用需求。把精力留给真正的业务逻辑,而不是构建脚本,本身就是一种工程效率。
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
minify: false,
});
四、双格式发布:exports 字段的正确写法
只写 main 已经不够了。现代打包器(Webpack 5、Vite、Rollup)会优先读 exports 字段,并根据引入方是 ESM 还是 CJS 自动选择对应产物。下面这张表解释了常见 condition 的含义,配置示例紧随其后。
一个实用技巧是保留 ./package.json 的自引用入口,很多工具链会主动读取它来确认包的存在与版本;另外如果包内有子路径导出(比如 @scope/utils/date),可以在 exports 里逐条声明,让用户按需引入、进一步减小打包体积。注意一旦写了 exports,包内所有未被声明的子路径都会变成”不可访问”,所以上线前务必把所有对外入口列全,否则会收获一堆 404 报错。
| condition | 触发场景 | 指向 |
|---|---|---|
| import | ESM 中 import 引入 | dist/index.js |
| require | CJS 中 require 引入 | dist/index.cjs |
| types | TypeScript 类型解析 | dist/index.d.ts |
| default | 兜底入口 | dist/index.js |
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
}
五、本地联调:npm link 与 workspace
写完就想在业务项目里试,最忌讳的是先发包再验证。两种本地联调方式:单包用 npm link 把包软链到全局再在消费项目里 link 回来;多包仓库直接用 pnpm workspace,改完即时生效。关于 monorepo 的更完整实践,可以参考pnpm Monorepo 与微前端实战。
# 方式一:单包软链
cd awesome-utils && npm run build && npm link
cd ../my-app && npm link @your-scope/awesome-utils
# 方式二:pnpm workspace(根目录 pnpm-workspace.yaml)
packages:
- "packages/*"
六、版本管理:用 changesets 自动算版本
手动改 version 极易漏掉破坏性变更。changesets 让你在每次改动时声明影响级别(patch / minor / major),发版时自动汇总、自增版本、生成 changelog。它和Git 工作流与提交规范配合能把发布节奏标准化。下面这张表对应 SemVer 的三种版本含义。
在 monorepo 场景下,changesets 还能配合 --snapshot 做预发版(prerelease),非常适合给内部灰度验证后再正式发布。一个关键经验是把 changeset version 和 changeset publish 拆成两个动作:前者提交”版本号 + changelog”的变更,后者真正把包推上 registry。这样即便发包环节因网络或权限失败,版本信息的提交也不会丢失,重试成本极低。
| 变更类型 | SemVer | 示例 |
|---|---|---|
| 补丁(向后兼容的修复) | patch 0.1.0 → 0.1.1 | 修了一个 bug |
| 次版本(新功能、兼容) | minor 0.1.1 → 0.2.0 | 新增一个导出函数 |
| 主版本(破坏性变更) | major 0.2.0 → 1.0.0 | 改了函数签名 |
# 安装并初始化
npm i -D @changesets/cli && npx changeset init
# 每次改动后记录意图
npx changeset # 交互选择 patch/minor/major 并写说明
# CI 中消费变更并发布
npx changeset version && npx changeset publish
七、自动发版:GitHub Actions 流水线
把发布交给 CI 是 npm 包开发成熟的分水岭:本地只负责 push 代码和打 changeset,CI 负责构建、测试、发版、打 Git tag。下面这条工作流在每次 push 到 main 时运行,借助 GitHub Actions 搭建 CI/CD 的能力完成自动发版,npm token 通过仓库 Secret 注入,永远不要硬编码进代码。
name: release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, registry-url: "https://registry.npmjs.org" }
- run: npm ci && npm test && npm run build
- run: npx changeset version
- run: npx changeset publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
八、发布前检查清单
| 检查项 | 命令 / 方法 |
|---|---|
| 类型声明已生成 | 确认 dist/index.d.ts 存在 |
| 入口可被 ESM/CJS 引用 | 分别用 import / require 验证 |
| files 未漏发布 | npm pack 后 inspect tarball |
| README 含用法示例 | 人工 review |
| dry-run 验证 | npm publish –dry-run |
九、常见坑与排查
第一,"type": "module" 下忘了把 CJS 产物改成 .cjs 后缀,导致 require 时报语法错误。第二,exports 没有写 ./package.json 入口,工具链读取元信息失败。第三,发布前没跑 npm pack 检查,结果把 src 而不是 dist 打了进去。第四,把 .npmrc 里的 token 提交到了仓库。把这些在 CI 里固化成检查步骤,就能把”发错版本”的概率降到最低。
第五,发版后才发现 engines 写得太宽松,导致老 Node 版本安装后运行即报错——建议用 CI 矩阵实测最低支持版本,而不是凭感觉填一个数字。第六,忘了在仓库根目录放 LICENSE 文件,部分企业私有源会因此拒绝安装,公开包也会被 npm 标记为”无协议”。第七,把测试脚本写成了 test 空命令,CI 一路绿灯却没真正验证任何东西。把这七条写进发布前清单逐项打勾,能省下大量”发完才发现问题”的返工成本。
十、结语
npm 包开发不是把代码丢上 registry 就结束,而是一套”边界清晰、版本可控、发布自动”的工程习惯。从 package.json 的字段语义,到 tsup 双格式构建,再到 changesets 与 GitHub Actions 的自动发版,每一步都在降低协作成本。把这套流程跑顺之后,你会发现复用不再是负担,而是团队效率的杠杆。配合 asdf 多运行时管理 统一本地 Node 版本,整条工具链就能稳定运转。




