快速创建一个react组件库

快速创建组件库🚀(使用 Vite 的库模式)封面图

原文在此!

如果你正在管理多个 React 应用,并希望在ui保持一致,迟早你会发现需要一个组件库。

当我第一次想要创建一个 React 组件库时,花了很多时间 才找到一个满足我所有要求 且不太复杂的设置。

本篇指南可以为我节省大量与这些东西搏斗的精力,我也希望它能帮助到你。

此文章涵盖了 React 组件库 的编写和发布,包括配置构建过程和将包发布到 npm!。

我已经尽力保持所有配置简单明了,尽可能使用默认设置。

完成后,你可以像安装任何其他 npm 包一样安装你的库:

npm install @username/my-component-library

并像这样使用它:

import { Button } from `@username/my-component-library`;

function MyComponent() {
  return <Button>Click me!</Button>
}

开始之前

在深入实现细节之前,我想详细说明一些关于库设置的技术细节。

🌳 Tree shaking

对我来说,特别重要的一点是:“最终的应用程序里只包含真正必要的代码”。当你导入一个组件时,它只包含必要的 JS 和 CSS 样式,干净利落,很酷吧?

🦑 Css Modules

组件使用 CSS Modules 来写样式。在打包成库时,它们会被编译成普通 CSS 文件,因此使用方完全不需要支持 CSS Modules。

额外的好处是:把 CSS Modules 提前编译后,就绕过了兼容性问题,既可直接引入这个包,零配置。

🧁 如果你想用 vanilla-extract 而不是 CSS Modules,在文章底部有对应的示例,可以参考。

😎 TypeScript

虽然这个库是用 TypeScript 写的,但普通 JavaScript 项目也能无缝使用。
如果你还没试过 TypeScript,不妨尝试下Ts:它不仅能逼你写更干净的代码,还能让你的 AI 小助手给出更好的建议 😉

好了,读够了,现在让我们开始享受乐趣吧!

1.新建 Vite 项目

如果你从未用过 Vite,可以将其视为 Create React App 的替代品。只需几个命令,你就可以开始了。

npm create vite@latest
? Project name: › my-component-library
? Select a framework: › React
? Select a variant: › TypeScript
cd my-component-library
npm i

就是这样,你的新 Vite/React 项目已经准备就绪。

这里有两件我建议你在安装 Vite 后立即做的事情。

2. 基本构建设置

现在你可以运行 npm run dev 并访问 Vite 提供的 URL。
在开发库时,这个环境可以轻松导入你的库并实时查看组件效果(请将 src 内的内容均视为演示页面 example or demo)。

而咱们的库的代码将存放在另一个文件夹中,比如这里我们创建一个名为 lib 的文件夹(也可选用其它名称,但 lib 是个大家都常用的的选择)。

库的主要入口点将是 lib 内名为 main.ts 的文件。安装库时,你可以导入从此文件导出的所有内容。

 📂my-component-library
 +┣ 📂lib
 +┃ ┗ 📜main.ts
  ┣ 📂public
  ┣ 📂src
  …

Vite 库模式

当前,如果您运行 npm run build,Vite 会默认将 src 目录下的代码构建并输出到 dist 文件夹。这是 Vite 的标准行为。

不过,目前我们 src 目录中的内容仅用于开发环境下启动和演示,因此并没有被构建的必要。相反,lib 目录中的代码才是我们真正需要构建、编译并发布(到 npm) 的部分!

这正是 Vite 库模式 发挥作用的地方。该模式专为构建库(Library)而设计。要激活此模式,只需在 vite.config.ts 配置文件中指定您的库入口点即可。

import { defineConfig } from 'vite'
+ import { resolve } from 'path'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
+  build: {
+    lib: {
+      entry: resolve(__dirname, 'lib/main.ts'),
+      formats: ['es']
+    }
  }
})

💡 默认格式是 'es''umd'。对于你的组件库,'es' 就足够了。这也消除了添加 name 属性的必要性。

💡 如果你的 TypeScript linter 抱怨 'path'__dirname,只需安装 node 的类型:npm i @types/node -D

📘 Library 模式文档
📘 lib 模式文档

TypeScript 和库模式

