ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Rollup打包TypeScript库:双格式输出与类型声明生成实战

Rollup打包TypeScript库:双格式输出与类型声明生成实战

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

如果你写过几个工具函数,或者维护过一个内部组件库,大概率经历过这样的阶段:代码在项目里跑得好好的,一旦要发布成独立的包给别人用,问题就全冒出来了。要么是产出的js文件在别人的构建流程里报错,要么是类型声明文件缺失导致使用方满屏飘红,要么是package.json里的入口字段配得乱七八糟,import和require两种引入方式各挂各的。

这个项目的核心就是解决这件事:用rollup把一个TypeScript库打包成可直接消费的js文件,同时产出配套的类型声明文件。听起来只有一句话,但里面涉及的配置取舍、产物结构设计、类型声明生成方式、模块格式兼容,每一项都有坑。适合的读者是那些已经能写TS、但还没完整走过"库发布"流程的开发者,也适合正在维护内部组件库、想把手上的打包配置理顺的人。

我先说清楚这个标题背后的真实需求。一个TS库的交付物,本质上不是"能跑的代码",而是"别人能无痛接入的代码"。使用方可能用webpack,可能用vite,可能在Node环境里直接require,也可能在ESM项目里import。你的产物要同时覆盖这些场景,还要保证类型提示完整。rollup在这件事上的定位很明确:它擅长把ESM源码打成干净的库产物,tree-shaking做得好,产物结构可控,配合插件体系能顺手把类型声明一起处理掉。相比直接用tsc编译,rollup能输出多种模块格式并做依赖外置;相比用打包应用的方式去打库,rollup的产物更"素",不会塞进一堆运行时垫片。

所以这个项目的价值不在于"学会一个命令",而在于建立一套库产物的交付标准:入口怎么设计、格式怎么选、类型声明放哪、外部依赖怎么处理、发布前怎么验证。下面我按实际操作的顺序,把每个环节的考量和细节拆开讲。

2. 整体方案设计与关键取舍

2.1 产物形态先定,配置才有方向

动手写配置之前,必须先想清楚最终要交付什么。一个规范的TS库产物通常包含三部分:编译后的js文件、类型声明文件(.d.ts)、以及描述入口和依赖关系的package.json。这三者是一个整体,缺一个都会让使用方难受。

js文件的模块格式是第一个要拍板的事。常见的选择有三种:只出ESM、只出CommonJS、或者两者都出。我的建议是双格式输出,也就是同时产出ESM和CJS。原因很实际:现在新项目基本都用ESM,但大量存量项目和Node脚本仍然走CJS,你只出一种格式,就等于主动砍掉一半使用场景。rollup的output配置支持数组,一次打包输出多份,成本很低。

类型声明文件则是第二个关键点。TS库如果没有.d.ts,使用方在编辑器里就得不到任何提示,等于把一个TS库降级成了JS库,这是不可接受的。类型声明的生成有两条主流路线:一是用tsc单独跑一遍只出声明,二是用rollup插件在打包过程中顺带生成。两条路线各有适用场景,后面会详细对比。

还有一个容易被忽略的决策:依赖要不要打进产物。库和应用不一样,库的第三方依赖通常应该外置(external),交给使用方的项目去解析,否则同一个依赖在产物和使用方项目里各存一份,体积翻倍还可能引发实例不一致的问题。这个决策直接决定了external字段和peerDependencies的写法。

2.2 为什么是rollup而不是别的工具

有人会问,tsc本身就能编译TS,为什么还要套一层rollup。答案在于产物的"干净程度"和"格式灵活性"。tsc的输出是逐文件对应的,一个源文件出一个js,模块格式由module配置统一决定,想同时出ESM和CJS就得跑两次并改目录,管理起来很别扭。而且tsc不做打包,产物是一堆散文件,入口的聚合需要自己写。

rollup的强项正好补上这块:它从单一入口出发,把内部模块合并成少量产物文件,tree-shaking能剔除未使用的导出,output.format可以一次配多种格式。对于库这种"入口明确、产物要精简"的场景,rollup的模型非常贴合。至于打包应用常用的那些工具,它们更偏向处理样式、静态资源、代码分割等应用级需求,用来打库反而显得重,产物里容易混入运行时辅助代码。

