背景
最近晚上在开发marsview
功能,12点
准备打包部署的时候,突然发现构建失败了,这件事很离奇,因为报错的文件是所有组件库对应的forwardRef
语法,可是最近几天开发的内容跟组件库毫无关联。
在这上线前的时刻,居然出这种问题,一时让我很着急,幸亏这只是个人开源项目,继续查找了半个小时,最终才锁定问题,觉得有点坑,今天借此机会记录一下。
报错信息
Marsview
是一个纯前端的低代码平台,为了增强组件库的能力,我们给每个物料组件都通过forwardRef
和useImperativeHandle
暴露了一系列方法。
最近在做行业模板的需求,希望开发者可以直接一键安装一个行业模板,告别从0搭建,然而提交以后,准备部署时,出现了上面的错误,而且几乎所有的组件库都报错。
错误跟踪
鼠标悬浮查看问题
鼠标查看问题,显示缺少属性:id
、type
、name
、config
。
查看组件类型定义
ts
export type ComponentType<T = any> = {
id: string;
type: string;
name: string | number;
remoteUrl?: string;
remoteConfigUrl?: string;
remoteCssUrl?: string;
parentId?: string;
config: ConfigType<T>;
// 属性中用于展示的事件,跟配置中的事件不同
events?: Array<{ name: string; value: string }>;
// 属性中用于展示的方法,跟配置中的方法不同
methods: ComponentMethodType[];
apis: { [key: string]: ApiType };
elements: ComponentType<T>[];
[key: string]: any;
};
类型里面已经包含了id
、type
、name
、config
,于是,我下载了线上开源版本,发现线上版本正常,只有我当前这个最新开发版本报错。
自我思考
难道是版本问题? 是哪个插件引起的?
于是我分析了两个版本的插件版本,发现我当前开发版本用的是react@18.3.1
,而线上GitHub
由于提交了pnpm-lock.yaml
锁定了版本,所以线上安装的是react@18.2.0
,我本地开发时,意外删除了lock
文件。
那为什么react@18.3.1
版本会报错?
对比了两个版本的报错信息后,发现他们用的泛型不一样,在最新版本中,forwardRef
的类型定义,用Omit
排除了ref
属性,而在老版本却没有。
版本差异分析
18.2类型定义
ts
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, P>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
18.3类型定义
ts
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, PropsWithoutRef<P>>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
type PropsWithoutRef<P> = P extends any ? ("ref" extends keyof P ? Omit<P, "ref"> : P) : P;
原来在18.2
版本中,forwardRef
的类型定义比较简单,render
函数传两个泛型就可以,而新版本对于组件属性用了PropsWithoutRef<P>
,也就是说排除了ref
属性。
在看一下组件定义
tsx
const Input = (props:ComponentType,ref:any)=>{
return <input />
}
export default forwardRef(Input)
在泛型里面,T
对应ref
对象,P
对应组件属性props
,就算这样,那又怎样?继续分析...
原因分析
类型ComponentType
中,有一个动态属性定义:[key:string]:any
,这个大家可能比较疑惑,你为什么要定义这个? 这个是有背景的,你听我说:
在低代码平台中,事件交互是最重要的能力之一,我需要给每个组件添加事件回调,从而在触发事件的时候去执行事件流中的行为。为了方便调用,我在组件渲染方法里面,遍历事件流,把事件回调统一挂在了组件上面,要不然我就要在组件内部循环查找来实现回调。
你跟我说着干啥?这跟类型定义有啥关系??
别激动,因为挂载的事件名称是动态的,所以ComponentType
上面才有了[key:string]:any
的类型定义。由于key
是任何字符串,也就意味着它可以是ref
属性,对不对?是不是有点破绽了?
再来看这段类型定义:
ts
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, PropsWithoutRef<P>>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
type PropsWithoutRef<P> = P extends any ? ("ref" extends keyof P ? Omit<P, "ref"> : P) : P;
"ref" extends keyof P
这段代码成立,所以返回:Omit<P, "ref">
,相当于Omit<ComponentType>,"ref">
,那这段类型有啥问题?其实我第一眼也没看出来有啥问题。
用typescript编译器看一下
在线编译器解析完以后,会发现Omit<P, "ref">
返回的是:
ts
{
[x: string]: any;
[x: number]: any;
}
最终这个类型跟我们组件定义的属性类型明细不一致,所以引发了这个问题。
问题解决
前面给大家分析了报错和分析过程,接下来,我们来解决这个问题。很明显,不能使用[key:string]:any
来无脑扩展属性,因为它可能是ref: xxxx
,从而变成Omit<P, "ref">
,最终变成四不像。
整理思路:我们要什么?
我们需要动态扩展事件,比如:onChange
、onClick
、onSubmit
、onFinish
、onReset
等等,总之,我们要组件的类型定义支持动态事件。
怎么定义?
ts
type OnProps<P extends string> = {
[key in `on${P}`]: (data?: any) => void;
};
这样的话,就支持了以on
开头的事件属性定义,并且值为一个函数,接受可选参数。
ComponentType定义
ts
export type ComponentType<T = any> = {
id: string;
type: string;
name: string | number;
remoteUrl?: string;
remoteConfigUrl?: string;
remoteCssUrl?: string;
parentId?: string;
config: ConfigType<T>;
// 属性中用于展示的事件,跟配置中的事件不同
events?: Array<{ name: string; value: string }>;
// 属性中用于展示的方法,跟配置中的方法不同
methods: ComponentMethodType[];
apis: { [key: string]: ApiType };
elements: ComponentType<T>[];
} & OnProps<string>;
type OnProps<TKeys extends string> = {
[P in `on${TKeys}`]: (data?: any) => void;
};
查看typescript编译结果
这一次结果就正常了。最终我们改完ComponentType
类型以后,所有的组件库不再报错。
总结
- 项目最好还是锁定版本,防止版本差异带来的痛苦。
- 出现问题,不要慌张,仔细查看报错信息,逐步思考可能的报错原因。
- 多锻炼解决问题的能力,这在工作中极其重要。
Marsview
最近更新了不少功能,欢迎体验:www.marsview.com.cn