Vite 创建的 tsconfig.json 只包含 src 文件夹。要为你新创建的 lib 文件夹也启用 Ts,你需要将其添加到 Ts 配置文件中

-   "include": ["src"],
+   "include": ["src", "lib"],

虽然需要为 srclib 文件夹都启用 Ts,但在构建库时最好不包含 src

为确保在构建过程中只包含 lib 目录,你可以专门为构建创建一个单独的 Ts 配置文件。

💡 实施这种单独的配置有助于避免当你在演示页面上直接从 dist 文件夹导入组件,而这些组件尚未构建时出现的 Ts 错误。

 📂my-component-library
  ┣ …
  ┣ 📜tsconfig.json
 +┣ 📜tsconfig.lib.json
  …

唯一的区别是 📜tsconfig.lib.json 中的构建配置只包含 lib 目录,而默认配置包含 libsrc

{
    "extends": "./tsconfig.app.json",
    "include": [
        "lib"
    ]
}

要使用 tsconfig.lib.json 进行构建,你需要在 package.json 的构建脚本中将配置文件传递给 tsc

  "scripts": {
    …
-   "build": "tsc -b && vite build",
+   "build": "tsc --p ./tsconfig.lib.json && vite build",

最后,你还需要将文件 vite-env.d.tssrc 复制到 lib。没有这个文件,Ts 在构建时会错过 Vite 提供的一些类型定义(因为我们不再包含 src)。

现在你可以再次执行 npm run build,这是你将在 dist 文件夹中看到的内容:

 📂dist
  ┣ 📜my-component-library.js
  ┗ 📜vite.svg

💡 默认情况下,输出文件的名称与 package.json 中的 name 属性相同。这可以在 Vite 配置中更改(build.lib.fileName),但我们稍后会对此做其他处理。

文件 vite.svg 在你的 dist 文件夹中,因为 Vite 将 public 目录中的所有文件复制到输出文件夹。让我们禁用此行为:

build: {
+  copyPublicDir: false,
…
}

你可以在这里阅读更详细的解释:为什么 vite.svg 文件会出现在 dist 文件夹中?

构建类型

由于这是一个 Ts 库,你还希望随包一起发布类型定义。幸运的是,有一个 Vite 插件可以做到这一点:vite-plugin-dts

npm i vite-plugin-dts -D

默认情况下,dts 会为 srclib 生成类型,因为两个文件夹都包含在项目的 .tsconfig 中。这就是为什么我们需要传递一个配置参数:include: ['lib']

// vite.config.ts
+import dts from 'vite-plugin-dts'
…
  plugins: [
    react(),
+   dts({ include: ['lib'] })
  ],
…

💡 使用 exclude: ['src'] 或为构建使用不同的 Ts 配置文件也可以工作。

为了测试,让我们向你的库添加一些实际代码。打开 lib/main.ts 并导出一些内容,例如:

// lib/main.ts
export function helloAnything(thing: string): string {
  return `Hello ${thing}!`
}

然后运行 npm run build 来转译你的代码。如果你的 dist 文件夹的内容如下所示,你应该已经准备就绪 🥳:

 📂dist
  ┣ 📜main.d.ts
  ┗ 📜my-component-library.js

💡 不要害羞,打开文件看看程序为你做了什么!

3. 没有组件的 React 组件库算什么?

我们做这一切可不仅仅只是为了导出一个 helloAnything 函数😔,所以让我们为我们的库添加一些有意义实质内容:让我们使用三个非常常见的基本组件--按钮、标签和输入框。

 📂my-component-library
  ┣ 📂lib
 +┃ ┣ 📂components
 +┃ ┃ ┣ 📂Button
 +┃ ┃ ┃ ┗ 📜index.tsx
 +┃ ┃ ┣ 📂Input
 +┃ ┃ ┃ ┗ 📜index.tsx
 +┃ ┃ ┗ 📂Label
 +┃ ┃   ┗ 📜index.tsx
  ┃ ┗ 📜main.ts
  …

以及这些组件的非常基本的实现:

// lib/components/Button/index.tsx
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button {...props} />
}
// lib/components/Input/index.tsx
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
  return <input {...props} />
}
// lib/components/Label/index.tsx
export function Label(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
  return <label {...props} />
}

