灵魂拷问:你的 React 组件库是给谁用的?是给人类看的,还是给机器看的?
大家好,欢迎来到今天的“搬砖进阶班”。我是你们的老朋友,一个在代码堆里摸爬滚打多年,看着各种构建工具从 Webpack 1.0 到 Vite,头发越来越稀疏,技术却越来越硬核的资深工程师。
今天我们不聊业务逻辑,不聊怎么把那个难搞的需求搞定。今天我们要聊的是——如何优雅地把你写的那堆 React 组件,打包成一个让全世界开发者都爱不释手的“超级英雄”库。
想象一下,你辛辛苦苦写了三个组件:SuperButton、SuperInput 和 SuperModal。你把它们放在 src/components 里面,然后直接扔给用户:“拿去用吧,import { SuperButton } from './SuperButton'。”
用户会怎么做?他会一脸懵逼地打开终端,然后报错:“找不到模块 ‘./SuperButton’”。为什么?因为浏览器不认识 TypeScript,不认识 JSX,更不认识你那一堆散落在文件夹里的 .tsx 文件。浏览器只认识一种语言:JavaScript(或者它的老祖宗汇编语言)。
所以,我们的任务就是:利用 Rollup 和 dts-bundle,打造一个高性能、多格式支持的 React 组件库。
别被这些名词吓到了,咱们今天就把它们扒个精光,顺便看看怎么把它们组合成一套“打怪升级”的装备。
第一部分:为什么是 Rollup?Webpack 是废物吗?
在开始之前,我们必须先进行一场“武林大会”的排位赛。现在的前端构建工具有很多:Webpack、Vite、Rollup、Parcel。
你可能会问:“我平时用 Webpack 都挺顺手的,为什么到了库的开发这里,突然就要换 Rollup?”
好问题。这就好比开卡车和开法拉利的区别。
- Webpack 是个全能型卡车司机。它不仅能运货,还能把货分门别类地塞进车厢,甚至能给你加个车顶帐篷(Loader)让你露营。它非常适合构建应用,因为它能把你的所有代码、样式、图片都打包在一起,甚至能处理复杂的代码分割。
- Rollup 是个外科医生。它手里拿的不是大铲子,而是手术刀。它非常擅长做“摇树优化”。什么叫摇树优化?简单说,就是如果你只 import 了一个组件里的
Button,Rollup 会把Button以外的代码全部砍掉,就像抖落衣服上的灰尘一样。
对于库来说,我们不需要把所有东西都塞进一个巨大的 bundle 里,我们只需要提供最精简、最纯净的代码。
如果你的库用了 Webpack 打包,你可能会发现你的用户引入了一个库,结果下载下来 2MB 的代码,结果只用了其中 10KB。这就像你请了一支交响乐团回家,结果你只想要个门铃。Rollup 就是那个只给你门铃的裁缝。
而且,Rollup 原生支持 ES Modules(ESM),这是现代前端的标准。它能把你的代码转换成扁平化的、没有中间层包装的代码。这对 Tree-shaking 至关重要。
所以,结论是:构建应用用 Webpack/Vite,构建库用 Rollup。这是江湖规矩。
第二部分:Rollup 的核心配置——从入门到入土
好了,让我们开始搭建我们的 Rollup 环境。首先,你需要安装一些“帮手”。
npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-babel rollup-plugin-terser
这些插件是干嘛的?我们一个个来看:
- @rollup/plugin-node-resolve: 这个插件就像是你的“导航员”。当你写
import Button from 'my-lib/Button'时,它负责去node_modules里找到这个文件。 - @rollup/plugin-commonjs: 这个插件负责处理旧的代码。很多老库是用 CommonJS 写的(
module.exports = ...),而 Rollup 只认识 ES Modules。这个插件负责把它们翻译成 Rollup 能听懂的语言。 - @rollup/plugin-babel: React 组件是 JSX 写的,浏览器不认识。这个插件负责把 JSX 编译成普通的 JavaScript。
- rollup-plugin-terser: 压缩代码的。它会把你的代码压缩成一行,把变量名缩写成
a, b, c,让你看着心疼,但用户下载速度飞快。
2.1 构建脚本
首先,在 package.json 里加个脚本:
"scripts": {
"build": "rollup -c"
}
2.2 编写 rollup.config.js
这是我们的主战场。来,跟我一起敲代码。
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import terser from 'rollup-plugin-terser';
export default {
input: 'src/index.js', // 入口文件,你的库从这里开始
output: [
{
file: 'dist/index.cjs.js', // 输出:CommonJS 格式 (老牌,Node.js 用)
format: 'cjs',
exports: 'auto'
},
{
file: 'dist/index.esm.js', // 输出:ES Modules 格式 (现代,浏览器和 Webpack/Vite 用)
format: 'es',
exports: 'auto'
},
{
file: 'dist/index.umd.js', // 输出:UMD 格式 (全球通用,浏览器 CDN 用)
format: 'umd',
name: 'MyLibrary', // 全局变量名
exports: 'auto'
}
],
plugins: [
resolve(), // 1. 找文件
commonjs(), // 2. 翻译旧代码
babel({
babelHelpers: 'bundled', // 把 babel 的辅助函数打包进去,不用依赖 babel-runtime
exclude: 'node_modules/**' // 排除 node_modules,别把第三方库也转译了,浪费时间
}),
terser() // 3. 压缩代码
],
external: ['react', 'react-dom'] // 排除 react,因为 react 是通过 npm 安装的,不应该被打包进去
};
看到这段代码,你可能会觉得:“这有什么难的?” 别急,这里有几个灵魂拷问的细节:
external是什么鬼?
你肯定不想把 React 的源代码打包到你的库里吧?那样你的库会变成几百兆。用户电脑里已经装了 React,你只需要告诉 Rollup:“嘿,遇到 React 的引用,别管它,直接从 node_modules 拿过来。” 这就叫external。- 为什么要有三个输出文件?
index.cjs.js: 以前的 Node.js 项目(Webpack 4 时代)都用这个。index.esm.js: 现在的项目都用这个,支持 Tree-shaking,性能最好。index.umd.js: 当用户在浏览器里直接<script src="...">引用你的库时,他们需要这个。
babelHelpers: 'bundled':
这是一个性能优化点。默认情况下,Babel 会生成很多辅助函数,比如_extends。如果每个文件都生成一个_extends,那你的包会变得很大。bundled模式会把这些辅助函数打包成一个文件,全局复用。
第三部分:TypeScript 的噩梦与 dts-bundle 的救赎
好了,现在我们有了 JS 版本的库。但是,我们是用 TypeScript 写的组件啊!用户如果想在 VSCode 里得到智能提示,他们需要 .d.ts 文件。
3.1 手动生成 .d.ts 的痛苦
如果你用 tsc(TypeScript 编译器)生成类型文件,你会发现一个巨大的问题。
假设你的库结构是这样的:
src/
index.ts
components/
Button/
Button.tsx
index.ts
Input/
Input.tsx
index.ts
当你运行 tsc 后,你会得到:
dist/
components/
Button/
Button.d.ts
index.d.ts
Input/
Input.d.ts
index.d.ts
index.d.ts
用户想用你的库,得这么写:
import { Button } from 'my-lib/components/Button';
import { Input } from 'my-lib/components/Input';
这太反人类了! 谁会记得每个组件在哪个子目录下?谁会去读复杂的嵌套文件?用户只想写:
import { Button, Input } from 'my-lib';
3.2 dts-bundle 的魔法
这时候,dts-bundle 登场了。它就像一个神奇的管家,能把所有散落在角落的 .d.ts 文件收集起来,像拼图一样拼成一个完美的 index.d.ts。
首先安装它:
npm install --save-dev dts-bundle
然后,我们需要配置一下 package.json,让 TypeScript 知道去哪里找类型定义。
"scripts": {
"build": "rollup -c && dts-bundle -d dist -o dist/index.d.ts --main dist/index.d.ts --typescript-compiler-folder ./node_modules/typescript"
}
等等,这命令有点长。我们来拆解一下:
-d dist: 在 dist 目录下操作。-o dist/index.d.ts: 输出文件名。--main dist/index.d.ts: 告诉它入口类型文件在哪里(通常是tsc生成的那个)。--typescript-compiler-folder ./node_modules/typescript: 告诉它去哪里找 TypeScript 编译器来处理文件。
但是! 这里有个大坑。dts-bundle 已经好几年没更新了,而且它对 TypeScript 4.x/5.x 的支持有时候不太稳定,特别是涉及到 export * from 的时候。
专家级修正方案(现代版):
虽然你要求用 dts-bundle,但作为一个资深专家,我必须告诉你,现在更流行的是 dts-bundle-generator 或者直接在 tsconfig.json 里配置。
不过,既然我们要讲“规范”,我们就得把 dts-bundle 的原理讲透。它的核心逻辑是:
- 读取
index.d.ts。 - 找到所有的
export * from './xxx'。 - 把
./xxx的内容“展开”到index.d.ts里。 - 删除重复的导出。
如果你坚持要用 dts-bundle,请务必在 tsconfig.json 里设置 declaration: true 和 outDir: 'dist'。
第四部分:实战演练——构建一个名为“SuperUI”的库
让我们来个实战。假设我们要构建一个名为 SuperUI 的库,里面只有一个组件 SuperButton。
4.1 目录结构
SuperUI/
├── package.json
├── tsconfig.json
├── rollup.config.js
├── src/
│ ├── index.ts # 主入口
│ ├── components/
│ │ └── SuperButton/
│ │ ├── SuperButton.tsx # 组件代码
│ │ └── index.ts # 组件导出
│ └── styles/
│ └── index.css # 样式文件
└── dist/ # 打包输出目录
4.2 组件代码 (SuperButton.tsx)
import React, { ButtonHTMLAttributes } from 'react';
export interface SuperButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
loading?: boolean;
}
export const SuperButton: React.FC<SuperButtonProps> = ({
variant = 'primary',
loading,
children,
...props
}) => {
const baseStyle = {
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
backgroundColor: 'blue',
color: 'white',
};
const variantStyle = {
primary: { backgroundColor: 'blue' },
secondary: { backgroundColor: 'gray' },
danger: { backgroundColor: 'red' },
};
return (
<button
style={{ ...baseStyle, ...variantStyle[variant] }}
disabled={loading}
{...props}
>
{loading ? 'Loading...' : children}
</button>
);
};
4.3 组件导出 (index.ts)
export { SuperButton } from './SuperButton';
4.4 主入口 (src/index.ts)
// 导出组件
export { SuperButton } from './components/SuperButton';
// 如果你想把所有组件都导出来,也可以用 export * from './components'
// export * from './components/SuperButton';
4.5 最终的 package.json 配置
这是最关键的部分。正确的 package.json 能让你的库在 npm 上发光发热。
{
"name": "super-ui",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rollup -c && dts-bundle -d dist -o dist/index.d.ts --main dist/index.d.ts --typescript-compiler-folder ./node_modules/typescript",
"prepublishOnly": "npm run build"
},
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^14.0.0",
"@rollup/plugin-typescript": "^9.0.0",
"rollup": "^2.79.0",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.9.0",
"dts-bundle": "^0.7.3"
}
}
注意看 main 和 module 字段。当用户执行 npm install 时,package.json 会告诉他们的打包工具(Webpack 或 Vite)应该优先加载哪个文件。现代工具会优先加载 module(ESM),老工具会加载 main(CJS)。
第五部分:性能优化与高级技巧
光能打包还不够,我们要的是高性能。
5.1 Tree-shaking 的艺术
Tree-shaking 的核心在于导出。
如果你在 src/index.ts 里这样写:
import { SuperButton } from './components/SuperButton';
import { SuperInput } from './components/SuperInput';
// 默认导出所有东西
export { SuperButton, SuperInput };
这没问题,但不够优雅。更好的做法是使用 export * from:
export * from './components/SuperButton';
export * from './components/SuperInput';
这样,用户在代码里写:
import { SuperButton } from 'super-ui';
Rollup 就能精准地知道,只需要打包 SuperButton 的代码,SuperInput 的代码完全不需要动。
但是! 如果你在组件内部使用了副作用,比如:
// SuperButton.tsx
import './styles.css'; // 这就是副作用!
export const SuperButton = () => <button>Click me</button>;
Tree-shaking 会失效。因为 import 了一个文件,编译器会认为这个文件可能有副作用,所以会把它打包进去。如果你想支持 Tree-shaking,请尽量避免在组件文件里写 import './styles.css'。把样式文件单独引入,或者使用 CSS Modules。
5.2 代码分割
如果你的库非常大,包含了很多组件,你可以使用动态导入来实现代码分割。
export const LazyButton = React.lazy(() => import('./components/SuperButton'));
这样,LazyButton 只会在用户真正使用它的时候才会被加载,而不是在页面初始化时就加载进来。
5.3 环境变量处理
有时候,你的组件库需要根据环境变量来改变行为。比如,在开发环境打印日志,在生产环境不打印。
在 Webpack 里,我们用 DefinePlugin。在 Rollup 里,我们用 @rollup/plugin-replace。
import replace from '@rollup/plugin-replace';
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
preventAssignment: true // 防止被意外赋值
})
]
然后在代码里:
if (process.env.NODE_ENV === 'development') {
console.log('Debugging...');
}
第六部分:CSS 与样式处理
React 组件库离不开 CSS。
6.1 CSS Modules
这是最推荐的方式。每个组件一个 CSS 文件,自动生成唯一的类名,避免样式冲突。
// SuperButton.tsx
import styles from './SuperButton.module.css';
export const SuperButton = ({ children }) => (
<button className={styles.button}>{children}</button>
);
6.2 PostCSS 与 Autoprefixer
为了兼容性,我们需要处理 CSS。安装 postcss 和 autoprefixer。
import postcss from 'rollup-plugin-postcss';
import autoprefixer from 'autoprefixer';
plugins: [
postcss({
plugins: [autoprefixer()],
inject: false, // 不把 style 标签注入到 HTML,因为我们的库是组件库,样式由使用者控制
extract: 'styles.css' // 提取所有样式到一个单独的文件(可选)
})
]
第七部分:常见坑与避坑指南
在开发 React 库的过程中,我们会遇到很多坑。作为过来人,我帮你总结一下:
7.1 循环依赖
如果你在 Button 里 import 了 Input,而在 Input 里 import 了 Button,你的构建工具会报错。
解决方法: 尽量打破循环依赖。把公共的逻辑提取到一个单独的文件里,或者使用事件总线。
7.2 TypeScript 类型丢失
如果你在 rollup.config.js 里没有正确配置 external,TypeScript 可能会把 react 的类型也打包进去,导致类型冲突。
解决方法: 确保 external: ['react', 'react-dom', 'react/jsx-runtime']。
7.3 UMD 构建的命名空间
当你构建 UMD 格式时,所有的导出都会被挂载到 MyLibrary 这个全局变量上。
// dist/index.umd.js
var MyLibrary = MyLibrary || {};
MyLibrary.SuperButton = SuperButton;
如果你的用户已经有一个叫 MyLibrary 的全局变量,你的库就会污染它。
解决方法: 使用 namespaces 配置,或者使用 IIFE 的方式封装。
第八部分:发布到 npm
代码写好了,打包好了,怎么让全世界都能用?
- 注册 npm 账号。
- 初始化 git 仓库。
- 修改
package.json:{ "private": false, // 必须是 false "publishConfig": { "access": "public" // 必须是 public,否则只能发布到私有仓库 } } - 运行
npm publish。
第九部分:总结与升华
好了,兄弟们,今天我们聊了很多。
我们从一个简单的 React 组件,一步步把它变成了一个可以发布的库。我们学会了如何使用 Rollup 这个“外科医生”来处理代码,如何使用 dts-bundle 这个“管家”来整理类型文件,以及如何配置 package.json 来适应不同的打包工具。
记住几个核心要点:
- 选对工具:构建库用 Rollup,别用 Webpack。
- 多格式输出:CJS, ESM, UMD 一个都不能少,照顾好老用户和新用户。
- 类型友好:让用户能舒服地
import { Component } from 'my-lib',别让他们去读文件路径。 - Tree-shaking:优化你的导出,让用户的包体积尽可能小。
- 外部依赖:React 这种核心库,一定要 external,别打包进去。
写代码不仅仅是把功能实现出来,更是一种艺术。一个优秀的组件库,就像一座精美的桥梁,连接了你的创造和用户的需求。
当你看到你的库被几千个开发者使用,他们的代码里闪烁着你的组件,那种成就感,比喝了十罐红牛还爽。
最后,送给大家一句话:代码如诗,构建如画。愿你的库永远没有 Bug,愿你的 Tree-shaking 永远成功!
现在,拿起你的键盘,去构建属于你的“SuperUI”吧!加油!