组件库工程底座:基于 TypeScript + Rollup 的多端通用(ESM/CommonJS)高质量组件打包体系搭建
组件库工程底座:基于 TypeScript + Rollup 的多端通用(ESM/CommonJS)高质量组件打包体系搭建
在前端工程化体系日趋成熟的今天,很多中大型团队都会选择搭建符合企业自身视觉规范的私有组件库(Component Library)。一个高质量的通用组件库,不仅要满足视觉交互要求,更要在工程层面做到体积轻量(支持极致的 Tree Shaking 摇树优化)、多端兼容(同时兼容 ESM 与 CommonJS)以及类型完备(提供干净无死角的 TypeScript .d.ts 类型声明)。
在业务项目打包(如 Webpack/Vite)与组件库打包(如 Rollup)之间,有着本质的架构诉求差异。为了实现纯净的代码转换,大厂通常将 Rollup 作为组件打包的底座。本文将深入解构 ESM 与 CommonJS 运行时加载的底层冲突,提供生产级 Rollup 配置文件,并解剖 Package.json 现代条件导出(Conditional Exports)的最佳实践。
一、造轮子的起点:为什么组件库开发独爱 Rollup?
为什么在应用开发中风靡的 Webpack 或 Vite 并不适合组件库的构建?这需要审视它们底层设计的侧重点:
- Webpack 的局限性:Webpack 的核心目标是把应用中所有的图片、CSS、JS 等异构资源打碎并聚合成少量的 bundle。为了支持运行时的模块懒加载和垫片注入,Webpack 会在打包产物中包裹大量的自定义模块加载引导代码(Runtime Helper)。这对于组件库而言是沉重的体积负担,并且这些包裹代码极易阻断打包器对组件执行静态 Tree Shaking 剪枝。
- Rollup 的纯粹性:Rollup 诞生之初就彻底确立了“基于 ESM 静态分析”的原则。它不做冗余的运行时注入,而是把所有的模块合并到同一层级作用域中(Scope Hoisting)。它的打包产物极其干净,接近手写代码。这对于后续消费该组件库的业务应用(无论使用 Vite 还是 Webpack 5)来说,能够以极低开销实现无死角的 Tree Shaking,剔除未被使用的组件代码。
因此,组件库构建体系应当致力于输出完全原生的 ESM 与用于兼容 Node.js 服务端渲染(SSR)的 CommonJS 规范模块。
二、底层解构:双端模块(ESM/CJS)的运行时冲突与类型声明文件合并
2.1 ESM 与 CommonJS 的双向加载冲突
在现代 Node.js 运行时或打包工具中,CommonJS 与 ES Modules 存在天然的加载壁垒:
- CommonJS (CJS):采用
require()进行同步、动态加载,模块在运行时被解析。 - ES Modules (ESM):采用
import/export静态导入,在编译期(静态阶段)完成依赖关系图的构建,不支持动态条件引入。
当业务系统在 Node.js 环境下执行服务端渲染(SSR)时,如果组件库没有提供高兼容性的 CommonJS 格式产物,Node 引擎会抛出致命的ERR_REQUIRE_ESM异常并阻断服务。而如果仅提供 CommonJS 格式,业务前端应用在客户端打包时就无法对组件库进行按需引入,导致包体积失控。
因此,构建底座必须输出双端产物,并通过package.json指导打包工具进行条件重定向:
flowchart TD A[业务项目构建器] --> B{读取组件库 package.json} B --> C{是否支持 Exports 条件导出?} C -- 是 --> D{检测业务项目的导入类型} D -- import 'my-lib' --> E[重定向至 dist/esm/index.js] D -- require 'my-lib' --> F[重定向至 dist/cjs/index.js] C -- 否 --> G[兜底读取 main / module 字段] G -- module --> H[加载 esm 产物] G -- main --> I[加载 cjs 产物]2.2 类型声明文件(.d.ts)的合并链条
TypeScript 开发中,组件库必须附带完整的.d.ts类型声明文件。如果直接使用tsc编译,编译器会在每个组件目录下生成一份细碎的.d.ts文件。这会导致组件库发布后,IDE 在解析类型时产生漫长的响应迟滞。
- 自动化合并 (Dts Bundling):我们需要通过 Rollup 插件将整个组件库的全部类型定义,提取并融合成一个统一的
index.d.ts。这不仅提高了组件库消费者的开发体验(毫秒级类型推导),更降低了库本身的打包碎屑。
三、生产级代码实现:Rollup 全能配置文件与现代条件导出样板
下面提供了一个 100% 完整、真实的 TypeScript + Rollup 组件打包工程配置实现。
3.1 核心 Rollup 配置文件实现 (rollup.config.ts)
import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import typescript from 'rollup-plugin-typescript2'; import dts from 'rollup-plugin-dts'; import postcss from 'rollup-plugin-postcss'; import { terser } from 'rollup-plugin-terser'; import pkg from './package.json'; // 1. 显式声明外部依赖 (External),防止将宿主应用已有的依赖打包进组件库 const externalDependencies = [ ...Object.keys(pkg.peerDependencies || {}), ...Object.keys(pkg.dependencies || {}), 'react/jsx-runtime' // 防止破坏 React18 的新运行时 ]; export default [ // 第一轨构建:输出支持 ESM 与 CommonJS 的 JS/CSS 产物 { input: 'src/index.ts', output: [ { file: pkg.main, // 对应 dist/index.cjs.js format: 'cjs', sourcemap: true, exports: 'named' }, { file: pkg.module, // 对应 dist/index.esm.js format: 'esm', sourcemap: true } ], external: externalDependencies, plugins: [ // 解析 CSS/PostCSS,并提取到独立的 CSS 文件中 postcss({ extract: 'index.css', minimize: true, sourceMap: true }), // 允许 Rollup 解析 node_modules 中的模块 resolve(), // 允许 Rollup 将 CommonJS 依赖转换为 ESM 格式 commonjs(), // 严谨的 TypeScript 编译插件,输出类型文件到临时目录 typescript({ useTsconfigDeclarationDir: true, tsconfigOverride: { compilerOptions: { declaration: true, declarationDir: 'dist/types' } } }), // 压缩生产代码 terser({ compress: { drop_console: true } }) ] }, // 第二轨构建:合并声明文件,生成干净的单个 index.d.ts { input: 'dist/types/index.d.ts', output: [{ file: 'dist/index.d.ts', format: 'esm' }], external: [/\.css$/], // 排除 css 的类型声明分析 plugins: [ dts() ] } ];3.2 package.json 条件导出 (Conditional Exports) 生产级样板
{ "name": "enterprise-ui-components", "version": "1.0.0", "description": "Enterprise-grade React component library", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", "files": [ "dist" ], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.esm.js", "require": "./dist/index.cjs.js", "default": "./dist/index.esm.js" }, "./dist/index.css": "./dist/index.css" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "dependencies": { "classnames": "^2.3.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-node-resolve": "^15.0.0", "rollup": "^3.0.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.34.0", "typescript": "^5.0.0" } }四、边界与 Trade-offs:全局样式绑定与 External 依赖侵入的架构博弈
构建组件库底座时,有两个悬而未决的“痛点”需要进行清晰的架构抉择:
4.1 样式绑定的博弈:内联注入还是独立 CSS?
组件库的样式打包通常面临三种方案:
- 纯 CSS 抽取(独立 CSS):如我们在 Rollup 配置中所设定的
postcss({ extract: 'index.css' })。- 优点:产物 JS 极为干净,且宿主应用可以通过 CSS Modules 自由覆盖样式。
- 缺点:业务使用组件库时,不仅要写
import { Button } from 'lib',还要手动引入import 'lib/dist/index.css',极易遗忘导致页面无样式白屏。
- 内联注入 JS (CSS inside JS):使用 style-inject 将样式动态转换并随 JS 执行时以
<style>标签写入 DOM 头部。- 优点:对组件消费者零入侵,只需
import即可用。 - 缺点:不支持 SSR 服务端渲染(因为 Node 环境没有 DOM
document),会导致水合(Hydration)报错;且无法对 CSS 进行静态 CDN 缓存加速。
- 优点:对组件消费者零入侵,只需
- 工程取舍:现代大厂的最佳实践是采用“方案一:独立 CSS”,并在条件导出
exports中暴露样式文件路径,再配合构建插件(如vite-plugin-style-import)实现 JS 与 CSS 样式的自动按需绑定加载。
4.2 依赖注入防护:PeerDependencies 的强制隔离
在配置 Rollup 的external选项时,必须将dependencies和peerDependencies中的巨型框架(如 React/Vue)强制排除在外。
- 灾难性后果:如果忘记将 React 排除,Rollup 会直接将整个 React 源代码编译合并进你所发布的组件库
dist/index.esm.js中。当业务应用下载此组件库并启动时,业务项目自己的 React 会与组件库里的 React 发生严重的实例篡改与 Context 冲突,直接引发运行时页面瘫痪。
五、总结
搭建一个工业级的组件打包体系,需要开发者摒弃简单“把代码打包出来就好”的思维,从更长生命周期的消费端出发进行设计:
- 确保模块干净:选用 Rollup 作为底座,消除任何不必要的运行时脚手架代码,实现干净可被摇树(Tree Shaking)的 ESM 输出。
- 双端适配与重定向:在
package.json中配置严谨的exports条件导出,无缝适配 Node (CommonJS) 与现代浏览器客户端构建 (ESM) 的双向请求。 - 依赖强制解耦:利用外部依赖隔离配置阻断大型库的误打包,保护宿主应用的依赖环境不被交叉污染。