最后从库的主文件中导出组件:

// lib/main.ts
export { Button } from './components/Button'
export { Input } from './components/Input'
export { Label } from './components/Label'

如果你再次运行 npm run build,你会注意到编译后的文件 my-component-library.js 现在有 78kb 😮

上面组件的实现包含 React JSX 代码,因此 react(和 react/jsx-runtime)也被打包了。

不过由于这个库 将会在已经安装了 React 的项目中使用,你可以将这些依赖项外部化(即你的react项目已经包含了react运行时库),以从包中移除代码:

//vite.config.ts
  build: {
    …
+   rollupOptions: {
+     // 作用和Webpack 中的 externals一样,
+     // 告诉打包工具哪些模块不应该被打包到最终的 bundle 中,而是在运行时从外部环境获取。
+     external: ['react', 'react/jsx-runtime'], 
+   }
  }

4. 添加一些样式

如开头所述,这个库将使用 Css Module 来为组件设置样式。

Vite 默认支持 Css Module,你所要做的就是创建以 .module.css 结尾的 CSS 文件即可。

 📂my-component-library
  ┣ 📂lib
  ┃ ┣ 📂components
  ┃ ┃ ┣ 📂Button
  ┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
  ┃ ┃ ┣ 📂Input
  ┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
  ┃ ┃ ┗ 📂Label
  ┃ ┃   ┣ 📜index.tsx
+ ┃ ┃   ┗ 📜styles.module.css
  ┃ ┗ 📜main.ts
  …

并添加一些基本的 CSS 类:

/* lib/components/Button/styles.module.css */
.button {
    padding: 1rem;
}
/* lib/components/Input/styles.module.css */
.input {
    padding: 1rem;
}
/* lib/components/Label/styles.module.css */
.label {
    font-weight: bold;
}

然后在你的组件中导入/使用它们,例如:

import styles from './styles.module.css'

export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { className, ...restProps } = props
  return <button className={`${className} ${styles.button}`} {...restProps} />
}

⛴️ 发布你的样式

转译库后,你会注意到分发文件夹中有一个新文件:

 📂dist
  ┣ …
  ┣ 📜my-component-library.js
+ ┗ 📜style.css

但这个文件有两个问题:

  1. 你需要在使用的项目里,手动导入该样式文件。
  2. 该样式文件包含所有组件的样式的文件(非常大,包含我们用不到的其它组件样式)。

怎么办?请接着往下看 👇

导入 CSS

由于 CSS 文件无法直接在 JavaScript 中轻松导入,因此需要单独生成 CSS 文件,让库的使用者自行决定如何处理该文件。

但但如果我们假设:“使用该库的应用程序已经配置了能处理 CSS 导入的打包器配置”会怎样?

要实现这种处理方式,就需要要求我们编译后 Js 包必须包含 CSS 文件的导入语句,这里我们将使用另一个 Vite 插件 👉 vite-plugin-lib-inject-css 来达到这个效果,且零配置。

npm i vite-plugin-lib-inject-css -D
// vite.config.ts
+import { libInjectCss } from 'vite-plugin-lib-inject-css'
…
  plugins: [
    react(),
+   libInjectCss(),
    dts({ include: ['lib'] })
  ],
…

构建库 并 查看打包的 Js 文件(dist/my-component-library.js)的顶部,你会发现 OBJK成了😊

// dist/my-component-library.js
import "./main.css";
…

💡 你可能会注意到 CSS 文件名已从 style.css 更改为 main.css。发生此更改是因为插件为每个块生成单独的 CSS 文件,在这种情况下,块的名称来自入口文件的文件名。

拆分 CSS

接下来我们解决第二个问题:当你从库中导入某些内容时,main.css 也会被导入,所有 CSS 样式最终都会出现在你的应用程序中,即使你只导入 Button这个组件。

好在libInjectCSS 插件会为每个组件代码块生成独立的 CSS 文件,并在每个代码块输出文件的开头添加对应的导入语句。

因此,如果您对 Js 代码进行拆分,就将会得到独立的 CSS 文件——且这些样式文件只会在对应的 JavaScript 文件被导入时才会同步加载,这就是解决方案!

