前端公共组件设计的那些坑我帮你踩过了

博潇 前端 阅读 1,600
赞 9 收藏
二维码
手机扫码查看
反馈

公共组件开发踩坑记

最近在重构项目的公共组件库,本来以为就是搬砖的活儿,结果搞出了不少意外情况。最大的问题是组件复用性太差,每次用都得改一堆配置项,而且样式冲突、状态管理这些问题接踵而来。

前端公共组件设计的那些坑我帮你踩过了

一开始的组件设计太死板

最开始写的那个按钮组件,我给它定了几个固定的type:

// 初始版本,各种type写死
const ButtonTypes = {
  primary: 'bg-blue-500 text-white',
  secondary: 'bg-gray-200 text-gray-800',
  danger: 'bg-red-500 text-white'
}

看起来挺正常的对吧?结果业务方说要自定义颜色,我就懵了。这里我踩了个坑,把组件的灵活性完全锁死了。每次他们提新需求,我都得往代码里加新的type配置,越加越多,最后变成了一个巨大的配置文件。

后来试了下发现,应该把样式开放出来让用户自己传。这样既能保持基础样式的一致性,又能灵活定制:

import React, { forwardRef } from 'react';

const BaseButton = forwardRef(({ 
  children, 
  type = 'button', 
  variant = 'primary', 
  size = 'md', 
  className = '',
  disabled = false,
  onClick,
  ...props 
}, ref) => {
  const baseClasses = 'inline-flex items-center justify-center font-medium rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
  
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 border-transparent',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500 border-transparent',
    outline: 'bg-transparent text-gray-700 border-gray-300 hover:bg-gray-50 focus:ring-gray-500',
    ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500 border-transparent',
    link: 'bg-transparent text-blue-600 underline-offset-4 hover:underline focus:ring-transparent'
  };
  
  const sizes = {
    sm: 'text-xs px-3 py-1.5',
    md: 'text-sm px-4 py-2',
    lg: 'text-base px-6 py-3'
  };

  // 支持用户自定义class覆盖
  const classes = ${baseClasses} ${variants[variant]} ${sizes[size]} ${className};

  return (
    <button
      ref={ref}
      type={type}
      className={classes}
      disabled={disabled}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
});

export default BaseButton;

状态管理那堆破事儿

然后是状态管理的问题,之前写了个Modal组件,所有状态都在组件内部维护。结果业务方说需要外部控制显示隐藏,还要知道当前是否打开的状态。折腾了半天发现,组件内部的状态管理逻辑跟业务耦合太深了。

最后改成这样的API设计:

import React, { useState, useEffect } from 'react';

