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',
})

性能优化

  1. Tree Shaking支持

    • 使用ES Modules格式
    • 每个组件独立导出
    • 避免副作用
  2. 包体积优化

    • 使用terser压缩代码
    • 去除未使用的依赖
    • 按需导入第三方库
  3. 类型定义优化

    • 继承原生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打包配置和主题系统的实现,让我对前端工程化有了更深的理解。