TypeScript npm 包开发完全指南:从零到发布的工程化实战

手把手教你用 TypeScript 构建高质量 npm 包,涵盖 tsup 打包、ESM/CJS 双格式发布、Vitest 测试、Changesets 版本管理、GitHub Actions 自动化发布全流程,附完整可运行代码。

前端开发 2026-05-29 18 分钟

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 解决了所有这些问题。它的工作流是:

  1. 开发时运行 npx changeset 创建变更描述
  2. PR 合并后,CI 自动创建「版本发布 PR」
  3. 合并发布 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

[![npm version](https://img.shields.io/npm/v/@your-org/my-sdk)](https://npm.im/@your-org/my-sdk)
[![bundle size](https://img.shields.io/bundlephobia/minzip/@your-org/my-sdk)](https://bundlephobia.com/package/@your-org/my-sdk)
[![CI](https://github.com/your-org/my-sdk/actions/workflows/ci.yml/badge.svg)](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 包,核心工程化链路如下:

  1. 项目初始化tsconfig.json + 目录结构设计
  2. 打包构建 → tsup(ESM + CJS 双格式 + DTS)
  3. 测试保障 → Vitest(单元测试 + 类型测试)
  4. 版本管理 → Changesets(自动生成 CHANGELOG + 版本号)
  5. 自动发布 → 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 node shebang、以及跨平台兼容性。但对于 SDK/库类型的包,以上流程已经覆盖了 95% 的场景。

📚 相关文章