实现此功能的一种方法是将每个文件都转换为 Rollup 的入口点。更赞的是,Rollup 文档中正好推荐了这种实现方式:

📘 如果你想将一组文件转换为另一种格式,同时保持文件结构和导出签名,推荐的做法是将每个文件都设为入口点——而不是使用 output.preserveModules 选项(该选项可能会对导出进行 tree-shake 优化,并输出插件创建的虚拟文件)。

所以让我们将此添加到你的配置中。

首先安装 glob,因为它是必需的:

npm i glob -D

然后将你的 Vite 配置更改为:

// vite.config.ts
-import { resolve } from 'path'
+import { extname, relative, resolve } from 'path'
+import { fileURLToPath } from 'node:url'
+import { glob } from 'glob'
…
    rollupOptions: {
      external: ['react', 'react/jsx-runtime'],
+     input: Object.fromEntries(
+       glob.sync('lib/**/*.{ts,tsx}', {
+         ignore: ["lib/**/*.d.ts"],
+       }).map(file => [
+         // 入口点的名称
+         // lib/nested/foo.ts 变成 nested/foo
+         relative(
+           'lib',
+           file.slice(0, file.length - extname(file).length)
+         ),
+         // 入口文件的绝对路径
+         // lib/nested/foo.ts 变成 /project/lib/nested/foo.ts
+         fileURLToPath(new URL(file, import.meta.url))
+       ])
+     )
    }
…

💡 glob 库帮助你指定一组文件名。在这种情况下,它选择所有以 .ts.tsx 结尾的文件,并忽略 *.d.ts 文件 Glob Wikipedia

现在你的 dist 文件夹根目录中会有一堆 Js 和 CSS 文件。它可以工作,但看起来不是特别美观,不是吗?

// vite.config.ts
    rollupOptions: {
…
+     output: {
+       assetFileNames: 'assets/[name][extname]',
+       entryFileNames: '[name].js',
+     }
    }
…

重新打包lib,你会发现所有 Js 文件现在应该与它们的类型定义一起位于你在 lib 中创建的相同有组织的文件夹结构中。CSS 文件位于名为 assets 的新文件夹内。🙌

注意 主文件的名称已从"my-component-library.js"更改为"main.js",真棒👍!

4. 发布包之前的最后几个步骤

你的构建设置现在已经准备就绪,在发布包之前只需要考虑几件事。

package.json 文件将与你的包文件一起发布。你需要确保它包含有关包的所有重要信息。

主文件

每个 npm 包都有一个主要入口点,默认情况下这个文件是包根目录中的 index.js

你的库的主要入口点现在位于 dist/main.js,因此需要在你的 package.json 中设置。类型的入口点也是如此:dist/main.d.ts

// package.json
{
  "name": "my-component-library",
  "private": true,
  "version": "0.0.0",
  "type": "module",
+ "main": "dist/main.js",
+ "types": "dist/main.d.ts",
  …

定义要发布的文件

你还应该定义哪些文件应该打包到你的分发包中。

// package.json
  …
  "main": "dist/main.js",
  "types": "dist/main.d.ts",
+ "files": [
+   "dist"
+ ],
  …

💡 某些文件如 package.jsonREADME 总是包含在内,无论设置如何:阅读文档

依赖项

现在看看你的 dependencies:现在应该只有两个 reactreact-dom 以及一些 devDependencies

你也可以将这两个移动到 devDependencies 中。并且另外将它们添加为 peerDependencies,以便使用应用程序知道它必须安装 React 才能使用此包。

// package.json
- "dependencies": {
+ "peerDependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
+   "react": "^18.2.0",
+   "react-dom": "^18.2.0",
    …
  }

💡 查看此 StackOverflow 答案以了解有关不同类型依赖项的更多信息:链接

副作用

为了防止 CSS 文件被消费者的 tree-shaking 工作意外删除,你还应该将生成的 CSS 指定为副作用:

// package.json
+ "sideEffects": [
+   "**/*.css"
+ ],

你可以在 webpack 文档中阅读有关 sideEffects 的更多信息。(最初来自 Webpack,此字段已发展为现在也受其他打包器支持的通用模式)

确保包已构建

你可以使用特殊的生命周期脚本 prepublishOnly 来保证在发布包之前始终构建你的更改:

// package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    …
+   "prepublishOnly": "npm run build"
  },