这里要强调一个取舍:rollup负责"打包和格式转换",类型声明交给专门的工具处理,两者分工明确。不要指望rollup本身理解TS类型,它只是通过插件把TS转成js,类型信息的提取是另一条链路。

2.3 目录结构与入口设计

在写配置前,先把目录约定好,后面所有路径都围绕它展开。我习惯的结构是这样的:源码放src,入口文件是src/index.ts,它负责把各个子模块的导出聚合起来。构建产物统一输出到dist,并在dist下按格式分子目录,比如dist/esm和dist/cjs,类型声明单独放dist/types。

这种分目录的做法有个直接好处:package.json里的入口字段可以精确指向对应文件,使用方无论走哪种模块解析都能命中正确的产物。如果所有格式混在一个目录里,文件名容易冲突,入口字段也会写得很绕。

入口文件的设计也有讲究。src/index.ts应该只做导出聚合,不写具体逻辑,这样rollup以它为入口时,能清晰地看到整个库的导出面,tree-shaking的边界也明确。如果库有多个子入口(比如按功能拆分的core、utils),可以用多个input配置,但初期建议先保持单入口,降低复杂度。

3. 核心配置拆解与实操要点

3.1 依赖安装与版本选择

先把工具链装齐。核心依赖是rollup本身、TypeScript、以及把两者接起来的插件。我用的组合是rollup、typescript、@rollup/plugin-typescript(负责TS转js)、rollup-plugin-dts(负责把声明文件合并成单文件)、@rollup/plugin-node-resolve(解析node_modules里的模块)、@rollup/plugin-commonjs(把CJS依赖转成ESM以便rollup处理)。

npm install -D rollup typescript @rollup/plugin-typescript rollup-plugin-dts @rollup/plugin-node-resolve @rollup/plugin-commonjs

版本上有个实操心得:@rollup/plugin-typescript对TypeScript版本有一定敏感性,装的时候尽量让两者版本接近发布周期,避免插件内部调用的TS API对不上。我踩过一次坑,TS升到较新的大版本后插件报内部错误,回退TS小版本就好了。所以装完先跑一次空构建验证链路通不通,别等业务代码写完才发现工具链有问题。

rollup-plugin-dts的作用值得单独说。它读取tsc产出的声明文件,把多个.d.ts合并成一个入口声明文件。为什么需要合并?因为如果声明文件是散落的多个,使用方在package.json里指向入口声明时,rollup或打包工具需要顺着引用关系去解析,某些配置下会解析失败。合并成单文件后,入口声明自包含,兼容性最好。

3.2 tsconfig的针对性配置

库的tsconfig和应用的不一样,重点在几个字段。declaration必须开,这是产出类型声明的前提;declarationDir指定声明输出目录;outDir指定js输出目录;module设成ESNext让rollup接管后续转换;target按需要设,库一般设ES2018或更高,太老的target会引入不必要的降级代码。

