首先要说明的是,这篇文章不是教你如何封装一个组件。如果想要学习此类问题,我推荐阅读下面两篇文章:
我们该如何编写声明文件,才能达到直接面向接口
开发。
从而满足简单、方便、正确
的使用组件并获取近乎完美的的开发体验呢?
一、明确区分必填和非必填
这一条很基础也很重要。好的组件应该是尽可能少的必填属性。甚至不需要传递任何参数
在顺序上,一般必填的的属性在前面,方便一目了然的阅读
export interface CmpProps {
// 必填
id: string;
// 选填
code?: number
}
二、简洁但丰富的注释
对于简单属性,注释不需要过多,清晰明了即可
如果对于一些复杂的属性,除了文字描述外,可以适当的补充使用示例
存在默认值也要注明默认值
export interface CmpProps {
/**
* 组件id
*/
id: string;
/**
* 提示信息
* defaultValue: '请输入'
*/
placeholder?: string;
/**
* 获取编码,根据不同参数类型返回不同结果
* getCode(1) -> '1'
* getCode('1') -> 1
*/
getCode?: (a: number) => string
getCode?: (a: string) => number
}
三、完整且正确的类型描述
对于每一个属性的类型,要描述完整。
3.1 多case函数
例如事件监听函数,我们可以使用函数重载来描述每一个case的输入输出关系
export interface EventProps {
/**
* 监听指定的事件
* onStart: 普通回调
* beforeClose: 回调函数返回reject,将会阻止关闭。
*/
on?: (eventName: 'onStart', callback: () => void) => void
on?: (eventName: 'beforeClose', callback: () => Promise<string>) => void
}
3.2 原生属性、框架内置属性
这部分就需要大家没事多看看定义
export interface EventProps {
/**
* 样式
*/
style?: React.CSSProperties;
/**
* 点击事件
*/
onClick?: React.MouseEventHandler<HTMLDivElement>;
/**
* 显示标签
* React.ReactNode 和 React.ReactElement 的区别你搞清楚了吗
*/
label: React.ReactNode;
}
3.3 最小范围
顾名思义,不要扩大类型的定义。比如值是一个确切的字符串,而不是 string
当然,如果是多个值,最好使用枚举单独定义并导出
export enum SizeType = {
// 小
small,
// 中
middle,
// 大
large,
}
export interface EventProps {
/**
* 名称
*/
name?: 'haoza'
/**
* 尺寸
* defaultValue: SizeType.small
*/
size?: SizeType
}
3.4 使用函数属性而不是函数方法
原因见我之前的 文章
四、正确的类型检查
这部分相对而已比较复杂,要做到正确的类型检查并不容易。我会通过以下几个 case 来展示
4.1 输入输出
这是相对简单的示例,组件的多个属性值由运行时确认
export interface CmpProps<T> {
value: T;
// 回调的参数类型和输入的 value 保持一致
onChange?: (val: T) => void
}
4.2 固定格式
比如某些数据埋点的属性需要以固定的格式传递
export type CmpProps = {
name: string;
code: number;
[k: `on-${string}`]: string
} & Record<`data-${string}`, string>;
4.3 属性互斥
比如当你传入属性 A 的时候,就希望不要传入属性 B,反之亦然。
最简单的方案是手动写联合类型
export interface CmpProps =
| { id: string, name?: never }
| { name: string, id?: never }
当然,作为工程师。我们肯定不会使用如此丑陋的方式。利用体操函数可以简化此过程
/**
* 多个属性相互互斥
*/
export type JustOne<T, K extends (keyof T)[] = [], Y extends keyof T = K[number]> = NonNullable<
{
[x in Y]: Pick<T, Exclude<keyof T, Exclude<Y, x>>> & SetKeyNever<T, Exclude<Y, x>>;
}[Y]
>;
type props = { name: string, id: string }
export type CmpProps = JustOne<props, ['name', 'id']>,
4.4 属性对(组)
顾名思义,这些属性要成对出现使用。
此处有两种方案:
- 将对应属性归纳到一个组,再放在某个属性下
- 属性组平级,使用联合类型
假如 value 和 defaultValue 都是非必填,但是其中一个被使用,另一个也要跟着使用
// 方案1
export type CmpProps<T> = {
input?: {
value: T;
defaultValue: T;
}
onChange(val: T): void
}
// 方案2
export type CmpProps<T> = {
value: T;
defaultValue: T;
onChange(val: T): void
} |
{
value?: never;
defaultValue?: never;
onChange(val: T): void
}
当然方案2也可以用体操函数帮助我们节省时间,请参考上面互斥自行实现哦~
4.5 属性类型联动可变
一个常见的场景就是:下拉框默认是单选。如果传入 multiple 为 true,则变成多选。
type SelectComboValue<T, M> = M extends false | undefined ? T : T[];
export type CmpProps<T, M extends boolean = false> = {
// 是否多选
multiple?: M;
value: SelectComboValue<T, M>;
onChange?(val: SelectComboValue<T, M>): void;
};
function Test<T, M extends boolean = false>(props: CmpProps<T, M>) {}
// 会正确推断 value 应该传入数组。 onChange的参数是一个数组
Test({
value: [],
multiple: true,
onChange(val) {
val.concat([]);
},
});
Test({
value: 1,
multiple: false,
onChange(val) {
val.toFixed()
},
});
当然还有其他类似场景,比如传入一个对象。另一个属性的值被限定为该对象的属性path
比如 value: { user: { name: 'haoza', age: 18 } }
另一个属性 disabledPaths 的可选值为 user.name | user.age 而不是 string[]
4.6 禁止合并 anyObject
在我们定义入参接口的时候,一定是有边界的,键值对可枚举的!
不能使用 object
, { [k: stirng]: any }
, Record<any, any>
等方式定义
比如以下都是错误方式!
export type CmpProps<T> = {
name: string,
id: numnber,
// ❌ 千万不要这样定义
[k: string]: any
}
export type CmpProps<T> = {
name: string,
id: numnber,
// ❌ 千万不要这样定义
} & Record<string, any>
export type CmpProps<T> = {
name: string,
id: numnber,
// ❌ 千万不要这样定义
} & object
当然,更加不要直接使用 any
,否则你前面所有的入参定义都白费了。
呜呼 楚人一炬,可怜焦土!
总结
如果严格按照以上的规则来定义声明文件,你的组件一定是熠熠生辉的。
所以拒绝无脑 any ,从我做起:)