在当今的前端开发中,组件化已经成为构建用户界面的标准范式。无论是大型企业应用还是个人项目,拥有一个设计一致、可复用性高的组件库都能显著提升开发效率和代码质量。本文将带你从零开始,构建一个现代化的 React 组件库,涵盖架构设计、开发工具、最佳实践和发布流程。
为什么需要自建组件库?
在开始之前,我们先明确自建组件库的价值:
- 设计一致性:确保整个应用的设计语言统一
- 开发效率:避免重复造轮子,专注业务逻辑
- 维护性:一处修改,处处更新
- 团队协作:提供标准的开发范式,降低沟通成本
- 质量保证:统一的测试和文档标准
技术选型与架构设计
核心依赖
json
{
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0"
}
构建工具链
现代组件库需要一套完整的工具链支持:
json
{
"vite": "^4.0.0", // 构建工具
"storybook": "^7.0.0", // 组件文档和开发环境
"jest": "^29.0.0", // 单元测试
"@testing-library/react": "^14.0.0",
"eslint": "^8.0.0", // 代码检查
"prettier": "^3.0.0", // 代码格式化
"husky": "^8.0.0", // Git hooks
"lint-staged": "^13.0.0"
}
项目初始化与配置
1. 创建项目结构
perl
my-component-library/
├── src/
│ ├── components/ # 组件源码
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ └── index.ts
│ │ └── index.ts # 组件导出
│ ├── styles/ # 样式文件
│ │ ├── variables.scss
│ │ └── mixins.scss
│ └── utils/ # 工具函数
├── stories/ # Storybook 配置
├── tests/ # 测试配置
├── .eslintrc.js
├── .prettierrc
├── tsconfig.json
├── vite.config.ts
└── package.json
2. 配置 TypeScript
json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"declarationDir": "dist/types",
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.tsx", "**/*.stories.tsx"]
}
3. 配置 Vite 构建
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/components/index.ts'),
name: 'MyComponentLibrary',
formats: ['es', 'umd'],
fileName: (format) => `my-component-library.${format}.js`,
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
})
开发第一个组件:Button
1. 组件实现
typescript
// src/components/Button/Button.tsx
import React, { forwardRef, ButtonHTMLAttributes } from 'react'
import classNames from 'classnames'
import './Button.scss'
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
export type ButtonSize = 'small' | 'medium' | 'large'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 按钮变体 */
variant?: ButtonVariant
/** 按钮尺寸 */
size?: ButtonSize
/** 是否加载中 */
loading?: boolean
/** 是否禁用 */
disabled?: boolean
/** 是否块级元素 */
block?: boolean
/** 图标 */
icon?: React.ReactNode
/** 点击事件 */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
block = false,
icon,
className,
onClick,
...rest
},
ref
) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (loading || disabled) return
onClick?.(event)
}
const classes = classNames(
'btn',
`btn--${variant}`,
`btn--${size}`,
{
'btn--loading': loading,
'btn--disabled': disabled,
'btn--block': block,
},
className
)
return (
<button
ref={ref}
className={classes}
disabled={disabled || loading}
onClick={handleClick}
aria-busy={loading}
{...rest}
>
{loading && (
<span className="btn__loader" aria-hidden="true">
<svg className="btn__loader-svg" viewBox="0 0 50 50">
<circle className="btn__loader-circle" cx="25" cy="25" r="20" />
</svg>
</span>
)}
{icon && !loading && <span className="btn__icon">{icon}</span>}
<span className="btn__content">{children}</span>
</button>
)
}
)
Button.displayName = 'Button'
2. 样式实现(使用 Sass)
scss
// src/components/Button/Button.scss
@import '../../styles/variables';
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 3px rgba($primary-color, 0.3);
}
// 尺寸
&--small {
padding: 6px 12px;
font-size: 12px;
line-height: 1.5;
}
&--medium {
padding: 8px 16px;
font-size: 14px;
line-height: 1.5;
}
&--large {
padding: 12px 24px;
font-size: 16px;
line-height: 1.5;
}
// 变体
&--primary {
background-color: $primary-color;
color: white;
&:hover:not(:disabled) {
background-color: darken($primary-color, 10%);
}
&:active:not(:disabled) {
background-color: darken($primary-color, 15%);
}
}
&--secondary {
background-color: $secondary-color;
color: $text-color;
&:hover:not(:disabled) {
background-color: darken($secondary-color, 10%