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

3 阅读2分钟

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

为什么需要自建组件库?

在 Ant Design、Material-UI 等成熟组件库大行其道的今天,为什么还要考虑自建组件库?原因主要有以下几点:

  1. 品牌一致性:自定义设计系统能完美匹配品牌视觉语言
  2. 技术栈定制:可以根据团队技术栈选择最适合的工具和框架
  3. 性能优化:按需打包,避免引入不必要的代码
  4. 特殊业务需求:针对特定业务场景开发专用组件

技术选型与架构设计

核心依赖

{
  "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