快速创建一个react组件库
如果你正在管理多个 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 项目已经准备就绪。
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"],
虽然需要为 src 和 lib 文件夹都启用 Ts,但在构建库时最好不包含 src。
为确保在构建过程中只包含 lib 目录,你可以专门为构建创建一个单独的 Ts 配置文件。
💡 实施这种单独的配置有助于避免当你在演示页面上直接从
dist文件夹导入组件,而这些组件尚未构建时出现的 Ts 错误。
📂my-component-library
┣ …
┣ 📜tsconfig.json
+┣ 📜tsconfig.lib.json
…
唯一的区别是 📜tsconfig.lib.json 中的构建配置只包含 lib 目录,而默认配置包含 lib 和 src
{
"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.ts 从 src 复制到 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 会为 src 和 lib 生成类型,因为两个文件夹都包含在项目的 .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
但这个文件有两个问题:
- 你需要在使用的项目里,手动导入该样式文件。
- 该样式文件包含所有组件的样式的文件(非常大,包含我们用不到的其它组件样式)。
怎么办?请接着往下看 👇
导入 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.json或README总是包含在内,无论设置如何:阅读文档
依赖项
现在看看你的 dependencies:现在应该只有两个 react 和 react-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
希望你觉得有帮助,我很乐意听到你想分享的任何想法。


浙公网安备 33010602011771号