React 库的开发规范:利用 Rollup 与 Dts-bundle 生成高性能、多格式支持的 React 组件库

灵魂拷问:你的 React 组件库是给谁用的?是给人类看的,还是给机器看的?

大家好,欢迎来到今天的“搬砖进阶班”。我是你们的老朋友,一个在代码堆里摸爬滚打多年,看着各种构建工具从 Webpack 1.0 到 Vite,头发越来越稀疏,技术却越来越硬核的资深工程师。

今天我们不聊业务逻辑,不聊怎么把那个难搞的需求搞定。今天我们要聊的是——如何优雅地把你写的那堆 React 组件,打包成一个让全世界开发者都爱不释手的“超级英雄”库。

想象一下,你辛辛苦苦写了三个组件:SuperButtonSuperInputSuperModal。你把它们放在 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

这些插件是干嘛的?我们一个个来看:

  1. @rollup/plugin-node-resolve: 这个插件就像是你的“导航员”。当你写 import Button from 'my-lib/Button' 时,它负责去 node_modules 里找到这个文件。
  2. @rollup/plugin-commonjs: 这个插件负责处理旧的代码。很多老库是用 CommonJS 写的(module.exports = ...),而 Rollup 只认识 ES Modules。这个插件负责把它们翻译成 Rollup 能听懂的语言。
  3. @rollup/plugin-babel: React 组件是 JSX 写的,浏览器不认识。这个插件负责把 JSX 编译成普通的 JavaScript。
  4. 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 的原理讲透。它的核心逻辑是:

  1. 读取 index.d.ts
  2. 找到所有的 export * from './xxx'
  3. ./xxx 的内容“展开”到 index.d.ts 里。
  4. 删除重复的导出。

如果你坚持要用 dts-bundle,请务必在 tsconfig.json 里设置 declaration: trueoutDir: '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"
  }
}

注意看 mainmodule 字段。当用户执行 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。安装 postcssautoprefixer

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

代码写好了,打包好了,怎么让全世界都能用?

  1. 注册 npm 账号
  2. 初始化 git 仓库
  3. 修改 package.json
    {
      "private": false, // 必须是 false
      "publishConfig": {
        "access": "public" // 必须是 public,否则只能发布到私有仓库
      }
    }
  4. 运行 npm publish

第九部分:总结与升华

好了,兄弟们,今天我们聊了很多。

我们从一个简单的 React 组件,一步步把它变成了一个可以发布的库。我们学会了如何使用 Rollup 这个“外科医生”来处理代码,如何使用 dts-bundle 这个“管家”来整理类型文件,以及如何配置 package.json 来适应不同的打包工具。

记住几个核心要点:

  1. 选对工具:构建库用 Rollup,别用 Webpack。
  2. 多格式输出:CJS, ESM, UMD 一个都不能少,照顾好老用户和新用户。
  3. 类型友好:让用户能舒服地 import { Component } from 'my-lib',别让他们去读文件路径。
  4. Tree-shaking:优化你的导出,让用户的包体积尽可能小。
  5. 外部依赖:React 这种核心库,一定要 external,别打包进去。

写代码不仅仅是把功能实现出来,更是一种艺术。一个优秀的组件库,就像一座精美的桥梁,连接了你的创造和用户的需求。

当你看到你的库被几千个开发者使用,他们的代码里闪烁着你的组件,那种成就感,比喝了十罐红牛还爽。

最后,送给大家一句话:代码如诗,构建如画。愿你的库永远没有 Bug,愿你的 Tree-shaking 永远成功!

现在,拿起你的键盘,去构建属于你的“SuperUI”吧!加油!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注