
1. 为什么一个TS库的打包值得单独拿出来说如果你写过一段时间TypeScript大概率经历过这个阶段本地开发爽得不行类型提示、自动补全、重构跳转全都到位结果一到“要给别人用”的时候就开始犯难。代码是TS写的但用的人可能是JS项目也可能是TS项目还可能是在Node环境跑也可能是在浏览器里跑。你总不能要求所有人都装一套TS编译环境再来用你的库吧。所以一个成熟的TypeScript库交付物通常得同时满足两件事一份能直接跑的JavaScript文件以及一份能让TS使用者继续享受类型提示的声明文件.d.ts。这两样东西缺一个库的可用性就打了折扣。只给JSTS用户体验断崖式下跌只给类型没有可执行产物那更是空中楼阁。这个项目标题说的就是这件事用rollup把TypeScript库打包输出JS文件加类型声明文件。听起来像是个“配置一下就行”的活儿但我实际折腾下来里面的坑比想象中多。比如声明文件到底怎么生成、rollup的插件顺序怎么排、输出格式选哪几种、package.json里的字段怎么配才能让各种模块系统都认这些细节但凡有一个没处理好使用者就会在引入你的库时遇到莫名其妙的报错。这篇文章适合两类人看一类是刚写完自己的第一个TS库、准备发布出去的新手另一类是已经能打包但总在类型声明或模块格式上翻车的朋友。我会从整体设计思路讲起把rollup配置、TypeScript编译、声明文件生成、产物验证这几个环节拆开揉碎配上可直接抄的配置和踩坑记录。读完你至少能做到打包产物干净、类型声明完整、各种引入方式都不报错。2. 整体方案设计与工具选型考量2.1 为什么是rollup而不是别的打包器先说说选型这件事。打包TS库的工具其实不少webpack、esbuild、tsup、rollup都能干。我最终倾向rollup核心原因在于它对库library场景的定位更纯粹。webpack更偏向应用打包配置里一堆跟资源处理、devServer相关的东西对纯库来说太重esbuild速度快得惊人但它在类型声明生成这块需要额外配合生态上不如rollup成熟tsup本质上是rollup的封装省事但黑盒出了问题排查起来隔了一层。rollup的设计哲学是“只做打包这一件事”配合插件体系可以精确控制每一步。对于库作者来说这种可控性很重要——你需要清楚地知道哪些代码进了产物、哪些被tree-shaking掉了、外部依赖有没有被误打进去。而且rollup对ESM的原生支持非常好输出格式灵活这对一个要同时兼容多种模块系统的库来说是刚需。当然选rollup也意味着你要自己把TypeScript编译和声明文件生成这两块拼起来不像tsup那样开箱即用。但这个“拼”的过程恰恰是理解整个链路的机会配一次之后心里就有底了。2.2 输出格式的取舍ESM、CJS还是都要模块格式的选择直接决定了谁能用你的库。现在的主流趋势是ESM但现实是大量存量项目还在用CommonJS尤其是Node端的工具类库。我的建议是至少同时输出ESM和CJS两种格式成本不高但覆盖面广。具体到配置上rollup的output.format可以设成es和cjs分别对应.mjs或.js配合type: module和.cjs。这里有个细节如果你的package.json里写了type: module那.js文件会被当成ESMCJS产物就得用.cjs后缀反过来如果没写type.js默认是CJSESM产物用.mjs。我一般倾向于不设type: module然后用.mjs和.cjs明确区分这样最不容易混淆。至于UMD格式除非你的库明确要给浏览器script直接引入用否则没必要加。多一种格式就多一份维护成本和测试负担而且UMD在tree-shaking上表现不佳。2.3 声明文件生成的两种路线类型声明文件的生成有两条主流路线得先想清楚走哪条。第一条是用TypeScript自带的declaration: true让tsc在编译时顺带产出.d.ts。这条路线的好处是类型绝对准确因为就是编译器自己吐出来的缺点是它和rollup的打包流程是分离的你需要么先跑tsc再跑rollup要么用rollup-plugin-typescript2这类插件把两者串起来。第二条是用rollup-plugin-dts先把TS编译成JS此时可能已经带了声明再用这个插件把散落的.d.ts合并成单个声明文件。它适合产物需要“单文件声明”的场景比如你的库对外只暴露一个入口。我实际用下来比较稳的组合是用rollup-plugin-typescript2负责编译TS并生成声明再用rollup-plugin-dts把声明合并成单文件。这样既有编译器的准确性又有单文件声明的整洁。如果库的入口结构简单其实光靠tsc生成声明也够用看个人对产物形态的要求。2.4 外部依赖的处理原则库和应用打包有个根本区别库不应该把自己的依赖打进去。比如你的库用了某个工具函数包这个包应该作为peerDependencies或dependencies声明在package.json里让使用者的包管理器去解析而不是被你塞进产物里造成重复。在rollup里对应的就是external配置。所有dependencies和peerDependencies里的包都应该被标记为external这样rollup不会尝试解析和打包它们。我见过有人忘了配这个结果产物里嵌了一整个依赖库使用者装了两个版本行为诡异还查不出原因。这个坑后面还会细说。3. 核心配置拆解与关键参数说明3.1 目录结构与入口约定动手写配置前先把目录结构定下来。我习惯的布局是这样的源码放src/入口文件是src/index.ts产物输出到dist/。dist/里最终会有index.mjs、index.cjs以及index.d.ts如果做单文件声明的话。tsconfig.json里几个关键项要留意。target建议设成ES2018或更高太老的target会让编译产物里塞一堆辅助函数module设成ESNext把模块转换的活儿交给rollupdeclaration设成truedeclarationDir指向dist/types之类的临时目录方便后续合并strict强烈建议打开库的类型质量直接影响使用者的体验。{ compilerOptions: { target: ES2018, module: ESNext, moduleResolution: node, declaration: true, declarationDir: dist/types, outDir: dist, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src] }这里skipLibCheck: true能跳过对声明文件本身的类型检查加快编译速度对库项目来说一般没副作用。esModuleInterop打开后import默认导入的写法更符合直觉建议开。3.2 rollup配置文件逐段解析rollup的配置我一般写成rollup.config.mjs用ESM语法清晰。核心结构是一个数组每个元素对应一种输出格式的配置或者拆成“编译”和“声明合并”两个独立任务。先看编译任务。插件顺序很关键typescript2要在nodeResolve和commonjs之前因为它负责把TS转成JS后面的插件处理的是转换后的模块解析。nodeResolve用来解析第三方依赖虽然库场景下大部分依赖被external了但有些内联的小工具可能需要commonjs用来把CJS模块转成ESM供rollup处理。import typescript from rollup-plugin-typescript2; import { nodeResolve } from rollup/plugin-node-resolve; import commonjs from rollup/plugin-commonjs; import dts from rollup-plugin-dts; const external [some-dep, another-dep]; export default [ { input: src/index.ts, external, output: [ { file: dist/index.mjs, format: es }, { file: dist/index.cjs, format: cjs, exports: named } ], plugins: [ nodeResolve(), commonjs(), typescript({ tsconfig: ./tsconfig.json, useTsconfigDeclarationDir: true }) ] }, { input: dist/types/index.d.ts, output: { file: dist/index.d.ts, format: es }, plugins: [dts()] } ];exports: named这个参数值得说一下。CJS输出时如果你的库用的是具名导出rollup默认可能会警告“mixing named and default exports”。加上exports: named明确告诉rollup按具名导出处理能消掉警告也让产物更规范。第二个配置块专门处理声明合并输入是tsc生成的dist/types/index.d.ts输出是单文件dist/index.d.ts。dts()插件会把所有引用到的类型声明内联进来最终得到一个自包含的声明文件。3.3 package.json里的字段怎么配才不翻车这块是重灾区。很多人打包产物没问题但使用者一引入就报错八成是package.json字段没配对。核心是main、module、types、exports这几个。main指向CJS产物module指向ESM产物这是打包器识别的非标准但广泛支持的字段types指向声明文件。exports字段则是现代Node的入口映射优先级最高配好了能精确控制不同条件下的入口。{ name: my-lib, version: 1.0.0, main: ./dist/index.cjs, module: ./dist/index.mjs, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.mjs, require: ./dist/index.cjs } }, files: [dist], sideEffects: false }exports里的顺序有讲究types必须放最前面否则某些TS版本解析时会找不到类型。import和require分别对应ESM和CJS引入。files字段限定发布内容只包含dist避免把源码和配置一起发出去。sideEffects: false告诉打包器这个库没有副作用可以放心tree-shaking对使用者友好。注意exports字段一旦配置就会“锁死”包的入口使用者不能再通过深层路径引入未在exports里声明的文件。如果你的库需要暴露子路径比如my-lib/utils记得在exports里一并声明否则会报“Package subpath not exported”。3.4 声明文件路径的常见错位一个高频问题是声明文件生成了但使用者的编辑器找不到类型。原因通常是types字段指向的路径和实际产物对不上。比如tsc把声明生成到了dist/types/index.d.ts但package.json里types写的是dist/index.d.ts中间差了一层目录。解决办法要么调整declarationDir让声明直接落在dist根下要么用rollup-plugin-dts合并后输出到dist/index.d.ts。我倾向后者因为单文件声明对使用者更友好也避免了声明文件里相对路径引用错乱的问题。合并时如果遇到“找不到模块”的报错检查一下dts()的输入路径是否和tsc实际输出路径一致这个路径错一位就全盘皆输。4. 完整实操流程与产物验证4.1 从零搭建的完整步骤假设你已经有了一