从零到一:构建高性能React组件库的完整指南

2 阅读1分钟

引言:为什么需要自建组件库?

在现代前端开发中,组件库已成为提升开发效率、保证产品一致性的关键工具。虽然市面上有Ant Design、Material-UI等优秀的开源组件库,但在实际企业级项目中,自建组件库往往能更好地满足特定业务需求、设计规范和性能要求。

本文将带你从零开始,构建一个高性能、可维护的React组件库,涵盖架构设计、开发规范、性能优化到发布部署的全流程。

一、架构设计与技术选型

1.1 项目初始化

首先,我们使用现代构建工具创建项目基础结构:

# 创建项目目录
mkdir my-react-components
cd my-react-components

# 初始化monorepo结构
npm init -y

# 安装必要依赖
npm install -D typescript @types/react @types/react-dom
npm install -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs
npm install -D @rollup/plugin-typescript rollup-plugin-postcss
npm install -D rollup-plugin-terser rollup-plugin-peer-deps-external

1.2 项目结构设计

my-react-components/
├── packages/
│   ├── components/          # 组件源码
│   │   ├── src/
│   │   │   ├── Button/
│   │   │   │   ├── index.tsx
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Button.types.ts
│   │   │   │   ├── Button.test.tsx
│   │   │   │   └── Button.stories.tsx
│   │   │   └── index.ts     # 组件统一导出
│   │   └── package.json
│   └── docs/               # 文档站点
├── package.json
├── tsconfig.json
├── rollup.config.js
└── .eslintrc.js

二、核心组件开发实践

2.1 基础Button组件实现

让我们从最基础的Button组件开始,展示如何构建一个健壮的React组件:

// packages/components/src/Button/Button.tsx
import React, { forwardRef, useMemo } from 'react';
import classNames from 'classnames';
import { ButtonProps, ButtonSize, ButtonVariant } from './Button.types';
import './Button.css';

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      variant = 'primary',
      size = 'medium',
      disabled = false,
      loading = false,
      fullWidth = false,
      className,
      onClick,
      ...restProps
    },
    ref
  ) => {
    // 使用useMemo优化样式计算
    const buttonClasses = useMemo(() => {
      return classNames(
        'btn',
        `btn-${variant}`,
        `btn-${size}`,
        {
          'btn-disabled': disabled,
          'btn-loading': loading,
          'btn-full-width': fullWidth,
        },
        className
      );
    }, [variant, size, disabled, loading, fullWidth, className]);

    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled || loading) return;
      onClick?.(e);
    };

    return (
      <button
        ref={ref}
        className={buttonClasses}
        disabled={disabled || loading}
        onClick={handleClick}
        aria-busy={loading}
        {...restProps}
      >
        {loading && (
          <span className="btn-spinner" aria-hidden="true">
            <svg className="spinner" viewBox="0 0 50 50">
              <circle className="path" cx="25" cy="25" r="20" fill="none" />
            </svg>
          </span>
        )}
        <span className="btn-content">{children}</span>
      </button>
    );
  }
);

Button.displayName = 'Button';
export default Button;

2.2 TypeScript类型定义

// packages/components/src/Button/Button.types.ts
export type ButtonSize = 'small' | 'medium' | 'large';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';

export interface ButtonProps 
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
  /** 按钮变体 */
  variant?: ButtonVariant;
  /** 按钮尺寸 */
  size?: ButtonSize;
  /** 是否禁用 */
  disabled?: boolean;
  /** 加载状态 */
  loading?: boolean;
  /** 是否撑满容器宽度 */
  fullWidth?: boolean;
  /** 点击事件 */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  /** 自定义类名 */
  className?: string;
  /** 子元素 */
  children?: React.ReactNode;
}

2.3 样式系统设计

/* packages/components/src/Button/Button.css */
.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid transparent;
  border-radius: 6px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  user-select: none;
  outline: none;
}

/* 尺寸系统 */
.btn-small {
  padding: 6px 12px;
  font-size: 12px;
  line-height: 1.5;
}

.btn-medium {
  padding: 8px 16px;
  font-size: 14px;
  line-height: 1.5;
}

.btn-large {
  padding: 12px 24px;
  font-size: 16px;
  line-height: 1.6;
}

/* 变体系统 */
.btn-primary {
  background-color: #1890ff;
  color: white;
}

.btn-primary:hover:not(.btn-disabled) {
  background-color: #40a9ff;
}

.btn-primary:active:not(.btn-disabled) {
  background-color: #096dd9;
}

.btn-secondary {
  background-color: #f5f5f5;
  color: #333;
  border-color: #d9d9d9;
}

/* 状态系统 */
.btn-disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-loading {
  cursor: wait;
}

.btn-full-width {
  width: 100%;
}

/* 加载动画 */
.btn-spinner {
  display: inline-flex;
  margin-right: 8px;
}

.spinner {
  animation: rotate 1.4s linear infinite;
  width: 16px;
  height: 16px;
}

.spinner .path {
  stroke: currentColor;
  stroke-width: 4;
  stroke-linecap: round;
  animation: dash 1.4s ease-in-out infinite;
}

@keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}

@keyframes dash {
  0% {
    stroke-dasharray: 1, 150;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 150;
    stroke-dashoffset: -35;
  }
  100% {
    stroke-dasharray: 90, 150;
    stroke-dashoffset: -124;
  }
}

三、构建与打包优化

3.1 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 peerDepsExternal from 'rollup-plugin-peer-deps-external';
import { terser } from 'rollup-plugin-terser';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';

const packageJson = require('./package.json');

export default {
  input: 'packages/components/src/index.ts',
  output: [
    {
      file: packageJson.main,
      format: 'cjs',
      sourcemap: true,
      exports: 'named',
    },
    {
      file: packageJson.module,
      format: 'esm',
      sourcemap: true,
    },
    {
      file: packageJson.unpkg,
      format: 'umd',
      name: 'MyComponents',
      sourcemap: true,
      globals: {
        react: 'React',
        'react-dom': 'ReactDOM',
      },
    },
  ],
  plugins: [
    peerDepsExternal(),
    resolve({
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
    }),
    commonjs(),
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
      exclude: ['**/*.test.tsx', '**/*.stories.tsx'],
    }),