作为前端开发,你是否遇到过这些坑?封装的组件复用性差,换个项目就得重构;面试时被问 组件设计思路 ,只能支支吾吾说不出核心 ;合作开发时,自己写的组件被同事吐槽 难用又难改
其实,优质的组件封装不仅能提升项目开发效率,更是面试中的 加分利器, 大厂面试官格外看重候选人的组件设计思维,这直接反映了代码功底和工程化能力。
结合我多年一线开发经验,以及对 antd、element-plus、vant 等顶尖组件库的深度拆解,总结出 16条实战级组件封装原则。不管你用 React 还是 Vue,掌握这些技巧,既能让你的代码更优雅易维护,更能在面试中脱颖而出,拿下心仪 Offer
一、基础属性:兜底设计,兼容灵活扩展
任何组件都该默认支持 className 和 style 两个基础属性,这是组件灵活性的 "底线"。通过属性继承,让使用者能轻松自定义样式,无需修改组件源码。
typescript
import classNames from 'classnames';
// 公共属性接口,可复用
export interface CommonProps {
/** 自定义类名 */
className?: string;
/** 自定义内联样式 */
style?: React.CSSProperties;
}
// 业务组件属性继承公共属性
export interface MyInputProps extends CommonProps {
/** 输入框值 */
value: any;
}
const MyInput = forwardRef((props: MyInputProps, ref: React.LegacyRef<HTMLDivElement>) => {
const { className, ...rest } = props;
// 合并默认类名和自定义类名
const displayClassName = classNames('chc-input', className);
return (
<div ref={ref} {...rest} className={displayClassName}>
<span></span>
</div>
);
});
export default MyInput;
二、注释规范:清晰易懂,提升协作效率
组件的 props 和 ref 属性必须加注释,而且要避免使用 // 单行注释(TS 无法识别,鼠标悬浮无提示)。推荐用 JSDoc 风格注释,关键参数补充 @description(描述)、@version(版本)、@deprecated(废弃说明)、@default(默认值),国际化组件优先用英文注释。
反面示例:
csharp
interface MyInputsProps {
// 自定义class(TS无法识别,协作坑)
className?: string;
}
正面示例:
php
interface MyInputsProps {
/** Custom class name */
className?: string;
/**
* @description Custom inline style
* @version 2.6.0
* @default ''
*/
style?: React.CSSProperties;
/**
* @description Custom title style
* @deprecated 2.5.0 (No longer supported)
* @default ''
*/
customTitleStyle?: React.CSSProperties;
}
三、导出规范:明确命名,便于调试定位
组件的 props 类型、ref 类型(若用 useImperativeHandle)必须 export 导出,方便使用者复用类型。组件本身要设置明确名称,避免匿名导出 ------ 否则报错时无法定位具体组件,调试效率极低。
反面示例:
typescript
interface MyInputProps { ... }
// 匿名组件,报错无明确提示
export default (props: MyInputProps) => {
return <div></div>;
};
正面示例:
javascript
// 导出类型,方便外部复用
export interface MyInputProps { ... }
// 命名组件,便于调试
function MyInput(props: MyInputProps) {
return <div></div>;
}
// 开发环境设置显示名称
if (process.env.NODE_ENV !== 'production') {
MyInput.displayName = 'MyInput';
}
export default MyInput;
// index.ts 统一导出,方便引入
export * from './input';
export { default as MyInput } from './input';
如果需要获取其他组件的类型,可通过 ComponentProps 和 ComponentRef 快速获取:
ini
type DialogProps = ComponentProps<typeof Dialog>;
type DialogRef = ComponentRef<typeof Dialog>;
四、入参约束:精准定义,减少使用错误
入参类型要明确,避免用 string、number 等宽泛类型 "一笔带过";公共组件尽量不用枚举(使用者需额外引入,增加成本);数值类型需说明取值范围,降低使用门槛。
反面示例:
typescript
interface InputProps {
status: string; // 类型模糊,不知道支持哪些值
count: number; // 无取值范围,容易传错
}
正面示例:
csharp
interface InputProps {
status: 'success' | 'fail'; // 明确支持的状态值
/** 总数(取值范围:0-999) */
count: number;
}
五、样式设计:隔离冲突,支持主题定制
禁用 CSS Module(会导致使用者无法修改内部样式),Vue 可使用 scoped 避免样式污染;组件内部 class 加统一前缀(如 my-input-),防止命名冲突;class 名称要语义化,禁用 !important;预留 CSS 变量,支持主题切换。
反面示例:
javascript
import styles from './index.module.less';
export default function MyInput(props: MyInputProps) {
return (
<div className={styles.input_box}> {/* 无法外部修改样式 */}
<span className={styles.detail}>内容</span>
</div>
);
}
正面示例:
javascript
import './index.less';
const prefixCls = 'my-input'; // 统一前缀
export default function MyInput(props: MyInputProps) {
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-detail`}>内容</span>
</div>
);
}
// 样式文件预留CSS变量
.my-input-box {
height: 100px;
background: var(--my-input-box-background, #000); // 支持外部定制
}
六、继承透传:兼容扩展,减少维护成本
二次封装组件时,不要逐个提取属性绑定到基础组件 ------ 基础组件更新后,需手动同步属性,维护成本高。推荐用 extends 继承基础组件属性,用 ...rest 承接所有传入属性,自动透传。
反面示例:
typescript
import { Input } from '某组件库';
export interface MyInputProps {
value: string;
limit: number;
state: string;
}
const MyInput = (props: Partial<MyInputProps>) => {
const { value, limit, state } = props;
return <Input value={value} limit={limit} state={state} />; // 需手动同步属性
};
正面示例:
typescript
import { Input, InputProps } from '某组件库';
// 继承基础组件所有属性
export interface MyInputProps extends InputProps {
value: string;
}
const MyInput = (props: Partial<MyInputProps>) => {
const { value, ...rest } = props;
return <Input value={value} {...rest} />; // 自动透传所有属性
};
七、事件配套:钩子齐全,提升可控性
组件内部操作导致 UI 变化时,必须提供对应的事件钩子(如 onChange、onShowChange),让使用者能感知状态变化并做自定义处理,提升组件可控性。
反面示例:
arduino
export default function MyInput(props: MyInputProps) {
const [open, setOpen] = useState(false);
const onCheckOpen = () => {
setOpen(!open); // 内部状态变化,外部无感知
};
return <div onClick={onCheckOpen}>{open ? '打开' : '关闭'}</div>;
}
正面示例:
ini
export default function MyInput(props: MyInputProps) {
const { onChange } = props;
const [open, setOpen] = useState(false);
const onCheckOpen = () => {
const newState = !open;
setOpen(newState);
onChange?.(newState); // 暴露事件钩子,外部可响应
};
return <div onClick={onCheckOpen}>{open ? '打开' : '关闭'}</div>;
}
八、Ref 绑定:预留接口,避免报错警告
组件需支持 ref 绑定,否则使用者挂载 ref 会出现控制台警告。原创组件可通过 useImperativeHandle 暴露自定义方法,或直接绑定根节点;二次封装组件直接绑定基础组件。
原创组件示例:
typescript
interface ChcInputRef {
setValidView: (isShow?: boolean) => void;
field: Field;
}
const ChcInput = forwardRef<ChcInputRef, MyProps>((props, ref) => {
const { className, ...rest } = props;
// 暴露自定义方法
useImperativeHandle(ref, () => ({
setValidView(isShow = false) {
setIsCheckBalloonVisible(isShow);
},
field
}), []);
return <div className={displayClassName} {...rest}></div>;
});
二次封装组件示例:
javascript
import { Input } from '某组件库';
const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef<Input>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return <Input ref={ref} className={displayClassName} {...rest} />;
});
九、自定义扩展:预留入口,灵活适配场景
组件内部的固定渲染逻辑或计算逻辑,需预留自定义入口(如 render 函数),让使用者能覆盖默认逻辑,无需修改组件源码,提升组件适配性。
反面示例:
javascript
export default function MyInput(props: MyInputProps) {
const { value } = props;
const detailText = useMemo(() => {
// 固定逻辑,无法自定义
return value.split(',').map(item => `内部逻辑:${item}`).join('\n');
}, [value]);
return <div>{detailText}</div>;
}
正面示例:
javascript
export default function MyInput(props: MyInputProps) {
const { value, render } = props;
const detailText = useMemo(() => {
// 优先使用自定义渲染逻辑,否则用默认逻辑
return render ? render(value) : value.split(',').map(item => `内部逻辑:${item}`).join('\n');
}, [value]);
return <div>{detailText}</div>;
}
十、受控非受控:双模式支持,降低使用门槛
组件需同时支持受控模式(外部控制状态)和非受控模式(内部管理状态):非受控模式方便快速使用,受控模式支持复杂场景自定义,兼顾易用性和灵活性。
示例:
ini
const prefixCls = 'my-input';
export default function MyInput(props: MyInputProps) {
const { value, defaultValue = true, className, style, onChange } = props;
// 非受控模式:内部管理状态
const [open, setOpen] = useState(value || defaultValue);
// 受控模式:外部控制状态
useEffect(() => {
if (typeof value === 'boolean') setOpen(value);
}, [value]);
const onCheckOpen = () => {
const newState = !open;
onChange?.(newState);
// 非受控模式下,内部更新状态
if (typeof value !== 'boolean') setOpen(newState);
};
return (
<div className={classNames(className, `${prefixCls}-box`, { [`${prefixCls}-open`]: open })}
style={style} onClick={onCheckOpen}>
内容
</div>
);
}
十一、最小依赖:减少耦合,提升兼容性
组件封装遵循 "最小依赖" 原则,简单功能优先手写实现,避免引入不必要的依赖包(如 ahooks),减少组件体积和耦合度。若必须引入,优先使用项目已有的依赖,或自行实现核心逻辑。
反面示例:
javascript
import { useLatest } from 'ahooks'; // 新增依赖,增加项目体积
import classNames from 'classnames';
const ChcInput = forwardRef((props: InputProps, ref) => {
const funcRef = useLatest(func);
return <div className={classNames('chc-input', props.className)} {...props}></div>;
});
正面示例:
javascript
// 自行实现核心逻辑,无需新增依赖
import { useRef } from 'react';
export function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
// 组件中使用
import { useLatest } from '@/hooks';
import classNames from 'classnames';
const ChcInput = forwardRef((props: InputProps, ref) => {
const funcRef = useLatest(func);
return <div className={classNames('chc-input', props.className)} {...props}></div>;
});
十二、单一职责:拆分功能,提升复用性
组件不要 "大包大揽",一个组件只处理一个核心功能(业务组件除外,可整合多个组件完成单一业务)。复杂功能拆分成独立公共组件,提升复用性和维护性。
反面示例:
javascript
// 一个组件包含表格和图例功能,职责混乱
const MyShowPage = forwardRef((props, ref) => {
const { data, imgList, ...rest } = props;
return (
<div>
<Table ref={ref} data={data} {...rest}>
{/* 表格逻辑... */}
</Table>
<div>{/* 图例逻辑... */}</div>
</div>
);
});
正面示例:
javascript
// 拆分独立组件,各司其职
const MyShowPage = forwardRef((props, ref) => {
const { data, imgList, ...rest } = props;
return (
<div>
<MyTable ref={ref} data={data} {...rest} /> {/* 仅处理表格功能 */}
<MyImg data={imgList} /> {/* 仅处理图片展示 */}
</div>
);
});
十三、业务组件:内置逻辑,降低使用成本
业务组件要 "去业务化暴露"------ 将复杂业务逻辑内置到组件内部,避免使用者重复编写业务代码,降低使用门槛。使用者只需传入原始数据,组件自行处理业务规则。
反面示例:
ini
// 组件未处理业务逻辑,使用者需手动处理
const MyTable = forwardRef((props, ref) => {
const { data, ...rest } = props;
return (
<Table ref={ref} data={data} {...rest}>
<Table.Column dataIndex="test1" title="标题1" />
<Table.Column dataIndex="data" title="值" />
</Table>
);
});
// 使用者需手动处理业务逻辑(type=1时data乘2)
const data = res.map(item => ({
...item,
data: item.type === 1 ? item.data * 2 : item.data
}));
<MyTable data={data} />
正面示例:
javascript
// 组件内置业务逻辑,使用者直接传原始数据
const MyTable = forwardRef((props, ref) => {
const { data, ...rest } = props;
const dataRender = (item) => {
return item.type === 1 ? item.data * 2 : item.data; // 内置业务规则
};
return (
<Table ref={ref} data={data} {...rest}>
<Table.Column dataIndex="test1" title="标题1" />
<Table.Column dataIndex="data" title="值" render={dataRender} />
</Table>
);
});
// 使用者无需关心业务逻辑
<MyTable data={res} />
十四、深度扩展:递归处理,支持无限层级
若组件需处理树形、嵌套等有深度的数据,需通过递归实现无限层级支持,避免固定层级导致的局限性。
反面示例:
typescript
// 仅支持两层嵌套,局限性大
interface Columns extends TableColumnProps {
columns: TableColumnProps[];
}
const MyTable = forwardRef((props, ref) => {
const { columns = [] } = props;
const renderColumn = columns.map(item => {
return item.columns ? (
<Table.Column {...item}>{item.columns.map(col => <Table.Column {...col} />)}</Table.Column>
) : <Table.Column {...item} />;
});
return <Table ref={ref} {...props}>{renderColumn}</Table>;
});
正面示例:
typescript
// 递归渲染,支持无限层级
interface Columns extends TableColumnProps {
columns: Columns[]; // 继承自身,支持嵌套
}
const MyColumn = (props: { columns: Columns[] }) => {
const { columns = [] } = props;
return columns.map(item => (
<Table.Column key={item.key} {...item}>
{item.columns && <MyColumn columns={item.columns} />} {/* 递归渲染 */}
</Table.Column>
));
};
const MyTable = forwardRef((props, ref) => {
const { columns = [], ...rest } = props;
return <Table ref={ref} {...rest}><MyColumn columns={columns} /></Table>;
});
十五、多语言适配:可配置化,兼容国际化
组件内部所有文案需支持自定义,默认推荐英文,兼容多语言场景。文案较多时,可暴露 strings 对象统一配置。
反面示例:
javascript
export default function MyInput(props: MyInputProps) {
const { title = '标题' } = props;
return (
<div>
<span>{title}</span>
<span>详情</span> {/* 固定文案,无法国际化 */}
</div>
);
}
正面示例:
typescript
export default function MyInput(props: MyInputProps) {
const { title = 'Title', detail = 'Detail' } = props; // 支持自定义文案
return (
<div>
<span>{title}</span>
<span>{detail}</span>
</div>
);
}
// 文案较多时,用strings统一配置
interface MyInputProps {
strings?: {
title: string;
detail: string;
};
}
export default function MyInput(props: MyInputProps) {
const { strings = { title: 'Title', detail: 'Detail' } } = props;
return (
<div>
<span>{strings.title}</span>
<span>{strings.detail}</span>
</div>
);
}
十六、语义化命名:清晰易懂,降低理解成本
组件名、API、方法名、变量名都要遵循语义化原则,见名知意。例如:MyInput(输入框组件)、onChange(状态变化事件)、prefixCls(类名前缀),避免模糊命名(如 data1、func2)。
为什么这些原则能帮你拿下 Offer?
大厂面试中,"组件设计" 是高频考点,面试官通过你的回答,判断你是否具备工程化思维、代码复用能力和协作意识。能把这 16 条原则融会贯通,不仅能写出高质量代码,面试时还能有条理地阐述设计思路,轻松和其他候选人拉开差距
前端简历面试辅导+求职陪跑
如果你想:
- 让简历突出组件设计、工程化等核心亮点,精准匹配大厂 JD;
- 面试时从容应对组件封装、源码解析等难点问题;
- 避开求职坑,高效拿到心仪 Offer;
我提供「前端简历面试辅导」和「求职陪跑计划」,结合我的一线开发经验和大厂面试逻辑,帮你针对性优化简历、模拟面试、拆解考点,全程陪伴你从求职准备到拿到 Offer,让你少走弯路,早日上岸