从零构建一个现代化的 React 组件库:架构设计与最佳实践

3 阅读1分钟

在当今的前端开发中,组件化已成为构建复杂应用的核心范式。无论是大型企业应用还是中小型项目,拥有一个设计良好、可维护的组件库都能显著提升开发效率和代码质量。本文将带你从零开始,构建一个现代化的 React 组件库,涵盖架构设计、开发工具链、测试策略和发布流程等关键环节。

为什么需要自建组件库?

你可能会有疑问:市面上已经有 Ant Design、Material-UI 等优秀的组件库,为什么还要自建?原因包括:

  1. 品牌一致性:自定义设计语言,完全匹配产品视觉规范
  2. 性能优化:只包含项目需要的组件,避免不必要的代码
  3. 特殊业务需求:针对特定业务场景定制组件
  4. 技术栈控制:完全掌控技术选型和实现细节

项目初始化与架构设计

1. 选择正确的构建工具

现代组件库构建有多种选择,我们推荐使用 Vite + TypeScript 的组合:

# 创建项目
npm create vite@latest my-component-library -- --template react-ts

# 安装必要依赖
cd my-component-library
npm install -D @types/react @types/react-dom
npm install -D @vitejs/plugin-react @types/node

2. 配置 monorepo 结构

对于组件库,采用 monorepo 结构可以更好地管理多个包:

// package.json
{
  "name": "my-component-library",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "test": "turbo run test"
  },
  "devDependencies": {
    "turbo": "^1.10.0"
  }
}

3. 设计组件目录结构

packages/
├── ui/
│   ├── src/
│   │   ├── components/
│   │   │   ├── Button/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Button.test.tsx
│   │   │   │   ├── Button.stories.tsx
│   │   │   │   ├── index.ts
│   │   │   │   └── styles.module.css
│   │   │   └── index.ts
│   │   └── index.ts
├── docs/
│   └── ... # 文档网站
└── playground/
    └── ... # 开发测试环境

核心组件开发实践

1. 基础 Button 组件实现

让我们从最基础的 Button 组件开始:

// packages/ui/src/components/Button/Button.tsx
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import classNames from 'classnames';
import styles from './styles.module.css';

export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'small' | 'medium' | 'large';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** 按钮变体 */
  variant?: ButtonVariant;
  /** 按钮尺寸 */
  size?: ButtonSize;
  /** 是否加载中 */
  loading?: boolean;
  /** 是否禁用 */
  disabled?: boolean;
  /** 是否块级元素 */
  block?: boolean;
  /** 自定义类名 */
  className?: string;
  /** 点击事件 */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}

export 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';

2. 样式方案选择

我们推荐使用 CSS Modules + CSS Variables 的组合:

/* packages/ui/src/components/Button/styles.module.css */
.button {
  --button-primary-bg: #1890ff;
  --button-primary-hover: #40a9ff;
  --button-primary-active: #096dd9;
  
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  border-radius: 6px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
  user-select: none;
  touch-action: manipulation;
  outline: none;
}

.button--primary {
  background-color: var(--button-primary-bg);
  color: white;
}

.button--primary:hover:not(.button--disabled) {
  background-color: var(--button-primary-hover);
}

.button--primary:active:not(.button--disabled) {
  background-color: var(--button-primary-active);
}

.button--medium {
  height: 32px;
  padding: 4px 15px;
  font-size: 14px;
}

.button--small {
  height: 24px;
  padding: 0 7px;
  font-size: 12px;
}

.button--large {
  height: 40px;
  padding: 6px 19px;
  font-size: 16px;
}

.button--block {
  width: 100%;
}

.button--loading {
  opacity: 0.65;
  cursor: not-allowed;
}

.button--disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.loadingSpinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  margin-right: 8px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: button-spin 1s linear infinite;
}

@keyframes button-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

3. 导出配置

// packages/ui/src/components/Button/index.ts
export { Button } from './Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';

// packages/ui/src/components/index.ts
export { Button } from './Button';

// packages/ui/src/index.ts
export * from './components';

开发工具链配置

1. TypeScript 配置

// packages/ui/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2020"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": 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"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

2. Vite 构建配置

// packages/ui/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,
      include: ['src'],
      exclude: ['**/*.