前端公共组件设计的那些坑我帮你踩过了
公共组件开发踩坑记
最近在重构项目的公共组件库,本来以为就是搬砖的活儿,结果搞出了不少意外情况。最大的问题是组件复用性太差,每次用都得改一堆配置项,而且样式冲突、状态管理这些问题接踵而来。
一开始的组件设计太死板
最开始写的那个按钮组件,我给它定了几个固定的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逻辑封装起来就完事了,要考虑的点太多了:灵活性、可控性、样式隔离、性能优化、开发体验等等。
虽然现在这个组件库还有一些小问题,比如某些边界情况的处理还不够完美,测试覆盖率还需要提升,但整体架构已经比之前好多了。后面会继续迭代,慢慢完善这些组件。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论