npm 包开发与发布实战:从本地调试到自动发版

把一段可复用的逻辑沉淀成 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触发场景指向
importESM 中 import 引入dist/index.js
requireCJS 中 require 引入dist/index.cjs
typesTypeScript 类型解析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 versionchangeset 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 版本,整条工具链就能稳定运转。

上一篇 视觉语言模型 VLM 实战:图文理解架构解析
下一篇 浏览器事件循环详解:宏任务、微任务与渲染时机