在当今的前端开发中,组件化已成为构建复杂应用的核心范式。无论是大型企业级应用还是个人项目,拥有一个设计一致、可复用性高的组件库都能显著提升开发效率和代码质量。本文将深入探讨如何从零开始构建一个现代化的 React 组件库,涵盖架构设计、开发工具链、测试策略和发布流程等关键环节。
为什么需要自建组件库?
在 Ant Design、Material-UI 等成熟组件库大行其道的今天,为什么还要考虑自建组件库?原因主要有以下几点:
- 品牌一致性:自定义设计系统能完美匹配品牌视觉语言
- 技术栈定制:可以根据团队技术栈选择最适合的工具和框架
- 性能优化:按需打包,避免引入不必要的代码
- 特殊业务需求:针对特定业务场景开发专用组件
技术选型与架构设计
核心依赖
{
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0"
}
构建工具选择
现代组件库构建主要有两种方案:
方案一:Rollup + TypeScript
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import postcss from 'rollup-plugin-postcss';
import { terser } from 'rollup-plugin-terser';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true
},
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true
}
],
plugins: [
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist/types'
}),
postcss({
extract: true,
minimize: true,
modules: true
}),
terser()
],
external: ['react', 'react-dom']
};
方案二:Vite + TS
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { libInjectCss } from 'vite-plugin-lib-inject-css';
export default defineConfig({
plugins: [
react(),
libInjectCss(),
dts({
insertTypesEntry: true,
include: ['src/**/*']
})
],
build: {
lib: {
entry: 'src/index.ts',
name: 'MyComponentLibrary',
formats: ['es', 'umd'],
fileName: (format) => `index.${format}.js`
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
}
}
});
目录结构设计
my-component-library/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ ├── Button.test.tsx
│ │ │ └── index.ts
│ │ ├── Input/
│ │ └── index.ts
│ ├── hooks/
│ ├── utils/
│ └── index.ts
├── stories/ # Storybook 文档
├── tests/
├── package.json
├── tsconfig.json
├── rollup.config.js
└── README.md
组件开发实践
基础组件示例:Button
// src/components/Button/Button.tsx
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import classNames from 'classnames';
import styles from './Button.module.css';
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;
/** 点击事件 */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
block = false,
className,
onClick,
...rest
},
ref
) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (loading || disabled) {
event.preventDefault();
return;
}
onClick?.(event);
};
const buttonClasses = classNames(
styles.button,
styles[`button--${variant}`],
styles[`button--${size}`],
{
[styles['button--loading']]: loading,
[styles['button--disabled']]: disabled,
[styles['button--block']]: block,
},
className
);
return (
<button
ref={ref}
className={buttonClasses}
disabled={disabled || loading}
onClick={handleClick}
aria-busy={loading}
{...rest}
>
{loading && (
<span className={styles.loadingSpinner} aria-hidden="true" />
)}
<span className={styles.content}>{children}</span>
</button>
);
}
);
Button.displayName = 'Button';
export default Button;
/* src/components/Button/Button.module.css */
.button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
outline: none;
}
.button--primary {
background-color: #007bff;
color: white;
}
.button--primary:hover:not(:disabled) {
background-color: #0056b3;
}
.button--secondary {
background-color: #6c757d;
color: white;
}
.button--small {
padding: 6px 12px;
font-size: 12px;
}
.button--medium {
padding: 8px 16px;
font-size: 14px;
}
.button--large {
padding: 12px 24px;
font-size: 16px;
}
.button--block {
display: flex;
width: 100%;
}
.button--loading {
cursor: wait;
opacity: 0.7;
}
.button--disabled {
cursor: not-allowed;
opacity: 0.5;
}
.loadingSpinner {
width: 16px;
height: 16px;
margin-right: 8px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
复合组件示例:Modal
// src/components/Modal/Modal.tsx
import React, { useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { createPortalRoot } from '../../utils/portal';
import styles from './Modal.module.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
closeOnOverlayClick?: boolean;
showCloseButton?: boolean;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
closeOnOverlayClick = true,
showCloseButton = true,
}) => {
const portalRoot = createPortalRoot('modal-root');
const handleEscapeKey = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
},
[onClose]
);
const handleOverlayClick = (event: React.MouseEvent) => {
if (closeOnOverlayClick && event.target === event.currentTarget) {
onClose();
}
};
useEffect