const Modal = ({ 
  isOpen: controlledIsOpen, 
  onOpenChange, 
  title, 
  children, 
  closeOnOverlayClick = true,
  closeOnEscape = true 
}) => {
  const [internalIsOpen, setInternalIsOpen] = useState(false);
  
  // 控制模式还是非控制模式
  const isControlled = controlledIsOpen !== undefined;
  const currentIsOpen = isControlled ? controlledIsOpen : internalIsOpen;

  const handleClose = () => {
    if (isControlled) {
      onOpenChange?.(false);
    } else {
      setInternalIsOpen(false);
    }
  };

  const handleOverlayClick = (e) => {
    if (closeOnOverlayClick && e.target === e.currentTarget) {
      handleClose();
    }
  };

  // ESC键关闭
  useEffect(() => {
    if (!closeOnEscape || !currentIsOpen) return;

    const handleKeyDown = (e) => {
      if (e.key === 'Escape') {
        handleClose();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [closeOnEscape, currentIsOpen]);

  if (!currentIsOpen) return null;

  return (
    <div 
      className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
      onClick={handleOverlayClick}
    >
      <div className="bg-white rounded-lg shadow-xl max-w-lg w-full">
        {title && (
          <div className="flex items-center justify-between p-4 border-b">
            <h2 className="text-lg font-semibold">{title}</h2>
            <button 
              onClick={handleClose}
              className="text-gray-400 hover:text-gray-600"
            >
              ×
            </button>
          </div>
        )}
        <div className="p-4">
          {children}
        </div>
      </div>
    </div>
  );
};

export default Modal;

CSS隔离是个大问题

还有一个头疼的事儿,就是CSS样式污染。组件库引入后,有些全局样式会影响到组件的默认样式。之前用的是普通的CSS类名,结果项目里的其他样式把组件样式给覆盖了。

试过几种方案,最后选择了CSS Modules结合BEM命名规范:

/* modal.module.css */
.modal-container {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background-color: rgba(0, 0, 0, 0.5);
}

.modal-content {
  background-color: white;
  border-radius: 0.5rem;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 
              0 4px 6px -2px rgba(0, 0, 0, 0.05);
  max-width: 32rem;
  width: 100%;
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem;
  border-bottom: 1px solid #e5e7eb;
}

.modal-title {
  font-size: 1.125rem;
  font-weight: 600;
  margin: 0;
}

不过后来发现,这样虽然解决了样式污染,但定制起来又麻烦了。用户想改组件样式还得去覆盖CSS Modules的类名,那串哈希值看着就头大。

最后还是回归到了Tailwind的utility-first思想,组件内部用Tailwind类名,外部样式通过props传递进来覆盖。这样既保持了样式的隔离,又给了用户足够的定制空间。

Props设计要合理

Props的设计也是个技术活儿。一开始我把所有配置项都放在一个对象里传进去,结果用起来特别不方便。后来拆分成多个单独的props,用户体验好多了。

还有就是默认值的设置,不能随便设。比如按钮的loading状态,默认应该是false,但如果业务方忘了初始化这个状态,组件可能会一直显示加载状态。所以要在组件内部做一些容错处理:

const LoadingButton = ({ 
  loading = false, 
  disabled: externalDisabled = false, 
  children, 
  ...props 
}) => {
  // loading状态下,按钮强制禁用
  const finalDisabled = loading || externalDisabled;
  
  return (
    <BaseButton 
      disabled={finalDisabled}
      {...props}
    >
      {loading && (
        <span className="mr-2 inline-block animate-spin">
          {/* loading spinner icon */}
        </span>
      )}
      {children}
    </BaseButton>
  );
};

测试用例不能少

写完组件还得配上测试,不然下次改代码都不知道会不会影响已有的功能。用了React Testing Library写了一些基本的测试用例:

import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from './Modal';

describe('Modal Component', () => {
  test('renders with title and content', () => {
    render(
      <Modal isOpen title="Test Title">
        <div>Test Content</div>
      </Modal>
    );

    expect(screen.getByText('Test Title')).toBeInTheDocument();
    expect(screen.getByText('Test Content')).toBeInTheDocument();
  });

  test('calls onOpenChange when closed', async () => {
    const mockOnOpenChange = jest.fn();
    const user = userEvent.setup();

    render(
      <Modal 
        isOpen 
        onOpenChange={mockOnOpenChange} 
        title="Test Modal"
      >
        <div>Content</div>
      </Modal>
    );

    const closeButton = screen.getByRole('button', { name: /×/i });
    await user.click(closeButton);
    
    expect(mockOnOpenChange).toHaveBeenCalledWith(false);
  });

  test('closes on overlay click', async () => {
    const mockOnOpenChange = jest.fn();
    const user = userEvent.setup();

    render(
      <Modal 
        isOpen 
        onOpenChange={mockOnOpenChange}
      >
        <div>Content</div>
      </Modal>
    );

    const overlay = screen.getByTestId('modal-overlay');
    await user.click(overlay);
    
    expect(mockOnOpenChange).toHaveBeenCalledWith(false);
  });
});

说实话,这部分写得还不够完善,有些边界情况没覆盖到。后面还得继续补充测试用例。

另外,为了方便用户使用,还加了详细的JSDoc注释和TypeScript类型定义:

interface ModalProps {
  /** 控制模态框是否打开,未提供时为非受控模式 */
  isOpen?: boolean;
  /** 打开状态变化时的回调函数 */
  onOpenChange?: (isOpen: boolean) => void;
  /** 模态框标题 */
  title?: string;
  /** 是否允许点击遮罩层关闭 */
  closeOnOverlayClick?: boolean;
  /** 是否允许按ESC键关闭 */
  closeOnEscape?: boolean;
  /** 子元素 */
  children: React.ReactNode;
  /** 额外的CSS类名 */
  className?: string;
}

打包优化也不能忽视

组件库打包的时候也遇到了一些问题。一开始用Webpack直接打包整个库,结果生成的bundle太大了。后来改成按需导入的方式,只打包实际用到的组件。

Rollup的配置也调整了不少,主要是Tree Shaking相关的配置:

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import postcss from 'rollup-plugin-postcss';

export default {
  input: 'src/index.js',
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'esm',
      sourcemap: true
    },
    {
      file: 'dist/index.cjs.js',
      format: 'cjs',
      sourcemap: true
    }
  ],
  external: ['react', 'react-dom'],
  plugins: [
    resolve(),
    commonjs(),
    babel({
      babelHelpers: 'bundled',
      presets: ['@babel/preset-react']
    }),
    postcss({
      extract: false,
      modules: false
    })
  ]
};

还有就是CSS的处理,因为用了Tailwind,最终选择让用户自己安装Tailwind并配置,组件库只提供基础的Utility Classes,不再包含完整的CSS文件。这样能减少包体积,也避免了样式冲突的问题。

不过这样做也有缺点,就是增加了用户的使用成本。需要文档里详细说明如何配置Tailwind才能正常使用这些组件。还好现在大部分项目都已经在用Tailwind了,这个问题不算太大。

以上是我踩坑后的总结

这次重构公共组件库确实学到不少东西。最核心的感受是,组件设计真的不是简单地把UI逻辑封装起来就完事了,要考虑的点太多了:灵活性、可控性、样式隔离、性能优化、开发体验等等。

虽然现在这个组件库还有一些小问题,比如某些边界情况的处理还不够完美,测试覆盖率还需要提升,但整体架构已经比之前好多了。后面会继续迭代,慢慢完善这些组件。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论