引言
「会写 TS 应用」和「会写 TS 库」是两件事。应用只需要「代码能跑」,库还要求「类型能给别人用」:别人 import 你的包时,npm install 后 .d.ts 要能正确解析、泛型要够灵活、API 演进不能破坏旧用户、被 tree-shaking 后不能留垃圾。这就是「库作者」视角。
本文系统讲 TS 库开发全流程:先讲 tsconfig 构建与声明文件生成,再深入 API 设计(泛型/重载/模块暴露),覆盖包导出入口(package.json 的 exports/main/types)、兼容性与 semver 演进、tree-shaking 与 sideEffects、发布流程(prepublishOnly/build/test)、声明文件测试,最后给工程实践清单。
前置:/typescript/(TS 基础)、/typescript-project-architecture-tsconfig/(模块与项目架构)、/typescript-build-performance-optimization/(构建)、/typescript-api-type-generation/(契约)。
目录
- 1. 库 vs 应用:视角差异
- 2. tsconfig 构建配置
- 3. 声明文件:d.ts 生成与测试
- 4. API 设计:泛型与重载
- 5. 包导出入口:exports/main/types
- 6. 兼容性与 semver 演进
- 7. tree-shaking 与 sideEffects
- 8. 发布流程与版本管理
- 9. 开源库的工程实践
- 10. 速查表
- 延伸阅读
1. 库 vs 应用:视角差异
1.1 关键差异
| 维度 | 应用代码 | 库代码 |
|---|---|---|
| 类型消费者 | 自己 | 别人(import 你的包) |
| 构建目标 | 可运行 | 可被引用(ESM+CJS+types) |
| API 责任 | 内部约定 | 公开契约,不可随意改 |
| 包体积 | 无所谓 | 影响用户 bundle |
| 兼容 | 单一环境 | 多构建/多 TS 版本 |
| 发布 | 部署 | 发布到 npm + 版本管理 |
1.2 库的「三件套」
一个合格 npm 包至少提供:
1. ESM 构建(现代,支持 tree-shaking)
2. CJS 构建(旧 Node/遗留工具兼容)
3. .d.ts 声明(类型可用)
package.json 用 exports 精确指向三套产物
1.3 心智模型
应用代码 = 「用别人的类型」
库代码 = 「提供别人能用的类型」
库的每一次导出,都是一份「不能再违约的契约」
一句话总结:应用只要自己跑得通,库要「别人能正确引用」——产物(ESM/CJS/types)与公开 API 都是承诺。
2. tsconfig 构建配置
2.1 库的 tsconfig
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}
2.2 三个关键开关
declaration: true → 生成 .d.ts(类型对用户可见)
declarationMap: true → 类型可跳转到源码(开发体验)
isolatedModules → 与 esbuild/tsc 转译兼容(防 import 误用)
2.3 ESM/CJS 双构建
# 方案一:tsc 双输出
tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json
# 方案二:tsc 出 ESM + tsup/esbuild 转 CJS
tsup src/index.ts --format esm,cjs --dts
一句话总结:库构建三开关(declaration/declarationMap/isolatedModules)+ 双产物(ESM+CJS)——用 tsc 出类型与 ESM、tsup/esbuild 补 CJS。
3. 声明文件:d.ts 生成与测试
3.1 自动生成 vs 手写
99% 场景:declaration: true 自动生成(从源码推导)
手写 .d.ts 场景:
- 包裹无类型的 JS 依赖
- 对外类型比实现更「简练/稳定」
- 环境声明(全局变量、CSS modules)
3.2 生成 vs 手写的对照
// 源码 src/index.ts
export function join(a: string, b: string): string {
return a + b
}
// 自动生成 dist/index.d.ts
export declare function join(a: string, b: string): string
3.3 声明文件测试(dtslint / attw)
1. attw(Are the Types Wrong):
检查「发布包的类型解析」在 Node16/bundler 等解析模式是否正确
2. 类型测试(tsd/expect-type):
在测试里断言类型(expectTypeOf(x).toEqualTypeOf<Foo>())
3. 冒烟:在示例工程里 import 你的 dist,跑 typecheck
// test/types.test-d.ts(tsd)
import { expectType } from 'tsd'
import { join } from '../dist'
expectType<string>(join('a', 'b'))
// 参数类型错误则本文件编译失败 → 测试失败
一句话总结:声明文件靠
declaration自动生成,手写只留给「包裹无类型依赖 / 对外契约简练」;用 tsd/expect-type 断言类型、attw 检查解析,保证发布包的 d.ts 真能被人用。
4. API 设计:泛型与重载
4.1 让泛型「推导」而非「标注」
// 好:泛型从参数推导,调用者无需标注
export function identity<T>(x: T): T { return x }
const a = identity('hi') // a: 'hi'(字面量推导)
// 更精确:const 断言保留字面量
export function tuple<const T extends readonly unknown[]>(...xs: T): T {
return xs
}
const t = tuple('a', 1, true) // t: readonly ['a', 1, true]
4.2 重载:让 API 收窄
// 无重载:调用者要自己收窄
export function pick(o: object, k: string): unknown
// 有重载:按参数组合给精确返回
export function pick<T, K extends keyof T>(o: T, k: K): T[K]
export function pick<T, K extends keyof T>(o: T, ks: readonly K[]): Pick<T, K>
export function pick<T, K extends keyof T>(
o: T, k: K | readonly K[],
): T[K] | Pick<T, K> {
return Array.isArray(k)
? Object.fromEntries(k.map(x => [x, o[x]]))
: o[k]
}
4.3 模块暴露的纪律
1. 从 index 导出「公共面」,内部工具放 src/internal(不导出)
2. 用「导出类型而非实现」隐藏可变内部状态
3. 慎用 any:会污染用户类型
4. 方法 vs 属性:暴露「函数集合」而非可被改写的方法
一句话总结:库 API 设计 = 泛型让调用者零标注 + 重载收窄返回 + 只暴露公共面——「推导优先、收窄精确、隐藏实现」。
5. 包导出入口:exports/main/types
5.1 package.json 三件套
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
5.2 exports 的正确用法
exports 字段作用:
1. 精确控制「哪些子路径可被 import」(子模块白名单)
2. 按条件(import/require/types)分发产物
3. 未列出的子路径 → 禁止访问(防止内部泄漏)
顺序重要:types 放最前(TS 先解析类型)
错误写法:漏 types → TS 用户报「找不到声明」
5.3 子路径导出
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.js" }
}
}
一句话总结:
exports是包入口的「白名单 + 条件分发」——types/import/require 三条件、types 在前;只暴露想公开的子路径。
6. 兼容性与 semver 演进
6.1 semver 规则
major:破坏性变更(删 API、改签名、换类型含义)
minor:新增功能(向后兼容:加函数、加字段、加泛型约束放宽)
patch:修复 Bug(不改变任何可见行为/类型)
库纪律:
1. 破坏性类型变更 = major(即使用户运行时不报错)
2. 类型「放宽」可以 minor,类型「收紧」算破坏
3. 任何签名变化前先想「旧调用是否还能编译」
6.2 API 演进的操作
废弃:保留旧 API + @deprecated 标记(IDE 划线提示迁移)
替换:新名字 + 旧名字别名(过渡一个 minor)
删除:major 版本里删(废弃至少提前一个 major 通知)
类型演进优先「加法」:加可选字段/联合成员/新泛型参数默认值
6.3 检测破坏性变更
# api-extractor + api-diff:对每个版本生成 API 报告,diff 发现破坏
npx api-extractor run --local # 生成 .api.md 报告
# CI 中:旧报告 vs 新报告 diff → 破坏性变更即失败
一句话总结:semver 纪律 = 破坏性变更进 major、废弃要打 @deprecated 并过渡、用 api-extractor 的 API 报告 diff 兜底检测破坏——类型也是一种承诺。
7. tree-shaking 与 sideEffects
7.1 为什么重要
应用打包时,只应「保留实际用到的代码」
库要做三件事让 tree-shaking 生效:
1. 提供 ESM(tree-shaking 只对 ESM 有效)
2. 声明 sideEffects: false
3. 副作用代码放到模块顶层之外(或在内部隔离)
7.2 sideEffects 声明
{
"sideEffects": false,
"exports": {
"./styles.css": "./dist/styles.css"
}
}
sideEffects: false → 打包器可安全删除「未被 import 的导出」
若库有副作用(注入全局、注册 polyfill):
- 放单独子路径(如 ./polyfill)
- sideEffects 列白名单:["**/polyfill.js"]
7.3 写法的副作用纪律
// 反例:模块顶层有副作用(初始化),会被误判可删或删不掉
const cache = initGlobalCache() // ❌ 顶层副作用
// 正例:惰性初始化,副作用延迟到使用时
let cache: Cache | undefined
function getCache() { return cache ??= initGlobalCache() } // ✅
一句话总结:让 tree-shaking 生效 = 提供 ESM +
sideEffects: false+ 顶层无副作用;副作用代码走独立子路径或惰性初始化。
8. 发布流程与版本管理
8.1 发布前检查清单
# 完整流程
npm run build # 1. 构建(ESM + CJS + types)
npm test # 2. 跑测试
npm run typecheck # 3. 类型检查(含测试)
npm run lint # 4. lint
npx attw --pack . # 5. 检查发布包类型解析
node -e "require('.')" # 6. CJS 冒烟
node --input-type=module -e "import('.').then(...)" # 7. ESM 冒烟
npm publish # 8. 发布
8.2 用 prepublishOnly 自动化
{
"scripts": {
"prepublishOnly": "npm run build && npm test && npx attw --pack .",
"publish:patch": "npm version patch && npm publish",
"publish:minor": "npm version minor && npm publish",
"publish:major": "npm version major && npm publish"
}
}
8.3 版本管理工具
changesets / semantic-release:
1. PR 里写变更集(minor/major/patch)
2. 发布时自动更新版本号 + 生成 changelog
3. 保证「版本号 = 语义变更」的一致性
一句话总结:发布流程 = prepublishOnly 串起 build/test/attw/冒烟,用 changesets/semantic-release 让版本号自动对齐语义变更——发布前每一项检查都在门禁里。
9. 开源库的工程实践
9.1 质量门禁组合
单元测试 :vitest/jest(行为正确)
类型测试 :tsd/expect-type(类型正确)
类型解析检查:attw(发布包 d.ts 可解析)
API 报告 :api-extractor(检测破坏性变更)
示例冒烟 :examples/ 目录 typecheck(真实用法验证)
覆盖率 :核心逻辑 ≥ 80%
9.2 文档与示例
1. README 带「快速开始」代码块(同步到 examples/)
2. JSDoc 注释会进 .d.ts → 用户 IDE 悬浮提示(写好它)
3. 类型 vs 行为分开测:类型测试不替行为测试
4. 维护「破坏性变更 / 迁移指南」章节
9.3 常见坑
1. 忘发 types 或 exports 顺序错 → 用户类型报错
2. 库内用 any → 污染用户类型推断
3. 顶层副作用 → 用户 tree-shaking 失效
4. 依赖放 dependencies 还是 peerDependencies 不清晰
5. 不锁 Node/TS 兼容范围说明
一句话总结:开源库工程实践 = 行为测试 + 类型测试 + attw + API 报告四层门禁,JSDoc 写好以养用户 IDE 体验——库质量从「自己测」升维到「让别人用好」。
10. 速查表
| 需求 | 方案 |
|---|---|
| 生成声明 | declaration: true |
| 双构建 | tsc ESM + tsup/esbuild CJS |
| 包入口 | exports(types/import/require) |
| 类型解析检查 | attw |
| 类型测试 | tsd / expect-type |
| 破坏性检测 | api-extractor diff |
| 版本语义 | semver(破坏→major) |
| tree-shaking | ESM + sideEffects: false |
| 副作用隔离 | 独立子路径 / 惰性初始化 |
| 发布自动化 | prepublishOnly + changesets |
一句话记忆:TS 库作者的核心是「让别人能用好你的类型」——tsconfig 开 declaration 生成 d.ts、exports 三条件(types/import/require)做入口白名单、API 设计泛型推导 + 重载收窄、只暴露公共面;semver 纪律「破坏性变更进 major、废弃打 @deprecated 过渡」、api-extractor 报告 diff 兜底;tree-shaking 靠 ESM + sideEffects:false + 顶层无副作用;发布用 prepublishOnly 串起 build/test/attw/冒烟、changesets 管版本——把你的包当「对外契约」来维护,用户才敢长期依赖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。