5. 演示页面和部署

要在演示页面上使用你的组件,你可以简单地直接从项目根目录导入组件。这是可行的,因为你的 package.json 指向转译后的主文件 dist/main.ts

// src/App.tsx
import { Button, Label, Input } from '../';
…

要发布你的包,你只需要运行 npm publish。如果你想将包发布给公众,你必须在 package.json 中设置 private: false

你可以在我的这些文章中阅读有关发布包的更多信息,包括在本地项目中安装它(不发布):
发布和安装你的包
利用 GitHub actions自动构建和发布

常见问题

我可以使用 vanilla extract 而不是 CSS 模块吗?

这里有一个使用 vanilla extract 的分支:https://github.com/receter/my-component-library/tree/vanilla-extract

为了仍然能够使用 npm run dev 测试你的库,有必要在 vite.config.ts 中为 "lib/**/*.css.ts" 添加 ignore,以避免 vanillaExtractPlugin() 作用于编译后的文件。

我在最新版本的 create vite 上遇到问题

我还没有更新这篇文章,按照这个指南,你可能会在最新版本的 create vite 上遇到一些问题。不过我确实创建了一个分支,对 vite@5.4.4 进行了一些修改:

https://github.com/receter/my-component-library/tree/revision-1

我可以从输出中删除 CSS 导入吗?

是的,你可以轻松删除 vite-plugin-lib-inject-css 插件(以及随后从你的 package.json 中删除 sideEffects

完成后,你将在 dist/assets/style.css 中获得一个包含所有必需类的编译样式表。在你的应用程序中导入/使用此样式表,你应该就可以了。

当然,你会失去 CSS tree-shaking 功能,该功能通过仅在每个组件内导入所需的 CSS 来实现。

我在这里发布了一个演示此更改的分支:https://github.com/receter/my-component-library/tree/no-css-injection

这适用于 Next.js 吗?

从外部 npm 包导入 CSS 自 Next.js 13.4 起可以工作:
https://github.com/vercel/next.js/discussions/27953#discussioncomment-5831478

如果你使用较旧版本的 Next.js,你可以安装 next-transpile-modules

这里是一个 Next.js 演示仓库:https://github.com/receter/my-nextjs-component-library-consumer

错误:找不到模块 'ajv/dist/core'

如果你将 vite-plugin-dts@4--legacy-peer-deps 结合使用,就会发生此错误。解决方案是手动安装 ajv@8 或停止使用 --legacy-peer-deps

npm i ajv@8

https://github.com/qmhc/vite-plugin-dts/issues/388

如何为我的库使用 Storybook?

要安装 Storybook,运行 npx storybook@latest init 并开始添加你的故事。

如果你在 lib 文件夹内添加故事,你还需要确保从 glob 模式中排除所有 .stories.tsx 文件,这样故事就不会出现在你的包中。

glob.sync('lib/**/*.{ts,tsx}', { ignore: 'lib/**/*.stories.tsx'})

我在这里发布了一个带有 Storybook 的分支:https://github.com/receter/my-component-library/tree/storybook

为了能够构建 Storybook,你需要禁用 libInjectCss 插件。否则,在运行 npm run build-storybook 时会遇到 TypeError: Cannot convert undefined or null to object 错误(感谢 @codalf 找出了这个问题!)

2024年3月26日更新:这个问题(#15)与 vite-plugin-lib-inject-css 有关,已在版本 2.0.0 中修复,不再需要修复。

感谢阅读!

如果你没有跟着做或者有什么不清楚的地方,你可以在我的 GitHub Profile 上找到完整的源代码和工作示例:
https://github.com/receter/my-component-library
https://github.com/receter/my-component-library/tree/revision-1 (Revision that works with latest Vite version)
https://github.com/receter/my-component-library-consumer
https://www.npmjs.com/package/@receter/my-component-library

希望你觉得有帮助,我很乐意听到你想分享的任何想法。

posted @ 2025-09-12 17:19  丁少华  阅读(79)  评论(0)    收藏  举报