{ "compilerOptions": { "target": "ES2018", "module": "ESNext", "moduleResolution": "node", "declaration": true, "declarationDir": "dist/types", "outDir": "dist/tmp", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src"] }

这里有个细节:outDir我设成了dist/tmp,这是个临时目录,只用于tsc单独生成声明文件时存放中间产物,最终会被清理。为什么不直接让rollup插件输出到最终目录?因为声明文件的生成和js打包是两条链路,分开跑更可控,也方便排查问题。

skipLibCheck建议打开,它能跳过对node_modules里声明文件的类型检查,构建速度会明显提升,而且能规避一些第三方声明文件自身的类型错误干扰。strict则建议保持开启,库的类型质量直接影响使用方体验,宽松配置下漏掉的类型问题最终会转嫁给使用者。

3.3 rollup配置的逐段解析

rollup配置文件我习惯写成rollup.config.mjs,用ESM语法,避免CJS和ESM混用带来的解析歧义。配置导出的是一个数组,因为要跑两趟:一趟打js,一趟合并声明。

第一趟打js的配置,核心是input、plugins、external、output四块。input指向src/index.ts;plugins里挂上node-resolve、commonjs、typescript;external把第三方依赖排除掉;output用数组输出ESM和CJS两份。

import typescript from '@rollup/plugin-typescript'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; export default [ { input: 'src/index.ts', external: ['lodash-es'], plugins: [ resolve(), commonjs(), typescript({ tsconfig: './tsconfig.json', declaration: false, outDir: undefined }) ], output: [ { file: 'dist/esm/index.js', format: 'esm', sourcemap: true }, { file: 'dist/cjs/index.cjs', format: 'cjs', exports: 'named', sourcemap: true } ] } ];

几个参数的选择理由要说清楚。external里列出的依赖不会被打进产物,使用方项目需要自己安装,这对应package.json里的peerDependencies。exports: 'named'是CJS输出的关键,它让CJS产物以命名导出的形式暴露,避免使用方require后拿到一个带default包裹的对象,减少互操作摩擦。sourcemap建议开,库出问题时使用方能定位到源码,排查成本低很多。

CJS产物的文件扩展名我用.cjs而不是.js,这是为了在package.json的type字段设为module时,明确告诉Node这个文件是CJS。如果库的package.json没设type,用.js也行,但显式用.cjs更稳妥,不依赖外部配置。

第二趟合并声明的配置更简单,input直接指向tsc产出的入口声明文件,插件只用dts,输出单个.d.ts。

import { dts } from 'rollup-plugin-dts'; export default [ { input: 'dist/types/index.d.ts', output: { file: 'dist/types/index.d.ts', format: 'esm' }, plugins: [dts()] } ];

注意这里的输入输出是同一个路径,dts插件会读取散落的声明并合并覆盖。这种写法依赖tsc先跑完生成dist/types下的声明文件,所以构建脚本的顺序必须是先tsc再rollup。

3.4 package.json的入口字段设计

产物打出来后,package.json的字段配置决定了使用方能不能正确解析到它们。核心字段是main、module、types,以及更现代的exports。

{ "name": "your-lib-name", "version": "1.0.0", "type": "module", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", "exports": { ".": { "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.cjs" } }, "files": ["dist"], "sideEffects": false, "peerDependencies": { "lodash-es": "^4.17.21" } }

main指向CJS产物,兼容老工具链;module指向ESM产物,让支持ESM的打包工具优先用它;types指向声明入口。exports字段是更精细的解析规则,types条件要放在最前面,import和require分别对应两种引入方式。files限定发布内容只包含dist,避免把源码和配置一起发出去。sideEffects: false告诉打包工具这个库没有副作用,tree-shaking能更激进地剔除未使用代码。

这里有个高频坑:exports字段一旦存在,就会覆盖main和module的解析行为,某些老版本工具不认exports,会回退到main。所以两套字段都要写对,不能只配一边。另外types条件在exports里的位置很关键,如果放在import后面,某些解析器会先命中import而忽略类型,导致类型提示丢失。

4. 完整构建流程与现场记录

4.1 构建脚本的编排

整个构建分三步:清理旧产物、tsc生成声明、rollup打包js并合并声明。写成npm scripts,顺序执行。

{ "scripts": { "clean": "rimraf dist", "build:types": "tsc -p tsconfig.json --emitDeclarationOnly", "build:js": "rollup -c rollup.config.mjs", "build": "npm run clean && npm run build:types && npm run build:js" } }

--emitDeclarationOnly让tsc只出声明不出js,避免和rollup的js输出重复。clean用rimraf保证跨平台删除目录,别用rm -rf,Windows下会挂。

实际跑一遍,build:types会在dist/types下生成每个源文件对应的.d.ts,build:js先打js到dist/esm和dist/cjs,然后第二趟配置读取dist/types/index.d.ts并合并覆盖。最终dist结构是三个子目录,各司其职。

4.2 产物验证的实操方法

打完包不能只看"没报错"就完事,要实际验证产物可用。我通常做三件事。第一,在dist目录下用Node直接requireCJS产物,跑一个简单调用,确认运行时没问题。第二,新建一个临时TS项目,通过file:协议把库装进去,写一段import代码,看编辑器有没有类型提示、编译有没有报错。第三,用打包工具(比如vite)建个最小项目引入这个库,确认tree-shaking和模块解析正常。

node -e "const lib = require('./dist/cjs/index.cjs'); console.log(lib)"

这一步能快速暴露CJS产物的导出形态问题。如果打印出来是个带default的对象而不是预期的命名导出,说明exports: 'named'没生效或者配置位置不对。

类型验证那步尤其重要。很多人打完包自己项目里用着没问题,是因为自己项目里源码还在,TS直接读源码类型。真正发布后使用方只拿到dist,类型链路断了才发现。所以一定要在"只有产物"的环境里验证。

4.3 参数与体积的实测记录

拿一个中等规模的工具库实测,源码约30个文件、导出约80个函数。构建耗时上,tsc出声明约4秒,rollup打双格式约6秒,合并声明约2秒,整体在12秒左右,日常开发完全可接受。产物体积上,ESM产物约48KB(未压缩),CJS约52KB,声明合并后单文件约36KB。开启sourcemap后各多出约一倍体积,但发布时通常不带map或者单独提供,不影响主产物。

这里有个观察:CJS产物比ESM略大,是因为CJS的模块包装和exports赋值有额外开销,属于正常现象。如果体积敏感,可以进一步检查是否有大依赖被误打进产物,用external排除掉。

5. 常见问题与排查速查

5.1 类型声明相关的典型故障

最常见的问题是使用方装了库但编辑器没提示。排查顺序是:先看package.json的types字段路径对不对,再看exports里的types条件是否在正确位置,最后看合并后的.d.ts是否真的包含了所有导出。有时候dts插件合并时漏掉了某些通过export *转出的类型,需要检查源文件的导出写法是否规范。

另一个问题是声明文件和js对不上,比如js里有的导出声明里没有。这通常是tsc和rollup跑的顺序或输入不一致导致的,确保build:types在build:js之前,且两者读的是同一份源码。

5.2 模块格式与互操作问题

CJS使用方require后拿到的结构和预期不符,多半是exports模式没设成named。ESM使用方import报"没有默认导出",则要检查源文件是否用了export default而产物格式处理不当。双格式库最容易在默认导出上翻车,我的建议是库尽量只用命名导出,少用默认导出,互操作摩擦最小。

还有一个隐蔽的坑:package.json设了"type": "module"后,所有.js文件都被当成ESM,如果CJS产物用了.js扩展名,Node会按ESM解析而报错。这就是前面强调CJS产物用.cjs扩展名的原因。

5.3 依赖处理的边界情况

external漏配会导致第三方依赖被打进产物,体积暴涨且可能引发重复实例问题。判断方法是看产物里有没有出现依赖包的代码特征。反过来,external配多了但peerDependencies没声明,使用方装库后运行时报"模块找不到"。这两个字段要成对维护。

下面这张表是我整理的速查对照,遇到问题先对号入座。

现象可能原因处理方式
使用方无类型提示types路径错或exports条件顺序错核对路径,types放exports首位
CJS引入拿到default包裹未设exports: 'named'output里补该字段
产物体积异常大依赖未external检查external与peerDependencies
Node报ESM解析错误CJS产物用了.js且type: moduleCJS产物改.cjs扩展名
声明缺导出合并时export *未展开检查源导出写法,必要时显式导出

5.4 几个踩过的坑和独家心得

第一个坑是sourcemap的路径问题。rollup默认生成的map里sourceRoot可能指向构建机的绝对路径,发布后使用方看到的是无意义的本地路径。可以在output里配sourcemapPathTransform或者干脆发布时不带map,需要调试时单独提供。

第二个心得是关于构建速度的。库大了以后每次全量构建很慢,可以给rollup加cache,或者开发时只跑单格式。但发布构建一定要全量跑,别图快跳过声明合并。

第三个是版本管理上的经验:peerDependencies的版本范围要写宽一点,比如^4.0.0而不是锁死小版本,否则使用方项目里依赖版本稍有不同就报冲突。库的依赖策略应该是"尽量宽松,只约束大版本"。

6. 发布前的最后一道检查

打包配置调通只是第一步,真正发布前还有几件事要做。先跑一次npm pack --dry-run,看打包进tarball的文件列表是否符合预期,dist在、源码和配置不在,这是最基本的要求。然后在一个干净目录里npm install这个tarball,完整走一遍引入和调用,模拟真实使用方的体验。

我个人在实际操作中的体会是,库打包这件事的难点从来不在rollup配置本身,配置抄一抄就能跑,难的是站在使用方角度验证产物。你自己项目里能跑,不代表别人装了能跑,因为你的环境里有源码、有完整的node_modules、有各种隐式配置。每次发布前把自己当成一个什么都不知道的新用户,从零装一遍、用一遍,大部分问题在这一步就会暴露。这个习惯帮我挡掉过好几次"发布后才发现类型断了"的尴尬,比任何自动化检查都直接。

RELATED READING

延伸阅读

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