ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rollup打包TypeScript库:输出JS与类型声明文件全攻略

Rollup打包TypeScript库:输出JS与类型声明文件全攻略

1. 为什么一个TS库的打包值得单独拿出来说

如果你写过一段时间TypeScript,大概率经历过这个阶段:本地开发爽得不行,类型提示、自动补全、重构跳转全都到位,结果一到“要给别人用”的时候就开始犯难。代码是TS写的,但用的人可能是JS项目,也可能是TS项目,还可能是在Node环境跑,也可能是在浏览器里跑。你总不能要求所有人都装一套TS编译环境再来用你的库吧。

所以一个成熟的TypeScript库,交付物通常得同时满足两件事:一份能直接跑的JavaScript文件,以及一份能让TS使用者继续享受类型提示的声明文件(.d.ts)。这两样东西缺一个,库的可用性就打了折扣。只给JS,TS用户体验断崖式下跌;只给类型没有可执行产物,那更是空中楼阁。

这个项目标题说的就是这件事:用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文件会被当成ESM,CJS产物就得用.cjs后缀;反过来如果没写type,.js默认是CJS,ESM产物用.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,把模块转换的活儿交给rollup;declaration设成true,declarationDir指向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 从零搭建的完整步骤

假设你已经有了一

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进