React组件库开发实践
从零构建企业级React组件库,使用TypeScript、Storybook和Rollup,包含完整的文档系统、单元测试和NPM发布流程
2024年2月20日
源代码ReactTypeScript组件库Storybook
项目简介
这是一个从零开始构建的企业级React组件库项目,涵盖了组件库开发的完整流程:从架构设计、TypeScript类型系统、Storybook文档、单元测试到NPM包发布。项目包含20+个常用UI组件,提供完整的类型定义和使用文档。
核心功能
1. 组件系统
- 20+个常用UI组件
- 完整的TypeScript类型定义
- 主题定制系统
- 响应式设计
- 无障碍访问支持
2. 开发工具
- Storybook文档和演示
- Jest + React Testing Library测试
- ESLint + Prettier代码规范
- Rollup构建打包
- 自动化发布流程
3. 主题系统
- CSS变量主题定制
- 深色模式支持
- 自定义主题配置
- 主题切换动画
技术栈
- React 18
- TypeScript
- Storybook 7
- Rollup
- Jest
- React Testing Library
- CSS Modules
- Husky + lint-staged
项目架构
react-ui-lib/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ └── index.ts
│ │ ├── Input/
│ │ └── ...
│ ├── hooks/
│ ├── utils/
│ ├── types/
│ └── index.ts
├── .storybook/
├── dist/
├── rollup.config.js
├── package.json
└── tsconfig.json核心实现
1. Button组件实现
// src/components/Button/Button.tsx
import React from 'react'
import styles from './Button.module.css'
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** 按钮变体 */
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
/** 按钮尺寸 */
size?: 'sm' | 'md' | 'lg'
/** 是否加载中 */
loading?: boolean
/** 是否全宽 */
fullWidth?: boolean
/** 左侧图标 */
leftIcon?: React.ReactNode
/** 右侧图标 */
rightIcon?: React.ReactNode
/** 子元素 */
children: React.ReactNode
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
leftIcon,
rightIcon,
children,
disabled,
className,
...props
},
ref
) => {
const classNames = [
styles.button,
styles[variant],
styles[size],
fullWidth && styles.fullWidth,
loading && styles.loading,
className,
]
.filter(Boolean)
.join(' ')
return (
<button
ref={ref}
className={classNames}
disabled={disabled || loading}
{...props}
>
{loading && <span className={styles.spinner} />}
{!loading && leftIcon && (
<span className={styles.leftIcon}>{leftIcon}</span>
)}
<span className={styles.content}>{children}</span>
{!loading && rightIcon && (
<span className={styles.rightIcon}>{rightIcon}</span>
)}
</button>
)
}
)
Button.displayName = 'Button'/* src/components/Button/Button.module.css */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: inherit;
font-weight: 500;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variants */
.primary {
background-color: var(--color-primary);
color: var(--color-primary-foreground);
}
.primary:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
.secondary {
background-color: var(--color-secondary);
color: var(--color-secondary-foreground);
}
.outline {
background-color: transparent;
border-color: var(--color-border);
color: var(--color-text);
}
.ghost {
background-color: transparent;
color: var(--color-text);
}
.ghost:hover:not(:disabled) {
background-color: var(--color-muted);
}
/* Sizes */
.sm {
height: 2rem;
padding: 0 0.75rem;
font-size: 0.875rem;
}
.md {
height: 2.5rem;
padding: 0 1rem;
font-size: 1rem;
}
.lg {
height: 3rem;
padding: 0 1.5rem;
font-size: 1.125rem;
}
.fullWidth {
width: 100%;
}
.loading {
pointer-events: none;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}2. Storybook配置
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
import { FiArrowRight, FiDownload } from 'react-icons/fi'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'outline', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
loading: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
}
export const WithIcons: Story = {
args: {
children: 'Download',
leftIcon: <FiDownload />,
rightIcon: <FiArrowRight />,
},
}
export const Loading: Story = {
args: {
loading: true,
children: 'Loading...',
},
}
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
}
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
}3. 单元测试
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('renders button with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('handles click events', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('disables button when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('disables button when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('renders with icons', () => {
render(
<Button leftIcon={<span data-testid="left-icon">←</span>}>
With Icon
</Button>
)
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('applies correct variant classes', () => {
const { rerender } = render(<Button variant="primary">Button</Button>)
let button = screen.getByRole('button')
expect(button).toHaveClass('primary')
rerender(<Button variant="outline">Button</Button>)
button = screen.getByRole('button')
expect(button).toHaveClass('outline')
})
})4. Rollup打包配置
// 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'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
}),
postcss({
modules: true,
extract: 'styles.css',
minimize: true,
}),
terser(),
],
external: ['react', 'react-dom'],
}5. NPM发布配置
// package.json
{
"name": "@yourorg/ui",
"version": "1.0.0",
"description": "A React component library",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rollup -c",
"test": "jest",
"test:watch": "jest --watch",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"prepublishOnly": "npm run test && npm run build",
"release": "np"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-typescript": "^11.0.0",
"@storybook/react": "^7.0.0",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.0.0",
"jest": "^29.0.0",
"rollup": "^3.0.0",
"typescript": "^5.0.0"
}
}6. 主题系统
// src/theme/ThemeProvider.tsx
import React, { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
useEffect(() => {
const root = document.documentElement
root.setAttribute('data-theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}/* src/theme/tokens.css */
:root {
/* Light theme */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-foreground: #ffffff;
--color-secondary: #64748b;
--color-secondary-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-border: #e2e8f0;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
[data-theme='dark'] {
--color-background: #0f172a;
--color-foreground: #f1f5f9;
--color-muted: #1e293b;
--color-border: #334155;
}遇到的挑战
问题1:CSS Modules在打包后无法使用
描述:组件库打包后,CSS样式无法正确应用
解决方案:
- 配置Rollup的postcss插件
- 提取CSS到单独文件
- 在入口文件导入样式
// rollup.config.js
postcss({
modules: true,
extract: 'styles.css', // 提取到单独文件
minimize: true,
})
// src/index.ts
import './styles.css' // 导入样式
export * from './components'问题2:TypeScript类型定义生成
描述:打包后缺少.d.ts类型定义文件
解决方案:
- 配置TypeScript插件生成声明文件
- 在package.json指定types入口
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
})性能优化
-
Tree Shaking支持
- 使用ES Modules格式
- 每个组件独立导出
- 避免副作用
-
包体积优化
- 使用terser压缩代码
- 去除未使用的依赖
- 按需导入第三方库
-
类型定义优化
- 继承原生HTML属性
- 使用泛型增强灵活性
发布流程
# 1. 测试
npm test
# 2. 构建
npm run build
# 3. 发布到NPM
npm publish --access public
# 4. 使用np自动化发布
npm run release使用示例
# 安装
npm install @yourorg/ui// 在项目中使用
import { Button, Input, Card } from '@yourorg/ui'
import '@yourorg/ui/dist/styles.css'
function App() {
return (
<Card>
<Input placeholder="Enter your name" />
<Button variant="primary">Submit</Button>
</Card>
)
}总结
通过这个项目,我系统性地学习了组件库开发的完整流程。从组件设计、TypeScript类型系统、Storybook文档到NPM发布,每个环节都有深入的实践。特别是Rollup打包配置和主题系统的实现,让我对前端工程化有了更深的理解。