背景
因为用户体验和功能缺失等原因,产品要对房源发布功能做重构,在做新需求的时候考虑到有些状态和业务逻辑不需要暴露给父组件,所以就使用函数式组件对antd的数据录入组件(Select,Upload等)做了一下封装,注意这里用的是函数组件做的二次封装,入坑的原因也恰恰因为是函数组件,当然这也是后来才知道的。闲话少说,直接上代码:
import React, {useState, forwardRef} from 'react'
import {Form, Input, Button} from 'antd'
const CustomInput = forwardRef(({onChange, value}, ref) => {
const handleChange = (e) => {
onChange(e.target.value)
}
return (
)
})
const Sub1 = ({form}) => {
return ({form.getFieldDecorator('jzsHouseName')( )} )
}
const Sub2 = ({form}) => {
return ({form.getFieldDecorator('fssHouseName')( )} )
}
const Super = ({form}) => {
const [type, setType] = useState(0)
const onSubmit = (e) => {
e.preventDefault();
form.validateFieldsAndScroll((err, values) => {
console.log(values);
})
}
return (
)
}
export default Form.create()(Super);
问题场景及描述
1、复现场景:antd3 form.getFieldDecorator传入自定义函数组件
2、问题描述:从演示示例的console可以看出,当子组件销毁时绑定到form上的字段并没有清除,如果是必填字段没有被清除,会导致form.validateFieldsAndScroll一直校验不通过,如果是相同字段,会把前一个组件的值带到后一个组件中。当时由于项目工期比较紧张,临时解决方案是在子组件内部创建form实例,这样子组件销毁时,form实例也会销毁,然后父组件onSubmit的时候,通过ref获取子组件form实例。详见:https://3x.ant.design/components/form-cn/#如何在函数组件中拿到-form-实例。多form实例的解决方案,虽然解决了眼下的问题,但是如果子组件内部也有根据不同条件渲染不同组件的情况,还是会出现上面的问题,所以要想从根本上解决问题,只能从源码中找答案了。
填坑
首先clone一份antd源码到本地(调试问题的分支是3.x-stable),在antd3的form源码中,并没有找到getFieldDecorator方法的具体实现,因为antd form的底层依赖了rc-form,所以还需要看一下rc-form的源码。好在安装完依赖后,在node_modules看到的rc-form只是被简单的编译,不会对调试带来影响。后面为了阅读方便,还是直接贴rc-form源码吧。
function saveRef() {
}
function getFieldProps(name, usersFieldOption = {}) {
// 核心代码
const inputProps = {
// ...
ref: saveRef.bind(this, name),
};
return inputProps;
}
function getFieldDecorator(name, fieldOption) {
const props = getFieldProps(name, fieldOption);
return fieldElem => {
// 核心代码
const decoratedFieldElem = React.cloneElement(fieldElem, {
...props,
...this.fieldsStore.getFieldValuePropValue(fieldMeta),
});
return supportRef(fieldElem) ? (
decoratedFieldElem
) : (
{decoratedFieldElem}
);
};
}
通过getFieldDecorator方法,我们只能知道在getFieldDecorator闭包内部通过React.cloneElement对传入的组件动态注入了一些props属性,但是还看不出组件销毁后,rc-form是怎么清除多余字段的。因为上文提到getFieldDecorator假如传入的是antd组件,组件销毁了,绑定到form上的字段也会被删除,所以在rc-form某个地方肯定对form上的fields做了删除操作。直接全局搜一下delete关键字,果不其然在clearField方法里面搜到了。既然找到了删除字段的方法,那么在方法里面打个断点调试一下不就知道问题所在了。心动不如行动,首先在antd form => demo文件夹下新建一个markdown文件,把上面有问题的代码贴过去;然后切换切换房源类型,发现没有进断点,因为antd组件是没问题的,我们把CustomInput换成antd
Input,这下断点进来了,并且从断点的调用栈可以看出在saveRef内部调用了clearField;
function saveRef(name, component) {
// 组件销毁
if (!component) {
const fieldMeta = this.fieldsStore.getFieldMeta(name);
//antd文档:getFieldDecorator(id, options) 参数 option.preserve 即便字段不再使用,
//也保留该字段的值
if (!fieldMeta.preserve) { // 并且preserve不为true
this.clearField(name);
}
}
}
//清除字段
function clearField(name) {
// 主要的删除逻辑在createFieldsStore.js里的clearField方法中
// this.fieldsStore.clearField(name);
delete this.instances[name];
delete this.cachedBind[name];
}
我们再回过头来看一下前面的代码,在getFieldProps方法中把saveRef赋值给了inputProps.ref,在getFieldDecorator中通过React.cloneElement把inputProps注入到了我们传入的组件里。然后在组件销毁的时候,react内部会判断ref是否是函数,如果是函数就会执行ref方法。聪明如你,此时肯定知道为什么用antd组件(类组件)没问题而封装后的函数组件会有问题了,没错,问题就出在ref上面。因为我们自定义的函数组件虽然接收了ref参数,但是并没有挂载到dom上,所以在组件销毁的时候不会执行ref方法。现在我们把CustomInput组件的ref挂载到Input上再来看一下效果:
ok,现在是我们期望的效果了。这里解释一下为什么没有挂载ref,因为ref对于我们封装的组件来说并没有实际作用,而且当时不太清楚form.getFieldDecorator往我们传入的组件内注入ref的真实用意,antd form文档也没有说需要把ref挂载到函数组件的dom上( 强行甩锅 ),本来想偷个懒,结果懒没偷成还给自己挖了一个坑。
forwardRef拓展
关于函数组件使用ref,react官方给的解释是因为函数组件没有实例,所以不能直接使用ref。我们知道函数组件转发ref需要用到forwardRef,但是forwardRef做了什么以及react内部是怎么把ref从props里面分离出去的,带着好奇心,就去翻了一下react源码,forwardRef相关的核心代码整理如下,感兴趣的同学也可以去读一下react源码。
//forwardRef函数接收函数组件,返回elementType对象
function forwardRef(render) {
const elementType = {
$$typeof: REACT_FORWARD_REF_TYPE,
render,
};
return elementType;
}
var RESERVED_PROPS = {
key: true,
ref: true,
__self: true,
__source: true
};
// Create and return a new ReactElement of the given type
function createElement(type, props, children) { // confi
var newProps = {};
var key = null;
var ref = null;
var self = null;
var source = null;
if (props != null) {
if (hasValidRef(config)) {
ref = props.ref;
}
if (hasValidKey(config)) {
key = '' + props.key;
}
// Remaining properties are added to a new props object
for (propName in props) {
// 给newProps赋值并把ref过滤掉(类组件也会过滤掉ref)
if (hasOwnProperty$1.call(props, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
newProps[propName] = props[propName];
}
}
}
return ReactElement(type, key, ref, newProps);
}
function updateForwardRef(Component, nextProps) {
var render = Component.render; // Component => forwardRef函数返回的elementType
var ref = workInProgress.ref; //workInProgress FiberNode
renderWithHooks(render, nextProps, ref)
}
function renderWithHooks(render, props, refOrContext) {
var children = render(props, refOrContext); // 执行render回调 传入props 和 ref
return children;
}
总结
之前百思不得其解的问题,在看源码的过程中,慢慢的柳暗花明。现在再来看这个问题,感觉so easy,但是在当时还是给我带来了一些困扰的,以至于后来只能先使用变通方案来保证项目按时上线。只要思想不滑坡,办法总比困难多。对于从源码找答案这件事,我个人感觉还是需要一定技巧的,因为大部分开源框架或者类库代码量都是挺大的,我们没办法在短时间内熟悉作者的设计理念和思想,所以我们要对自己的问题做一下关键息提取(例如上面的问题,我们提取几个关键信息:delete、getFieldDecorator 、componentWillUnmount等),带着这些关键信息去源码中寻找答案,效率可能会更高。