先把坑踩一遍,再谈怎么填
做前端这几年,我见过太多团队被”内部组件库”和”业务组件库”折磨得痛不欲生。为什么?因为API设计从来不是小事——它决定了开发者愿意用还是骂着用,决定了代码是优雅还是混乱。
上周我帮一家电商公司重构组件库,把原来散落在各个页面的按钮、表单、弹窗统一收拢。结果呢?原来团队平均每个页面要写200行重复代码,重构后降到30行。效率翻了6倍不止。这背后不是什么黑科技,而是把API设计规范做到了极致。
今天我就把这套方法论掰开揉碎讲给你听,不管你是用React还是Vue,这套规范都能无缝落地。
一、先说核心:什么是好的API设计
在深入代码之前,我们先统一一个认知:好的组件API应该让开发者”不用看文档也能用对80%”。
这不是开玩笑。我见过太多组件,Props名起得莫名其妙,事件命名混乱,类型定义缺失,调用者只能猜。猜对了是运气,猜错了是bug。
真正优秀的API设计遵循几个基本原则:
- 一致性:同类型的属性用相同的命名风格
- 显式优于隐式:能写清楚的不要靠约定
- 渐进式复杂度:简单场景极简,复杂场景可扩展
- 类型安全:用类型系统拦住80%的运行时错误
接下来,我们按照一个真实组件的开发流程,一步步拆解这些原则怎么落地。
二、设计阶段:先画表格,再写代码
很多团队一上来就写代码,这是大忌。我习惯先花10分钟画一个”API契约表”,和后端、产品、其他前端同事对齐。这个表格会成为后续所有开发的基准。
以一个通用的”智能搜索框”组件为例,我们来设计它的API:
| 属性/事件 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| value | string | 否 | “ | 当前搜索值 |
| onChange | (value: string) => void | 是 | - | 值变化时触发 |
| placeholder | string | 否 | ‘请输入关键词’ | 占位提示文字 |
| loading | boolean | 否 | false | 是否显示加载状态 |
| disabled | boolean | 否 | false | 是否禁用 |
| clearable | boolean | 否 | true | 是否显示清除按钮 |
| onClear | () => void | 否 | - | 点击清除时触发 |
| onSearch | (value: string) => void | 否 | - | 按下回车或点击搜索时触发 |
| filterOption | (input: string, option: string) => boolean | 否 | - | 自定义过滤选项的逻辑 |
| options | OptionItem[] | 否 | - | 下拉选项列表 |
| size | ‘small’ | ‘default’ | ‘large’ | 否 | ‘default’ | 尺寸 |
| prefix | VNode | string | 否 | - | 前缀图标或内容 |
| suffix | VNode | string | 否 | - | 后缀图标或内容 |
你看,这个表格写完之后,开发同事拿到就能开始写代码了,测试同事也能开始写用例,甚至连产品都能看懂这个组件能做什么。这就是设计先行带来的好处。
三、类型定义:用TypeScript拦住错误
现在进入最关键的环节——类型定义。不管React还是Vue,TypeScript都是标配。好的类型定义能让IDE给出智能提示,让同事一眼看懂API。
React版本的类型定义
// types/SearchInput.ts
export interface OptionItem {
label: string;
value: string;
disabled?: boolean;
/** 可选的分类标题,用于分组展示 */
group?: string;
}
export type SearchInputSize = 'small' | 'default' | 'large';
export interface SearchInputProps {
/** 当前输入值,受控模式下必填 */
value?: string;
/** 值变化回调,受控模式下必填 */
onChange?: (value: string) => void;
/** 占位提示文字 */
placeholder?: string;
/** 是否显示加载转圈状态 */
loading?: boolean;
/** 是否禁用整个组件 */
disabled?: boolean;
/** 是否显示右侧清除按钮,默认显示 */
clearable?: boolean;
/** 点击清除按钮时触发 */
onClear?: () => void;
/** 搜索触发回调(回车或点击搜索按钮) */
onSearch?: (value: string) => void;
/** 自定义选项过滤逻辑,返回false的选项会被隐藏 */
filterOption?: (input: string, option: OptionItem) => boolean;
/** 下拉选项数据源 */
options?: OptionItem[];
/** 组件尺寸 */
size?: SearchInputSize;
/** 前缀内容(图标或自定义节点) */
prefix?: React.ReactNode;
/** 后缀内容(图标或自定义节点) */
suffix?: React.ReactNode;
/** 自定义类名 */
className?: string;
/** 自定义样式 */
style?: React.CSSProperties;
/** 数据-testid,方便测试定位 */
'data-testid'?: string;
}
/** 公共暴露的方法,供ref调用 */
export interface SearchInputRef {
/** 获取当前输入值 */
getValue: () => string;
/** 聚焦 */
focus: () => void;
/** 失焦 */
blur: () => void;
/** 清空输入 */
clear: () => void;
/** 设置焦点位置 */
setCaretPosition: (position: number) => void;
}
注意几个设计细节:
- 受控与非受控分离:
value和onChange用问号标记为可选,但实际上受控模式下两者必须成对出现。我们后面会在运行时做校验。 - 回调用on前缀:
onSearch、onClear、onChange,这是React生态的通用约定,开发者一看就懂。 - ref暴露方法而非状态:外部不直接修改内部状态,只通过
getValue、focus等方法操作,符合单向数据流原则。 - 测试友好:专门加了
data-testid,方便E2E测试。
Vue 3版本的类型定义
Vue 3配合defineProps和defineEmits,类型定义更加简洁:
// types/SearchInput.ts
import type { ComponentPublicInstance, VNode } from 'vue'
export interface OptionItem {
label: string
value: string
disabled?: boolean
group?: string
}
export type SearchInputSize = 'small' | 'default' | 'large'
export interface SearchInputProps {
/** 当前输入值,受控模式下必填 */
value?: string
/** 占位提示文字 */
placeholder?: string
/** 是否显示加载转圈状态 */
loading?: boolean
/** 是否禁用整个组件 */
disabled?: boolean
/** 是否显示右侧清除按钮,默认显示 */
clearable?: boolean
/** 组件尺寸 */
size?: SearchInputSize
/** 前缀内容 */
prefix?: VNode | string
/** 后缀内容 */
suffix?: VNode | string
/** 自定义类名 */
className?: string
/** 自定义样式 */
style?: CSSProperties
/** 数据-testid */
'data-testid'?: string
}
/**
* emits 定义,配合 defineEmits 使用
* Vue会自动从type inference推导出事件类型
*/
export interface SearchInputEmits {
(e: 'update:value', value: string): void
(e: 'clear'): void
(e: 'search', value: string): void
}
/**
* 暴露给父组件ref的方法
*/
export interface SearchInputExposed {
getValue: () => string
focus: () => void
blur: () => void
clear: () => void
setCaretPosition: (position: number) => void
}
export type SearchInputInstance = ComponentPublicInstance<
SearchInputProps,
SearchInputExposed
>
Vue版本和React版本的核心差异:
- 事件用emits定义:Vue的
defineEmits让事件类型自动推导,父组件调用@search时IDE会提示参数类型。 - 受控模式用
update:value:这是Vue 3的惯例,配合v-model自动双向绑定。 - 暴露用
defineExpose:与React的forwardRef+useImperativeHandle作用相同,控制外部能访问哪些方法。
四、React实现:组件主体与错误处理
类型定义好了,接下来写组件实现。这里我重点讲几个关键设计点。
1. 基础骨架与PropTypes补充
虽然TypeScript已经做了静态检查,但在运行时加一层PropTypes校验可以给JavaScript用户更好的错误提示:
// components/SearchInput/SearchInput.tsx
import React, {
useState,
useRef,
useEffect,
useMemo,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import classNames from 'classnames';
import { Input } from 'antd'; // 假设使用Ant Design的Input作为基础
import type { SearchInputProps, SearchInputRef } from './types';
import { SEARCH_INPUT_PREFIX } from './constants';
// 运行时类型校验(给不用TS的项目用)
import PropTypes from 'prop-types';
const SearchInput = forwardRef<SearchInputRef, SearchInputProps>(
(props, ref) => {
const {
value: controlledValue,
onChange,
placeholder = '请输入关键词',
loading = false,
disabled = false,
clearable = true,
onClear,
onSearch,
filterOption,
options = [],
size = 'default',
prefix,
suffix,
className,
style,
'data-testid': dataTestId,
} = props;
// 内部状态:非受控模式下的值
const [internalValue, setInternalValue] = useState('');
const inputValue = controlledValue !== undefined ? controlledValue : internalValue;
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
// ========== 受控模式校验 ==========
// 这是很多组件库容易忽略的点:value和onChange必须成对出现
const isControlled = controlledValue !== undefined;
useEffect(() => {
if (isControlled && !onChange) {
console.warn(
`[${SEARCH_INPUT_PREFIX}] SearchInput is a controlled component ` +
`but onChange is not provided. Please provide both value and onChange, ` +
`or omit value to use uncontrolled mode.`
);
}
if (!isControlled && onChange) {
console.warn(
`[${SEARCH_INPUT_PREFIX}] SearchInput: onChange is provided without value. ` +
`This will not work as expected. Please provide value for controlled mode.`
);
}
}, [isControlled, onChange]);
// ========== 暴露给外部的方法 ==========
useImperativeHandle(ref, () => ({
getValue: () => inputValue,
focus: () => {
inputRef.current?.focus();
setFocused(true);
setShowDropdown(true);
},
blur: () => {
inputRef.current?.blur();
setFocused(false);
},
clear: () => {
handleChange('');
inputRef.current?.focus();
},
setCaretPosition: (position: number) => {
const el = inputRef.current;
if (el) {
el.setSelectionRange(position, position);
el.focus();
}
},
}));
// ========== 事件处理 ==========
const handleChange = useCallback((nextValue: string) => {
if (!isControlled) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
}, [isControlled, onChange]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSearch?.(inputValue);
}
}, [inputValue, onSearch]);
const handleFocus = useCallback(() => {
setFocused(true);
setShowDropdown(true);
}, []);
const handleBlur = useCallback(() => {
setFocused(false);
// 延迟隐藏下拉,避免点击选项时立即消失
setTimeout(() => setShowDropdown(false), 200);
}, []);
const handleClear = useCallback(() => {
handleChange('');
onClear?.();
}, [handleChange, onClear]);
// ========== 选项过滤 ==========
const filteredOptions = useMemo(() => {
if (!filterOption) {
return options;
}
return options.filter((option) => filterOption(inputValue, option));
}, [options, filterOption, inputValue]);
// ========== 渲染 ==========
const classes = classNames(
`${SEARCH_INPUT_PREFIX}`,
`${SEARCH_INPUT_PREFIX}--${size}`,
{
[`${SEARCH_INPUT_PREFIX}--focused`]: focused,
[`${SEARCH_INPUT_PREFIX}--loading`]: loading,
[`${SEARCH_INPUT_PREFIX}--disabled`]: disabled,
[`${SEARCH_INPUT_PREFIX}--clearable`]: clearable && inputValue.length > 0,
},
className
);
return (
<div className={classes} style={style} data-testid={dataTestId}>
<Input
ref={inputRef}
value={inputValue}
placeholder={placeholder}
disabled={disabled}
loading={loading}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
prefix={prefix}
suffix={
clearable && inputValue.length > 0 ? (
<span onClick={handleClear} className={`${SEARCH_INPUT_PREFIX}__clear`}>
×
</span>
) : suffix
}
/>
{/* 下拉选项 */}
{showDropdown && filteredOptions.length > 0 && (
<div className={`${SEARCH_INPUT_PREFIX}__dropdown`}>
{filteredOptions.map((option) => (
<div
key={option.value}
className={classNames(
`${SEARCH_INPUT_PREFIX}__option`,
{ [`${SEARCH_INPUT_PREFIX}__option--disabled`]: option.disabled }
)}
onClick={() => {
handleChange(option.value);
onSearch?.(option.value);
setShowDropdown(false);
}}
>
{option.group && (
<span className={`${SEARCH_INPUT_PREFIX}__group`}>{option.group}</span>
)}
<span className={`${SEARCH_INPUT_PREFIX}__label`}>{option.label}</span>
<span className={`${SEARCH_INPUT_PREFIX}__value`}>{option.value}</span>
</div>
))}
</div>
)}
</div>
);
}
);
// 注意:propTypes要在forwardRef之后定义,否则name会丢失
SearchInput.displayName = 'SearchInput';
SearchInput.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
loading: PropTypes.bool,
disabled: PropTypes.bool,
clearable: PropTypes.bool,
onClear: PropTypes.func,
onSearch: PropTypes.func,
filterOption: PropTypes.func,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
group: PropTypes.string,
})
),
size: PropTypes.oneOf(['small', 'default', 'large']),
prefix: PropTypes.node,
suffix: PropTypes.node,
className: PropTypes.string,
style: PropTypes.object,
};
export default SearchInput;
关键设计点解析
1. 受控/非受控模式判断
const isControlled = controlledValue !== undefined;
这是搜索框最容易出bug的地方。很多开发者会忘记:如果传了value就必须传onChange,否则React会报”组件从受控变为非受控”的警告。我们在useEffect里加了校验提示,但更好的做法是直接抛出错误:
// 更严格的版本:直接报错
if (isControlled && !onChange) {
throw new Error(
`[SearchInput] value is provided but onChange is missing. ` +
`This will cause the component to be read-only.`
);
}
2. 下拉消失的延迟处理
setTimeout(() => setShowDropdown(false), 200);
这是UX细节。如果直接blur就隐藏,用户点击选项时会因为blur事件先触发而看不到选项。200ms的延迟给了用户足够的时间完成点击。
3. useImperativeHandle的安全防护
useImperativeHandle(ref, () => ({
getValue: () => inputValue,
// ...
}));
这里只暴露了必要的方法,没有暴露内部状态。外部无法直接修改internalValue或showDropdown,保证了组件的状态不被意外破坏。
五、Vue 3实现:Composition API的优雅实践
Vue 3的<script setup>让组件代码更加简洁,但类型安全和错误
