主题
npm 包发布
npm(Node Package Manager)是 JavaScript 生态中最核心的包管理与分发平台。对于前端工程师而言,发布一个高质量的 npm 包不仅仅是 npm publish 一条命令那么简单——它涉及到 registry 机制、模块格式设计、构建工具选型、版本管理、自动化发布流水线等一整套工程体系。
本文将从 发布基础 → 导出设计 → 构建工具 → 发布工作流 → 私有包 → 最佳实践 → 面试高频题 七个维度,全面深入地拆解 npm 包发布。
一、npm 包发布基础
1.1 npm Registry 机制
npm Registry 是一个基于 CouchDB 的巨型 JSON 文档数据库,所有通过 npm publish 发布的包都以 tarball(.tgz)的形式存储在 Registry 中。当用户执行 npm install 时,客户端通过 HTTP API 向 Registry 查询包的元数据(metadata),然后下载对应版本的 tarball 并解压到 node_modules。
npm publish / install 流程:
发布流程:
┌──────────┐ npm publish ┌──────────────────┐
│ 开发者 │ ──────────────► │ npm Registry │
│ 本地项目 │ 上传 tarball │ registry.npmjs.org│
└──────────┘ │ │
│ ┌────────────┐ │
│ │ package.tgz │ │
│ │ metadata │ │
│ └────────────┘ │
└──────────────────┘
安装流程:
┌──────────┐ npm install foo ┌──────────────────┐
│ 使用者 │ ──────────────► │ npm Registry │
│ 项目 │ 查询元数据 │ │
│ │ ◄────────────── │ 返回 metadata │
│ │ 下载 tarball │ │
│ │ ◄────────────── │ 返回 .tgz 文件 │
└──────────┘ └──────────────────┘
│
▼
┌──────────────┐
│ node_modules/ │
│ └── foo/ │
│ ├── dist/│
│ └── ... │
└──────────────┘Registry 为每个包维护一份 metadata 文档,核心结构如下:
json
{
"name": "my-lib",
"dist-tags": {
"latest": "2.0.0",
"next": "3.0.0-beta.1"
},
"versions": {
"1.0.0": { "dist": { "tarball": "https://..." } },
"2.0.0": { "dist": { "tarball": "https://..." } }
},
"time": {
"1.0.0": "2024-01-01T00:00:00.000Z",
"2.0.0": "2024-06-01T00:00:00.000Z"
}
}npm 客户端解析版本时的优先级:
npm install foo
│
▼
有 lockfile?──── 是 ───► 使用 lockfile 中锁定的版本
│
否
│
▼
查询 Registry metadata
│
▼
根据 package.json 中的 semver 范围
匹配 dist-tags.latest 对应的最新版本
│
▼
下载并解压 tarball1.2 核心命令
npm login
bash
npm login --registry=https://registry.npmjs.org执行后会在 ~/.npmrc 中写入 authToken:
//registry.npmjs.org/:_authToken=npm_XXXXXXXXXXXXnpm publish
bash
npm publish
npm publish --access public
npm publish --tag beta
npm publish --dry-run--dry-run 是发布前的重要检查手段,它会模拟整个发布流程,列出将要上传的文件列表和包大小,但不会真正上传到 Registry。
npm unpublish
bash
npm unpublish my-package@1.0.0
npm unpublish my-package --forcenpm 的 unpublish 策略:
| 条件 | 是否可撤销 |
|---|---|
| 发布后 72 小时内 | 可以撤销 |
| 发布后超过 72 小时 | 不可撤销 |
| 包没有其他包依赖 | 可以撤销 |
| 包被其他包依赖 | 不可撤销 |
npm deprecate
bash
npm deprecate my-package@"< 2.0.0" "请升级到 2.x 版本"相比 unpublish,deprecate 是更推荐的做法——它不会删除包,而是在用户安装时显示警告信息。
1.3 package.json 发布相关字段
package.json 是 npm 包的"身份证",其中与发布直接相关的字段有十余个,理解每个字段的含义和交互关系至关重要。
json
{
"name": "@myorg/utils",
"version": "1.2.3",
"description": "A collection of utility functions",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
"files": ["dist", "README.md"],
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/myorg/utils.git"
},
"license": "MIT",
"sideEffects": false,
"keywords": ["utils", "helpers"],
"peerDependencies": {
"react": ">=18.0.0"
}
}各字段详解:
name
包名是 npm 生态中的唯一标识符。命名规则:
- 长度 ≤ 214 个字符
- 不能以
.或_开头 - 不能包含大写字母
- 不能包含 URL 不安全字符
- Scoped 包名格式:
@scope/package-name
version
遵循 SemVer(语义化版本规范),格式为 MAJOR.MINOR.PATCH。Registry 不允许发布已存在的版本号——一旦发布,该版本号将被永久占用,即使 unpublish 后也无法重新使用。
main
模块解析时 main 字段的作用:
require("my-lib")
│
▼
Node.js 在 node_modules 中找到 my-lib
│
▼
读取 my-lib/package.json
│
▼
使用 "main" 字段作为入口
main: "./dist/index.cjs"
│
▼
加载 ./dist/index.cjsmodule
module 字段是社区约定(非 Node.js 官方),用于指向 ESM 格式的入口文件。Webpack、Rollup 等打包工具在解析模块时会优先读取 module 字段。
types / typings
指向 TypeScript 类型声明文件(.d.ts)。TypeScript 编译器在解析第三方包的类型时会读取此字段。
files
files 字段是一个白名单数组,指定哪些文件/目录会被包含在发布的 tarball 中。
files 字段的过滤逻辑:
项目目录 发布到 npm 的内容
┌──────────────┐ ┌──────────────┐
│ src/ │ files: │ dist/ │
│ dist/ │ ["dist"] │ index.mjs │
│ tests/ │ ──────────► │ index.cjs │
│ .eslintrc │ │ index.d.ts │
│ tsconfig.json │ │ package.json │ ← 始终包含
│ package.json │ │ README.md │ ← 始终包含
│ README.md │ │ LICENSE │ ← 始终包含
│ LICENSE │ └──────────────┘
└──────────────┘无论 files 字段如何配置,以下文件始终会被包含:
package.jsonREADME(任何大小写和扩展名)LICENSE/LICENCECHANGELOG
以下文件始终会被排除:
.gitnode_modules.npmrcpackage-lock.json
engines
声明包所需的 Node.js 版本范围。当用户的 Node.js 版本不满足要求时,npm 会发出警告(配合 engine-strict 可以升级为报错)。
sideEffects
告诉打包工具(Webpack/Rollup)该包是否有副作用。设为 false 表示所有模块都是纯净的,可以安全地进行 Tree Shaking。
1.4 .npmignore vs files 字段
两种方式都可以控制发布的文件范围,但思路相反:
| 特性 | .npmignore | files 字段 |
|---|---|---|
| 策略 | 黑名单(排除指定文件) | 白名单(只包含指定文件) |
| 默认行为 | 不存在时使用 .gitignore | 不存在时包含所有文件 |
| 推荐度 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 安全性 | 容易遗漏敏感文件 | 只有明确声明的才会发布 |
| 维护成本 | 需要随项目文件变化更新 | 通常只需声明 dist 目录 |
最佳实践:始终使用 files 字段。白名单策略远比黑名单安全——你永远不会意外发布不该发布的文件。
发布前可以通过以下命令检查将被打包的文件:
bash
npm pack --dry-runbash
npx publintpublint 是一个社区工具,可以自动检测 package.json 中的常见配置错误。
二、现代包的导出设计
2.1 exports 字段详解
exports 字段是 Node.js 12.7.0 引入的「条件导出」机制,它是 main / module / types 字段的现代化替代方案。相比传统字段,exports 提供了更精确的模块入口控制和子路径映射能力。
exports 条件匹配流程:
import { foo } from "my-lib"
│
▼
读取 my-lib/package.json 的 exports 字段
│
▼
匹配子路径 "."
│
▼
┌─────────────────────────────────────────────┐
│ 检测调用环境的条件(按优先级从高到低) │
│ │
│ 1. "import" ← 当前是 ESM import 语句? │
│ 2. "require" ← 当前是 CJS require 调用? │
│ 3. "node" ← 当前是 Node.js 环境? │
│ 4. "browser" ← 当前是浏览器环境? │
│ 5. "default" ← 兜底条件 │
└─────────────────────────────────────────────┘
│
▼
返回第一个匹配的条件对应的文件路径完整的 exports 配置示例:
json
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./styles/*.css": "./dist/styles/*.css",
"./package.json": "./package.json"
}
}条件导出支持的常用条件:
| 条件 | 含义 | 触发场景 |
|---|---|---|
import | ESM 导入 | import x from 'pkg' |
require | CJS 导入 | const x = require('pkg') |
types | TypeScript 类型 | TS 编译器解析类型时 |
node | Node.js 环境 | 在 Node.js 中运行时 |
browser | 浏览器环境 | 打包工具识别的浏览器构建 |
default | 兜底 | 其他条件都不匹配时 |
development | 开发环境 | 打包工具设定 NODE_ENV=development |
production | 生产环境 | 打包工具设定 NODE_ENV=production |
条件的顺序非常重要——Node.js 和打包工具会按照对象中键的顺序从上到下匹配,返回第一个满足的条件。因此 types 必须始终放在最前面。
2.2 main vs module vs exports 的关系与优先级
这三个字段的关系经历了一个演进过程:
模块入口字段的演进:
2012─────────────2017─────────────2020─────────────现在
│ │ │ │
│ main │ main │ main │
│ (CJS入口) │ (CJS入口) │ (CJS入口) │
│ │ │ │
│ │ module │ module │
│ │ (ESM入口) │ (ESM入口) │
│ │ (社区约定) │ (社区约定) │
│ │ │ │
│ │ │ exports │
│ │ │ (官方标准) │
│ │ │ (条件导出) │
│ │ │ (子路径映射) │
│ │ │ │
CommonJS时代 打包工具兴起 Node.js官方支持 三者并存优先级规则:
Node.js 模块解析优先级:
exports > main
打包工具(Webpack/Vite)模块解析优先级:
exports > module > main
TypeScript 类型解析优先级:
exports.types > types/typings当 exports 存在时,main 和 module 会被完全忽略。但为了兼容不支持 exports 的旧版 Node.js(< 12.7.0)和旧版打包工具,建议同时保留三个字段:
json
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}2.3 Dual Package(CJS + ESM 双格式发布)
JavaScript 生态目前同时存在 CommonJS 和 ES Modules 两种模块系统。一个高质量的 npm 包需要同时支持两种格式,这就是 Dual Package 模式。
Dual Package 产物结构:
dist/
├── index.mjs ← ESM 格式(import/export)
├── index.cjs ← CJS 格式(require/module.exports)
├── index.d.mts ← ESM 对应的类型声明
├── index.d.cts ← CJS 对应的类型声明
└── index.d.ts ← 通用类型声明(兼容旧版 TS)Dual Package Hazard(双包风险)
当一个包同时提供 CJS 和 ESM 两种格式时,存在一个潜在风险:同一个包可能在一个应用中被加载两次——一次通过 require,一次通过 import。这会导致:
Dual Package Hazard:
应用代码
├── 模块 A: import { foo } from 'my-lib' → 加载 dist/index.mjs
│ 创建实例 Instance_1
│
└── 模块 B: const { foo } = require('my-lib') → 加载 dist/index.cjs
创建实例 Instance_2
Instance_1 !== Instance_2 ← 两个不同的模块实例!
问题:
- 单例模式失效
- instanceof 检查失败
- 状态不共享解决方案一:ESM Wrapper 模式
javascript
import cjsModule from './index.cjs';
export const foo = cjsModule.foo;
export const bar = cjsModule.bar;ESM 入口只是对 CJS 模块的一个薄封装,确保无论通过哪种方式导入都使用同一份代码。
解决方案二:使用 exports 条件导出做隔离
json
{
"exports": {
".": {
"import": "./dist/esm-wrapper.mjs",
"require": "./dist/index.cjs"
}
}
}2.4 Type Declarations(类型声明文件)
TypeScript 用户需要类型声明文件来获得类型提示和类型检查。类型声明的解析路径取决于 tsconfig.json 中的 moduleResolution 配置:
TypeScript moduleResolution 与类型解析:
┌─────────────────┬────────────────────────────────────┐
│ moduleResolution │ 类型声明查找路径 │
├─────────────────┼────────────────────────────────────┤
│ "node" │ types 字段 → @types/pkg │
│ "node16" │ exports.types → types → @types/pkg │
│ "bundler" │ exports.types → types → @types/pkg │
│ "nodenext" │ exports.types → types → @types/pkg │
└─────────────────┴────────────────────────────────────┘为了确保类型声明在所有 moduleResolution 模式下都能正确解析,最完整的配置是:
json
{
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}关键规则:在 exports 中,types 条件必须放在 default 之前,因为条件匹配是按顺序进行的。
三、构建工具
npm 包的源码通常使用 TypeScript 编写,发布前需要构建为 JavaScript 产物。目前主流的库级别构建工具有三个:tsup、unbuild、Rollup。
3.1 tsup
tsup 是基于 esbuild 的零配置打包工具,专门为 TypeScript 库设计。它的核心优势是极快的构建速度和简洁的配置。
tsup 架构:
源代码 (TypeScript)
│
├──────────────────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ esbuild │ │ TypeScript │
│ (代码编译) │ │ Compiler │
│ 极快! │ │ (DTS 生成) │
└─────────────┘ └──────────────┘
│ │
▼ ▼
index.mjs index.d.ts
index.cjs index.d.mts
index.d.cts基本使用:
bash
npm install tsup -Dtypescript
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts', 'src/utils.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: true,
clean: true,
minify: true,
sourcemap: true,
target: 'es2020',
outDir: 'dist',
})json
{
"scripts": {
"build": "tsup"
}
}tsup 的多入口配置:
typescript
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'src/index.ts',
utils: 'src/utils.ts',
'react/index': 'src/react/index.ts',
},
format: ['cjs', 'esm'],
dts: true,
external: ['react', 'react-dom'],
})3.2 unbuild
unbuild 是由 unjs 团队维护的构建工具,底层基于 Rollup 和 mkdist。它的特色是"自动推断"——可以从 package.json 的 main / module / exports 字段自动推断构建配置。
unbuild 架构:
package.json (main / module / exports)
│
▼
自动推断入口和输出格式
│
├──────────────────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Rollup │ │ mkdist │
│ (打包模式) │ │ (文件转译模式) │
│ bundled │ │ passthrough │
└─────────────┘ └──────────────┘
│ │
▼ ▼
单文件 bundle 保持目录结构
index.mjs src/a.ts → dist/a.mjs
index.cjs src/b.ts → dist/b.mjs基本使用:
bash
npm install unbuild -Dtypescript
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
inlineDependencies: true,
},
})unbuild 的 Stub 模式是一个独特特性,它在开发时生成一个指向源文件的代理模块,无需每次修改后重新构建:
bash
unbuild --stub生成的 stub 文件(dist/index.mjs)内容类似:
javascript
import jiti from "jiti"
export default jiti(null, { interopDefault: true })("/path/to/src/index.ts")3.3 Rollup
Rollup 是 JavaScript 模块打包器的先驱,也是 Vite 生产构建的底层引擎。它对 ESM 有原生支持,产出的代码干净整洁,是库打包的经典选择。
javascript
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import terser from '@rollup/plugin-terser'
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.mjs',
format: 'es',
sourcemap: true,
},
{
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: true,
},
],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
terser(),
],
external: ['react', 'react-dom'],
}3.4 三者对比与选型建议
| 特性 | tsup | unbuild | Rollup |
|---|---|---|---|
| 底层引擎 | esbuild | Rollup + mkdist | Rollup |
| 构建速度 | ⚡ 极快 | 🔥 快 | 🐢 一般 |
| 零配置 | ✅ 支持 | ✅ 支持(自动推断) | ❌ 需手动配置 |
| DTS 生成 | ✅ 内置 | ✅ 内置 | 🔌 需插件 |
| 多入口 | ✅ | ✅ | ✅ |
| 多格式输出 | ✅ CJS/ESM/IIFE | ✅ CJS/ESM | ✅ CJS/ESM/UMD/IIFE |
| Stub 模式 | ❌ | ✅ 独有 | ❌ |
| 插件生态 | esbuild 插件 | Rollup 插件 | Rollup 插件(最丰富) |
| 代码分割 | ✅ | ✅ | ✅ |
| CSS 处理 | ✅ 内置 | 🔌 需配置 | 🔌 需插件 |
| 配置灵活度 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 适用场景 | 中小型 TS 库 | unjs 生态/Nuxt 模块 | 大型库/需要极致控制 |
选型建议:
你在写什么?
│
├── 中小型 TypeScript 工具库 → tsup(最省心)
│
├── Nuxt 模块 / unjs 生态 → unbuild(生态契合)
│
├── 大型库 / 需要 UMD 格式 → Rollup(最灵活)
│
└── 对构建速度极致要求 → tsup(esbuild 加持)四、发布工作流
4.1 语义化版本(SemVer)
SemVer(Semantic Versioning)是 npm 生态的版本规范基石,格式为 MAJOR.MINOR.PATCH:
版本号格式:MAJOR.MINOR.PATCH
2 . 3 . 1
│ │ │
│ │ └── PATCH:向后兼容的 Bug 修复
│ │
│ └──── MINOR:向后兼容的功能新增
│
└────── MAJOR:不兼容的 API 变更(Breaking Change)npm 中常见的版本范围语法:
| 语法 | 含义 | 示例 |
|---|---|---|
^1.2.3 | 兼容 MAJOR | >=1.2.3 <2.0.0 |
~1.2.3 | 兼容 MINOR | >=1.2.3 <1.3.0 |
1.2.3 | 精确版本 | 只匹配 1.2.3 |
* | 任意版本 | 匹配所有版本 |
>=1.2.3 | 大于等于 | >=1.2.3 |
1.2.x | 匹配 PATCH | >=1.2.0 <1.3.0 |
npm version 命令
bash
npm version patch
npm version minor
npm version major
npm version prepatch --preid=alpha
npm version prerelease --preid=betanpm version 会自动完成三件事:
- 修改
package.json中的version字段 - 执行
git commit(提交版本变更) - 执行
git tag(创建版本标签)
npm version patch 的执行流程:
package.json: "version": "1.2.3"
│
▼ npm version patch
│
修改 package.json: "version": "1.2.4"
│
▼
git add package.json
│
▼
git commit -m "1.2.4"
│
▼
git tag v1.2.4
│
▼
完成!可以执行 npm publish4.2 预发布版本
预发布版本用于在正式发布前进行测试和收集反馈:
版本发布生命周期:
alpha → beta → rc → stable
α 内部测试 β 公开测试 RC 候选发布 正式发布
功能不完整 功能基本完整 功能冻结 稳定可用
Bug 较多 修复主要 Bug 最终验证 生产环境
示例版本线:
1.0.0-alpha.1
1.0.0-alpha.2
1.0.0-beta.1
1.0.0-beta.2
1.0.0-rc.1
1.0.0-rc.2
1.0.0 ← 正式版发布预发布版本的命令:
bash
npm version 1.0.0-alpha.1
npm publish --tag alpha
npm version 1.0.0-beta.1
npm publish --tag beta
npm version 1.0.0-rc.1
npm publish --tag rc4.3 Tag 管理
npm 的 dist-tag 机制允许你为不同的发布渠道打标签:
dist-tag 与 npm install 的关系:
npm install my-lib → 安装 latest 标签对应的版本
npm install my-lib@next → 安装 next 标签对应的版本
npm install my-lib@canary → 安装 canary 标签对应的版本
npm install my-lib@1.0.0 → 安装精确版本| Tag | 用途 | 示例 |
|---|---|---|
latest | 默认标签,稳定版 | npm publish(默认) |
next | 下一个大版本预览 | npm publish --tag next |
canary | 每日/每次提交自动构建 | CI 自动发布 |
alpha / beta / rc | 预发布测试 | npm publish --tag beta |
管理 dist-tag 的命令:
bash
npm dist-tag ls my-lib
npm dist-tag add my-lib@2.0.0-beta.1 next
npm dist-tag rm my-lib next重要:如果你发布预发布版本时忘了加 --tag,它会被标记为 latest,所有用户执行 npm install 时都会安装到这个不稳定版本!这是非常常见的发布事故。
4.4 发布检查清单
一个规范的发布流程应包含以下步骤:
发布检查清单流程:
① 代码检查
│ npm run lint
│ npm run typecheck
│
▼
② 运行测试
│ npm run test
│ 确保所有用例通过
│
▼
③ 构建产物
│ npm run build
│ 检查 dist/ 目录
│
▼
④ 检查包内容
│ npm pack --dry-run
│ npx publint
│ npx are-the-types-wrong
│
▼
⑤ 更新版本
│ npm version patch/minor/major
│
▼
⑥ 更新 CHANGELOG
│ 记录变更内容
│
▼
⑦ 发布
│ npm publish
│
▼
⑧ 推送 Git
│ git push --follow-tags
│
▼
⑨ 验证发布
npm info my-lib
npm install my-lib@latest (在新项目中测试)可以编写一个发布脚本来自动化这个流程:
json
{
"scripts": {
"prepublishOnly": "npm run lint && npm run test && npm run build",
"preversion": "npm run lint && npm run test",
"version": "npm run build && git add -A",
"postversion": "git push --follow-tags"
}
}4.5 自动化发布
方案一:GitHub Actions + npm publish
yaml
name: Publish
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}GitHub Actions 自动发布流程:
开发者本地 GitHub npm Registry
│ │ │
│ git tag v1.2.3 │ │
│ git push --tags │ │
│ ─────────────────────► │ │
│ 触发 Actions │
│ │ │
│ ┌──────┴──────┐ │
│ │ npm ci │ │
│ │ npm test │ │
│ │ npm build │ │
│ │ npm publish │ ──────────────────► │
│ └──────┬──────┘ │
│ │ 包发布成功
│ Actions 完成 │
│ │ │方案二:Changesets + CI
Changesets 是由 Atlassian 维护的版本管理和发布工具,特别适合 monorepo 项目。
bash
npm install @changesets/cli -D
npx changeset initChangesets 的工作流:
Changesets 工作流:
① 开发者在 PR 中添加 changeset
│ npx changeset
│ 选择要发布的包
│ 选择版本类型(patch/minor/major)
│ 填写变更描述
│
▼ 生成 .changeset/xxx.md
② PR 合并到 main
③ CI 自动执行 changeset version
│ 消费所有 changeset 文件
│ 更新 package.json 版本号
│ 更新 CHANGELOG.md
│
▼ 创建 Release PR
④ 合并 Release PR
⑤ CI 自动执行 changeset publish
│ npm publish
│ 创建 Git Tag
│
▼ 发布完成GitHub Actions 配置 Changesets:
yaml
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
- run: npm run build
- uses: changesets/action@v1
with:
publish: npx changeset publish
version: npx changeset version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}changeset 文件示例(.changeset/happy-lion.md):
markdown
---
"@myorg/utils": minor
"@myorg/core": patch
---
Added new date formatting utilities and fixed a bug in string helpers.五、私有包与 Scope
5.1 @scope/package-name 命名规范
Scoped 包是 npm 的命名空间机制,通过 @scope/ 前缀将包组织在同一个命名空间下:
Scoped 包 vs 非 Scoped 包:
非 Scoped 包:
lodash ← 全局唯一名称
react ← 先到先得
utils ← 常见名称难以获取
Scoped 包:
@babel/core ← 属于 babel 组织
@vue/compiler-sfc ← 属于 vue 组织
@mycompany/utils ← 属于 mycompany 组织Scoped 包的特性:
| 特性 | 非 Scoped 包 | Scoped 包 |
|---|---|---|
| 默认可见性 | public | private(需付费或 --access public) |
| 命名冲突 | 容易冲突 | 仅在 scope 内唯一 |
| 安装方式 | npm i lodash | npm i @scope/pkg |
| 发布命令 | npm publish | npm publish --access public |
| 目录结构 | node_modules/lodash | node_modules/@scope/pkg |
5.2 私有 npm Registry
在企业环境中,通常需要搭建私有 Registry 来托管内部包。
Verdaccio
Verdaccio 是最流行的开源私有 npm Registry,支持缓存公共 npm 包:
Verdaccio 代理架构:
┌────────────┐ npm install ┌──────────────┐
│ 开发者 │ ──────────────► │ Verdaccio │
│ npm client │ │ 私有 Registry │
└────────────┘ └──────┬───────┘
│
┌───────────────────┤
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 私有包存储 │ │ npm 公共 │
│ @company/xxx │ │ Registry 代理 │
│ 本地磁盘/数据库 │ │ 缓存公共包 │
└──────────────┘ └──────────────┘bash
npm install -g verdaccio
verdaccioyaml
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@company/*':
access: $authenticated
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
proxy: npmjsGitHub Packages
json
{
"name": "@myorg/my-package",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}npm Organizations
npm 官方提供的 Organization 功能支持团队级别的私有包管理:
npm Organizations 权限模型:
Organization: @mycompany
│
├── Team: developers
│ ├── read: @mycompany/*
│ └── write: @mycompany/utils, @mycompany/core
│
├── Team: admins
│ └── admin: @mycompany/*
│
└── Team: readonly
└── read: @mycompany/shared-types5.3 .npmrc 配置私有源
.npmrc 是 npm 的运行时配置文件,可以配置在不同层级:
.npmrc 配置层级(优先级从高到低):
项目级别: /project/.npmrc ← 最高优先级
用户级别: ~/.npmrc
全局级别: $PREFIX/etc/npmrc
内置级别: /path/to/npm/npmrc ← 最低优先级常见的 .npmrc 配置:
ini
registry=https://registry.npmmirror.com/
@mycompany:registry=https://npm.mycompany.com/
//npm.mycompany.com/:_authToken=${NPM_TOKEN}
@myorg:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Scoped Registry 路由:
npm install lodash
→ 使用默认 registry → https://registry.npmmirror.com/
npm install @mycompany/utils
→ 匹配 @mycompany scope → https://npm.mycompany.com/
npm install @myorg/core
→ 匹配 @myorg scope → https://npm.pkg.github.com/六、npm 包开发最佳实践
6.1 README 规范
一个优秀的 npm 包 README 应包含以下部分:
README 结构模板:
# 包名
一句话描述这个包做什么。
## 特性
- 特性 1
- 特性 2
## 安装
npm install / yarn add / pnpm add
## 快速开始
最简单的使用示例
## API 文档
详细的 API 说明
## 配置选项
可配置参数说明
## 常见问题
FAQ
## 贡献指南
如何参与开发
## License
开源协议6.2 CHANGELOG 自动生成
手动维护 CHANGELOG 容易遗漏且效率低下,推荐使用工具自动生成。
Conventional Commits 规范
提交信息格式:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
type 类型:
feat: 新功能
fix: Bug 修复
docs: 文档变更
style: 代码格式(不影响逻辑)
refactor: 重构
perf: 性能优化
test: 测试
chore: 构建/工具变更
示例:
feat(auth): add OAuth2 login support
fix(api): handle null response correctly
feat!: remove deprecated v1 API ← ! 表示 Breaking Changeconventional-changelog
bash
npm install conventional-changelog-cli -Djson
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
}
}Changesets
Changesets 在版本管理的同时自动生成 CHANGELOG,特别适合 monorepo:
bash
npx changeset
npx changeset version执行 changeset version 后,会自动在每个包目录下生成或更新 CHANGELOG.md。
6.3 包体积优化
npm 包的体积直接影响用户的安装速度和应用的打包大小。
Tree Shaking 支持
Tree Shaking 是指打包工具在构建时移除未使用的代码。要让你的包支持 Tree Shaking,需要满足以下条件:
Tree Shaking 生效条件:
① 提供 ESM 格式产物
│ module: "./dist/index.mjs"
│ 或 exports.import
│
② 声明 sideEffects
│ "sideEffects": false
│ 或指定有副作用的文件
│ "sideEffects": ["./dist/styles.css"]
│
③ 避免副作用代码
│ 不要在模块顶层执行有副作用的操作
│ 例如:修改全局变量、发起请求等
│
④ 使用命名导出
│ export { foo, bar }
│ 而不是 export default { foo, bar }sideEffects 对 Tree Shaking 的影响:
用户代码:
import { Button } from 'my-ui-lib'
有 sideEffects: false 时:
┌────────────────────┐
│ my-ui-lib │
│ ├── Button ✅ 保留 │
│ ├── Input ❌ 移除 │ ← 未使用,被 tree-shake
│ ├── Modal ❌ 移除 │ ← 未使用,被 tree-shake
│ └── Table ❌ 移除 │ ← 未使用,被 tree-shake
└────────────────────┘
没有 sideEffects 声明时:
┌────────────────────┐
│ my-ui-lib │
│ ├── Button ✅ 保留 │
│ ├── Input ⚠️ 保留 │ ← 打包工具不确定是否有副作用
│ ├── Modal ⚠️ 保留 │ ← 为安全起见全部保留
│ └── Table ⚠️ 保留 │ ← 导致包体积膨胀
└────────────────────┘按需导入设计
对于组件库等大型包,推荐通过子路径导出实现按需导入:
json
{
"exports": {
".": {
"import": "./dist/index.mjs"
},
"./button": {
"import": "./dist/button.mjs"
},
"./input": {
"import": "./dist/input.mjs"
}
}
}typescript
import { Button } from 'my-ui-lib/button'6.4 测试:Vitest 单元测试
发布前的测试是保证包质量的关键环节。Vitest 是当前最推荐的测试框架,与 Vite 生态无缝集成。
bash
npm install vitest -Dtypescript
import { describe, it, expect } from 'vitest'
import { formatDate, parseDate } from '../src/index'
describe('formatDate', () => {
it('should format date with default pattern', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('2024-01-15')
})
it('should format date with custom pattern', () => {
const date = new Date('2024-01-15')
expect(formatDate(date, 'MM/DD/YYYY')).toBe('01/15/2024')
})
it('should throw on invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow()
})
})
describe('parseDate', () => {
it('should parse ISO string', () => {
const result = parseDate('2024-01-15')
expect(result.getFullYear()).toBe(2024)
expect(result.getMonth()).toBe(0)
expect(result.getDate()).toBe(15)
})
})json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}发布前建议配合 CI 运行测试覆盖率检查,确保核心功能都有测试覆盖。
6.5 文档:API 文档生成
TypeDoc
TypeDoc 可以从 TypeScript 源码中的 JSDoc 注释和类型信息自动生成 API 文档:
bash
npm install typedoc -Djson
{
"scripts": {
"docs": "typedoc src/index.ts --out docs"
}
}TypeDoc 配置文件(typedoc.json):
json
{
"entryPoints": ["src/index.ts"],
"out": "docs",
"name": "My Library",
"includeVersion": true,
"excludePrivate": true,
"excludeInternal": true
}七、面试高频问题
7.1 npm publish 发布一个包的完整流程是什么?
回答思路:从准备阶段到发布后验证,展示完整的工程化意识。
完整流程包括:
- 开发阶段:编写源码(TypeScript),配置 tsconfig.json,编写测试用例
- 配置 package.json:设置 name、version、main、module、types、exports、files、sideEffects 等字段
- 构建:使用 tsup/unbuild/Rollup 构建出 CJS + ESM 双格式产物以及类型声明文件
- 检查:
npm pack --dry-run确认打包内容,npx publint检查 package.json 配置,npx are-the-types-wrong检查类型声明 - 测试:运行完整测试套件确保所有用例通过
- 更新版本:
npm version patch/minor/major - 发布:
npm publish(或npm publish --access public对于 scoped 包) - 推送:
git push --follow-tags - 验证:在一个干净的项目中
npm install安装发布的包,验证功能正常
7.2 package.json 中 main、module、exports 的区别和优先级是什么?
回答思路:从历史演进角度说明三者的关系,并明确优先级规则。
main:最早的模块入口字段,Node.js 原生支持。最初只用于指向 CJS 入口。module:社区约定(由 Rollup 提出),用于指向 ESM 入口。Node.js 不识别此字段,仅打包工具(Webpack/Rollup/Vite)使用。exports:Node.js 12.7.0 正式引入的官方标准,支持条件导出(import/require/types)和子路径映射。
优先级:当 exports 存在时,main 和 module 被完全忽略。Node.js 解析优先级为 exports > main,打包工具解析优先级为 exports > module > main。
建议三者都配置以兼容不同的消费环境。
7.3 什么是 Dual Package Hazard?如何解决?
回答思路:解释问题本质,给出具体的解决方案。
Dual Package Hazard 是指当一个包同时提供 CJS 和 ESM 两种格式时,可能在同一个应用中被加载两次(一次通过 require,一次通过 import),产生两个独立的模块实例。这会导致单例模式失效、instanceof 检查失败、状态不共享等问题。
解决方案:
- ESM Wrapper 模式:ESM 入口文件不包含逻辑,只是对 CJS 模块的重导出,确保底层只有一份代码。
- 状态隔离:将共享状态放在 CJS 模块中,ESM 只是引用它。
- 只发布 ESM:如果目标用户群体都支持 ESM,可以只发布 ESM 格式。
7.4 如何让你的 npm 包支持 Tree Shaking?
回答思路:从打包工具的视角解释 Tree Shaking 的前提条件。
四个关键条件:
- 提供 ESM 格式产物:Tree Shaking 依赖于 ESM 的静态分析特性(import/export 在编译时确定),CJS 的 require 是动态的,无法静态分析。
- 声明 sideEffects 字段:在 package.json 中设置
"sideEffects": false,告诉打包工具所有模块都是纯净的、可以安全移除。如果有 CSS 等副作用文件,使用数组指定。 - 使用命名导出:
export { foo, bar }比export default obj更容易被 Tree Shaking,因为打包工具可以精确追踪哪些命名导出被使用。 - 避免模块顶层副作用:不要在模块顶层执行改变全局状态的操作。
7.5 npm 的 dist-tag 是什么?为什么预发布版本一定要指定 tag?
回答思路:从事故场景出发,说明 tag 的作用和重要性。
dist-tag 是 npm 的版本标签系统,默认标签是 latest。当用户执行 npm install pkg 时,实际安装的是 latest 标签对应的版本。
如果发布 2.0.0-beta.1 时忘记指定 --tag beta,那么 latest 标签会指向这个 beta 版本,所有执行 npm install pkg 的用户都会安装到不稳定的 beta 版本——这是一个典型的发布事故。
因此预发布版本必须使用 npm publish --tag beta(或 alpha/rc/next),确保 latest 标签始终指向稳定版本。
7.6 Changesets 和 npm version 有什么区别?Changesets 适合什么场景?
回答思路:对比两者的工作模式,突出 Changesets 在 monorepo 中的优势。
npm version 是 npm 内置的版本管理命令,适合单包项目。它直接修改 version 字段、创建 git commit 和 tag,是一步到位的操作。
Changesets 是一个独立的版本管理工具,核心理念是"intent-based versioning"(基于意图的版本管理)。开发者在 PR 中通过 npx changeset 声明"这个 PR 对哪些包做了什么级别的变更",这些意图被记录为 .changeset/*.md 文件。在发布时,Changesets 会聚合所有 changeset 文件,自动计算最终版本号、更新 CHANGELOG、发布到 npm。
Changesets 特别适合 monorepo 场景——当一个 PR 同时修改了多个包时,Changesets 可以精确地管理每个包的版本变更和依赖关系。
7.7 如何搭建企业内部的私有 npm Registry?
回答思路:给出方案选型和核心配置。
常用方案:
- Verdaccio:开源、轻量、部署简单,支持代理公共 npm Registry(miss cache 时自动从上游拉取),适合中小团队。
- GitHub Packages:与 GitHub 深度集成,适合使用 GitHub 作为代码仓库的团队。
- npm Organizations:npm 官方付费服务,提供团队管理和私有包功能。
- Artifactory / Nexus:企业级制品管理平台,支持 npm 在内的多种包格式。
配置方式:在项目的 .npmrc 中使用 scope 路由,将私有 scope 指向私有 Registry,其他包走公共 Registry。
7.8 npx publint 和 npx are-the-types-wrong 分别检查什么?
回答思路:说明两个工具的检查维度和使用场景。
publint 检查 package.json 的发布配置是否正确,包括:
- exports 字段的条件顺序是否正确
- main/module/types 指向的文件是否存在
- ESM/CJS 格式是否正确
- 文件扩展名是否与格式匹配
are-the-types-wrong(简称 attw)专门检查类型声明文件在不同 moduleResolution 模式下是否能正确解析,包括:
node、node16、bundler模式下类型是否可找到- CJS 和 ESM 入口是否都有对应的类型声明
- 类型声明的导出是否与运行时导出一致
两者配合使用可以在发布前发现绝大部分配置错误。
八、延伸阅读
- npm 官方文档 - package.json
- Node.js 文档 - Packages
- Node.js 文档 - Conditional Exports
- SemVer 规范
- tsup 文档
- unbuild 文档
- Changesets 文档
- publint
- Are the types wrong?
- Verdaccio 文档
- Modern Guide to Packaging Your JavaScript Library
- Best practices for creating a modern npm package
- Dual Package Hazard - Node.js
- TypeDoc 文档
- Conventional Commits