npm 上有超过 300 万个包,但其中真正具备生产级质量的 TypeScript SDK 不到 5%。根据 State of JS 2025 调查,78% 的 TypeScript 开发者曾在项目中遇到 npm 包的类型缺失、格式不兼容或打包混乱的问题。当 ESM 成为 Node.js 的默认模块系统,当 Tree Shaking 成为前端性能优化的基本要求,构建一个「真正好用」的 npm 包已经不再是 tsc && npm publish 那么简单了。本文将从工程化角度出发,带你用 tsup + Vitest + Changesets + GitHub Actions 构建一个企业级 TypeScript npm 包的完整流程。
📌 **本文定位:**这不是「Hello World」级别的入门教程。如果你已经会写 TypeScript 但总觉得发布的包「不够专业」——类型声明有问题、ESM 和 CJS 打架、CI/CD 没配好——这篇文章就是为你写的。
🔧 一、项目架构与工程化配置
1.1 项目目录结构设计
一个高质量的 npm 包,目录结构本身就体现了工程化水平。以下是经过大量开源项目验证的最佳实践:
my-sdk/
├── src/ # 源代码
│ ├── index.ts # 入口文件(统一导出)
│ ├── core/ # 核心逻辑
│ │ ├── client.ts
│ │ └── types.ts
│ └── utils/ # 工具函数
│ ├── validate.ts
│ └── format.ts
├── tests/ # 测试文件
│ ├── client.test.ts
│ └── utils.test.ts
├── tsconfig.json # TypeScript 配置
├── tsup.config.ts # 打包配置
├── vitest.config.ts # 测试配置
├── .changeset/ # Changesets 配置
│ └── config.json
├── .github/
│ └── workflows/
│ └── release.yml # 自动发布 CI
├── package.json
├── README.md
├── LICENSE
└── CHANGELOG.md
💡 提示:
src/index.ts是整个包的「门面」——所有对外暴露的 API 都应该从这里统一导出。不要让消费者从深层路径导入(import { foo } from 'my-sdk/core/utils/foo'),而是提供扁平化的导出(import { foo } from 'my-sdk')。
1.2 package.json 关键字段
package.json 是 npm 包的「身份证」,以下是必须正确配置的字段:
// package.json — TypeScript npm 包的核心配置
{
"name": "@your-org/my-sdk",
"version": "0.0.0",
"description": "A production-ready TypeScript SDK",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
"sideEffects": false,
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
这里有几个容易踩坑的字段,我逐一说明:
| 字段 | 作用 | 常见坑点 | 推荐做法 |
|---|---|---|---|
type |
模块系统类型 | 设为 "module" 后 .js 文件被视为 ESM |
✅ 新包统一用 "module" |
exports |
条件导出 | types 必须放在每个条件的第一位 |
✅ 始终把 types 放最前面 |
files |
发布文件白名单 | 忘了写导致源码泄露 | ✅ 只包含 dist 和必要文件 |
sideEffects |
Tree Shaking 标记 | 没设置导致 Webpack 无法优化 | ✅ 设为 false |
version |
版本号 | 手动管理容易出错 | ✅ 交给 Changesets 管理 |
⚠️ 警告:
exports字段中types的顺序至关重要!TypeScript 的模块解析会按顺序匹配,如果types放在default之后,部分工具链(尤其是老版本ts-node)会找不到类型声明。
1.3 TypeScript 配置
// tsconfig.json — 专为 npm 包发布的 TypeScript 配置
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
⚡ 关键结论:moduleResolution: "bundler" 是 2026 年 TypeScript 包开发的最佳选择。它既支持 exports 字段的条件导出,又兼容主流打包工具(tsup、Vite、Webpack 5)。不要用 "node" ——那是 CommonJS 时代的遗留物。
🚀 二、打包策略:tsup 配置与 ESM/CJS 双格式发布
2.1 为什么选 tsup?
2026 年的 TypeScript 打包方案对比:
| 打包工具 | 底层引擎 | ESM 支持 | CJS 支持 | DTS 生成 | 配置复杂度 | 推荐度 |
|---|---|---|---|---|---|---|
| tsup | esbuild | ✅ 原生 | ✅ 原生 | ✅ 内置 | ⭐ 低 | ✅ 强烈推荐 |
| rollup | 自研 | ✅ 原生 | ✅ 插件 | ✅ 插件 | ⭐⭐⭐ 高 | 适合复杂场景 |
| unbuild | rollup+mkdist | ✅ 原生 | ✅ 原生 | ✅ 内置 | ⭐⭐ 中 | Nuxt 生态推荐 |
| tsc | TypeScript | ✅ | ✅ | ✅ | ⭐ 低 | ❌ 不推荐单独使用 |
| webpack | 自研 | ✅ 插件 | ✅ 原生 | ❌ 需插件 | ⭐⭐⭐⭐ 很高 | ❌ 不适合库开发 |
tsup 的核心优势是零配置即可用,深度配置可定制。它基于 esbuild 构建,速度是 rollup 的 10-100 倍,同时内置了 DTS(类型声明)生成能力。
2.2 tsup 配置详解
// tsup.config.ts — 生产级 tsup 配置
import { defineConfig } from 'tsup'
export default defineConfig([
// 主构建:ESM + CJS 双格式
{
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // 自动生成 .d.ts 和 .d.cts
sourcemap: true, // 生成 source map
clean: true, // 构建前清理 dist
minify: false, // 库不需要压缩(消费者自己压缩)
target: 'es2022', // 目标运行环境
splitting: true, // 代码分割(ESM 支持)
treeshake: true, // Tree Shaking
outExtension({ format }) {
return {
js: format === 'esm' ? '.js' : '.cjs'
}
},
// 外部依赖:不打包 node_modules
external: [],
// banner:在输出文件顶部添加注释
banner: {
js: '// my-sdk - MIT License'
}
}
])
💡 提示:
minify: false是库开发的关键设置。压缩应该由最终应用的打包工具(Webpack、Vite 等)负责。如果你的库被压缩了,消费者的打包工具就无法进行有效的 Tree Shaking 和 Dead Code Elimination。
2.3 构建产物验证
构建完成后,务必验证产物结构:
# 构建并检查产物
npm run build && tree dist/
正确的产物结构应该是:
dist/
├── index.js # ESM 格式
├── index.cjs # CJS 格式
├── index.d.ts # ESM 类型声明
├── index.d.cts # CJS 类型声明
├── index.js.map # ESM source map
└── index.cjs.map # CJS source map
⚠️ **警告:**如果你只看到
.js和.d.ts,没有.cjs和.d.cts,说明 CJS 格式的产物没有生成。这会导致使用require()的消费者(包括大量 Node.js 项目)无法使用你的包。
2.4 验证 ESM/CJS 兼容性
在发布前,用以下脚本验证两种模块系统都能正确导入:
// test-import.mjs — 验证 ESM 导入
import { createClient } from './dist/index.js'
console.log('✅ ESM import works:', typeof createClient)
// test-import.cjs — 验证 CJS 导入
const { createClient } = require('./dist/index.cjs')
console.log('✅ CJS import works:', typeof createClient)
🧪 三、测试体系:Vitest 单元测试与类型测试
3.1 Vitest 配置
// vitest.config.ts — Vitest 测试配置
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true, // 全局 API(describe, it, expect)
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', 'tests/'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
})
3.2 单元测试实战
// tests/client.test.ts — SDK 客户端的完整测试
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createClient, MySDKError } from '../src/index'
describe('createClient', () => {
it('should create a client with default options', () => {
const client = createClient({ apiKey: 'test-key' })
expect(client).toBeDefined()
expect(client.baseUrl).toBe('https://api.example.com')
})
it('should throw MySDKError for invalid API key', () => {
expect(() => createClient({ apiKey: '' })).toThrow(MySDKError)
expect(() => createClient({ apiKey: '' })).toThrow('API key is required')
})
it('should allow custom baseUrl', () => {
const client = createClient({
apiKey: 'test-key',
baseUrl: 'https://custom.api.com'
})
expect(client.baseUrl).toBe('https://custom.api.com')
})
})
describe('Client.fetch', () => {
let client: ReturnType<typeof createClient>
beforeEach(() => {
client = createClient({ apiKey: 'test-key' })
// Mock global fetch
globalThis.fetch = vi.fn()
})
it('should include Authorization header', async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'ok' }), { status: 200 })
)
await client.fetch('/items')
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://api.example.com/items',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer test-key'
})
})
)
})
it('should retry on 5xx errors with exponential backoff', async () => {
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(new Response('Error', { status: 503 }))
.mockResolvedValueOnce(new Response('Error', { status: 503 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'ok' }), { status: 200 })
)
const result = await client.fetch('/items', { retries: 3 })
expect(result).toEqual({ data: 'ok' })
expect(globalThis.fetch).toHaveBeenCalledTimes(3)
})
})
3.3 类型测试(Type Testing)
单元测试只能验证运行时行为,类型测试则验证类型推导是否正确。这是高质量 TypeScript 包的标志:
// tests/types.test-d.ts — 类型级别测试
import { describe, it, expectTypeOf } from 'vitest'
import { createClient, type ClientResponse } from '../src/index'
describe('Type tests', () => {
it('createClient should return a typed client', () => {
const client = createClient({ apiKey: 'test' })
expectTypeOf(client.fetch).toBeFunction()
expectTypeOf(client.baseUrl).toBeString()
})
it('ClientResponse should have correct generic types', () => {
type UserResponse = ClientResponse<{ id: string; name: string }>
expectTypeOf<UserResponse>().toHaveProperty('data')
expectTypeOf<UserResponse>().toHaveProperty('status')
expectTypeOf<UserResponse['data']>().toMatchTypeOf<{ id: string; name: string }>()
})
})
⚡ **关键结论:**类型测试是区分「能用的包」和「好用的包」的分水岭。消费者最痛苦的体验莫过于「类型声明和实际行为不一致」。用 expectTypeOf 可以在 CI 中自动捕获这类回归。
📦 四、版本管理与变更日志:Changesets 自动化
4.1 为什么不用 npm version?
手动管理版本号(或用 npm version)有三个致命问题:
- ❌ 无法自动生成 CHANGELOG
- ❌ 无法批量管理 monorepo 中多个包的版本
- ❌ 无法在 PR Review 阶段就确定版本级别
Changesets 解决了所有这些问题。它的工作流是:
- 开发时运行
npx changeset创建变更描述 - PR 合并后,CI 自动创建「版本发布 PR」
- 合并发布 PR 后,CI 自动发布到 npm
4.2 Changesets 配置
// .changeset/config.json — Changesets 配置
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "your-org/my-sdk" }
],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
# 安装依赖
npm install -D @changesets/cli @changesets/changelog-github
# 初始化(自动生成 .changeset/ 目录)
npx changeset init
4.3 开发工作流
# 1. 完成代码修改后,创建 changeset
npx changeset
# 交互式选择:patch / minor / major
# 输入变更描述
# 2. 将 .changeset/*.md 文件一起提交到 PR
git add .changeset/
git commit -m "feat: add retry logic to client"
# 3. PR 合并后,CI 会自动:
# - 汇总所有 changeset
# - 更新 package.json 版本号
# - 更新 CHANGELOG.md
# - 创建 Release PR
# 4. 合并 Release PR 后,CI 自动 npm publish
📌 **记住:**每个 PR 都应该带一个 changeset 文件。在 CI 中配置
changeset status --since=main检查,强制要求每个 PR 都声明变更级别。这会让你的版本管理从「混乱」变为「有序」。
🤖 五、CI/CD 自动化:GitHub Actions 发布流水线
5.1 完整的发布工作流
# .github/workflows/release.yml — 自动化发布流水线
name: Release
on:
push:
branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run test -- --coverage
- run: npm run typecheck
release:
name: Release
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- name: Create Release PR or Publish
uses: changesets/action@v1
with:
publish: npx changeset publish
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
5.2 发布前检查清单
在 CI 中添加一个「prepublishOnly」脚本,确保每次发布前自动执行完整检查:
{
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check src/",
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
"changeset": "changeset",
"version": "changeset version",
"release": "npm run build && changeset publish"
}
}
⚠️ **警告:**永远不要在没有运行测试的情况下发布。
prepublishOnly脚本是你的最后一道防线。即使 CI 已经跑过测试,本地发布时这个脚本也能防止「手滑」导致的灾难。
💡 六、进阶优化与最佳实践
6.1 Peer Dependencies 管理
如果你的包依赖某个框架(如 React、Vue),应该用 peerDependencies 而不是 dependencies:
{
"peerDependencies": {
"react": ">=18.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": false
}
},
"devDependencies": {
"react": "^18.3.0"
}
}
💡 提示:
peerDependencies不会被安装,它只是告诉消费者「你需要自己安装这个依赖」。同时在devDependencies中也声明同一个包,用于本地开发和测试。
6.2 Bundle Size 监控
使用 size-limit 在 CI 中自动检查包体积是否超标:
{
"scripts": {
"size": "size-limit",
"size:check": "size-limit --json"
},
"size-limit": [
{
"path": "dist/index.js",
"limit": "5 kB",
"import": "{ createClient }"
},
{
"path": "dist/index.cjs",
"limit": "8 kB"
}
]
}
| 格式 | 文件大小 | 说明 |
|---|---|---|
ESM dist/index.js |
≤ 5 kB | ✅ 推荐,支持 Tree Shaking |
CJS dist/index.cjs |
≤ 8 kB | ✅ 兼容旧项目 |
DTS dist/index.d.ts |
不限 | 类型声明不影响运行时 |
6.3 常见坑点与避坑指南
在实际的 npm 包开发中,以下是最容易踩的坑。这些问题在 GitHub Issues 中出现频率极高,但其实都可以通过工程化手段提前规避。
❌ 坑点 1:在 exports 中遗漏 types 条件
// ❌ 错误写法 — TypeScript 4.x 可能找不到类型
{
"exports": {
".": "./dist/index.js"
}
}
// ✅ 正确写法 — 明确指定类型声明路径
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
❌ 坑点 2:源码中使用 __dirname 等 CJS 全局变量
// ❌ 错误写法 — ESM 环境下 __dirname 未定义
const configPath = path.join(__dirname, 'config.json')
// ✅ 正确写法 — 使用 import.meta.url
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const configPath = join(__dirname, 'config.json')
❌ 坑点 3:在库代码中使用 console.log
// ❌ 错误写法 — 污染消费者的控制台
export function processData(data: unknown) {
console.log('Processing:', data)
return transform(data)
}
// ✅ 正确写法 — 使用 debug 包或可选的 logger
import createDebug from 'debug'
const debug = createDebug('my-sdk:process')
export function processData(data: unknown, options?: { logger?: (msg: string) => void }) {
debug('Processing: %O', data)
options?.logger?.(`Processing ${JSON.stringify(data)}`)
return transform(data)
}
6.5 README 工程化:让你的包被「看见」
一个 npm 包能不能被社区采用,README 的质量至少占 30% 的权重。以下是一个高质量 README 的结构模板:
# @your-org/my-sdk
[](https://npm.im/@your-org/my-sdk)
[](https://bundlephobia.com/package/@your-org/my-sdk)
[](https://github.com/your-org/my-sdk/actions)
A production-ready TypeScript SDK for [purpose].
## Features
- ✅ Full TypeScript support with strict types
- ✅ ESM + CJS dual format
- ✅ Zero dependencies / Tree-shakable
- ✅ Works in Node.js 18+, Deno, Bun, and browsers
## Quick Start
npm install @your-org/my-sdk
## API Reference
[Auto-generated by TypeDoc]
## Contributing
[Standard contributing guide]
💡 **提示:**Bundle size badge 是一个强大的信任信号。如果你的包 gzip 后小于 5KB,一定要展示出来——这是消费者选择轻量级库时最看重的指标之一。
6.6 发布后的监控与维护
发布不是终点,而是起点。以下是发布后需要持续关注的事项:
- ✅ 监控 npm 下载量 — 用
npm-stat.com追踪趋势,判断包的采用情况 - ✅ 设置 GitHub Issue 模板 — 让用户报告 Bug 时提供环境信息和复现步骤
- ✅ 定期更新依赖 — 用 Dependabot 或 Renovate 自动创建依赖更新 PR
- ✅ 关注兼容性问题 — Node.js、TypeScript、打包工具的版本升级可能引入 Breaking Change
- ❌ 不要忽略 Issues — 社区包的生命力在于维护者的响应速度
一个常见的误区是「发布后就不管了」。实际上,npm 包的维护成本主要体现在:回复 Issues、适配新版本 Node.js/TypeScript、修复安全漏洞。如果你无法长期维护,建议在 README 中明确标注维护状态(如使用 stale bot 或 ARCHIVED.md)。
6.4 完整的 SDK 入口文件示例
// src/index.ts — 完整的 SDK 入口文件
export { createClient } from './core/client.js'
export type { ClientOptions, ClientResponse } from './core/types.js'
export { MySDKError, TimeoutError, RateLimitError } from './core/errors.js'
export { validateApiKey, formatResponse } from './utils/index.js'
// 版本号(由构建工具注入)
export const VERSION = __VERSION__
📌 **记住:**入口文件应该是「只导出,不执行」。不要在入口文件中包含任何副作用代码(如初始化连接、启动定时器等)。消费者 import 你的包时,不应该发生任何意料之外的事情。
✅ 总结与工具推荐
构建一个企业级 TypeScript npm 包,核心工程化链路如下:
- 项目初始化 →
tsconfig.json+ 目录结构设计 - 打包构建 → tsup(ESM + CJS 双格式 + DTS)
- 测试保障 → Vitest(单元测试 + 类型测试)
- 版本管理 → Changesets(自动生成 CHANGELOG + 版本号)
- 自动发布 → GitHub Actions(测试 → 版本 → 发布)
| 工具 | 用途 | 替代方案 |
|---|---|---|
| tsup | 打包构建 | rollup, unbuild |
| Vitest | 单元测试 | Jest, uvu |
| Changesets | 版本管理 | semantic-release, auto |
| Biome | 代码检查 | ESLint + Prettier |
| size-limit | 体积监控 | bundlephobia (手动) |
| typedoc | API 文档 | api-extractor |
⚡ **关键结论:**npm 包的质量不是「功能多不多」,而是「用起来舒不舒服」。类型声明精确、ESM/CJS 双格式支持、CHANGELOG 清晰、CI 自动化——这些「看不见的工程化」才是决定一个包能否被社区广泛采用的关键因素。
💡 **提示:**如果你正在开发一个 CLI 工具而不是库,还需要额外处理
bin字段、#!/usr/bin/env nodeshebang、以及跨平台兼容性。但对于 SDK/库类型的包,以上流程已经覆盖了 95% 的场景。