前言
大家好,我是祯民。今天犯了一个很蠢的"错误",回想起来很羞愧,这是一个很基础的问题,但中间包含了不少知识点,收获满满,所以这里还是想拿这个bad case出来鞭尸一下,给大家分享这个过程和涉及到的知识点,希望对大家有所帮助。
这里错误两个字打""呢?因为我的写法本身是不合适的,但是因为一些特殊的原因歪打正着却又可以满足需求,当时我和code reivew的同学面面相觑,场面非常尴尬...废话不多说,我们进入正题,开始吃瓜。
正文
"错误"起因 - 一个表单值监听的简单需求
为便于大家理解和脱敏,下面我尽量以最简demo的方式介绍~前段时间,产品提了一个迭代的需求,其中有一部分是需要监听表单某个字段的值,当某个字段值改变的时候做一些处理数据的操作。
类似这样,reason变了下面的展示也变。我们这儿项目是用 semi 组件库(和antd类似),一般来说这个需求用表单的onValueChange回调就可以处理了,但是出于项目历史包袱的考虑,原代码的表单有n层子组件的props透传,那么使用回调函数将值一级级传下去就很烦了。
除需要级级透传onChange外,还要考虑历史逻辑有没有坑或者和onChange重名但作用类似的回调,当然也可以考虑用个store一劳永逸,但是因为这部分页面改动量的确小,所以也不想为了透传一个值加个store。
所以我就在想有没有途径能让我只修改那个监听逻辑的组件,几年前的历史逻辑不到万不得已我是不想读的🐶这时候我想到 form 组件有提供 formApi 的 ref 来和表单联动,可以进行取值、设值、验证等操作。而历史逻辑中,formApi是进行了透传的,所以如果通过监听 formApi.current.getValues() 就可以实现只修改底层组件,而不使用change回调达到同样效果了,这部分代码简化下来如下demo。
ts
import { Form } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { useMemo, useRef } from 'react';
// 实际需求中引入,这里为简化说明,放在一起定义
const TestComponent = ({ formApi }) => {
const reasonContent = useMemo(() => {
return `reason: ${formApi.current?.getValues()?.reason}`;
}, [formApi.current?.getValues()?.reason]);
return <div>{reasonContent}</div>;
};
export default () => {
const formApi = useRef<FormApi>();
return (
<div>
<Form
getFormApi={formApiValue => {
formApi.current = formApiValue;
}}
>
{() => (
<>
<Form.Input field="reason" />
<TestComponent formApi={formApi} />
</>
)}
</Form>
</div>
);
};
效果可以看到是满足需求的,测试回归也通过了,但到 cr 阶段的时候,组里同学有提出质疑,这部分能力应该没效果才对,react钩子的dependencies正常来说不能监听函数的返回值。
cr同学:这么写会有问题的吧...react钩子的dependencies正常来说不能监听函数的返回值呀,这样reason字段变化的时候,reasonContent应该不更新的。
我:不可能啊,回归都通过了,我断点给你看。
cr同学:...还真行
抛开需求不谈,写个demo,但是打脸了
因为测试回归和效果都没有,所以一开始我还是很有信心的,秉承着"talk is cheap, show me the code"的原则,我打算抛开历史包袱,写个 demo 来证明我这么写没问题。从事实效果上看,当时我以为 dependencies 参数可以监听到深层函数的返回,所以我写的 demo 是这样的:
ts
import { Form } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { useMemo, useRef } from 'react';
export default () => {
const formApi = useRef<FormApi>();
const reasonContent = useMemo(() => {
return `reason: ${formApi.current?.getValues()?.reason}`;
}, [formApi.current?.getValues()?.reason]);
return (
<div>
<Form
getFormApi={formApiValue => {
formApi.current = formApiValue;
}}
>
<>
<Form.Input field="reason" />
<div>{reasonContent}</div>
</>
</Form>
</div>
);
};
结果最终的效果狠狠打脸了,useMemo 的 dependencies 参数并没有监听到 getValues 的变化,效果如下图。
useEffect、useMemo等钩子dependencies参数能监听什么?
这时候气氛就尴尬了,打开react官网文档再重新看看对应字段的描述,具体如下
设置代码内部引用的所有响应式值的列表。 响应式值包括props、state,以及在组件主体中直接声明的所有变量和函数。如果你的代码检查器(linter)配置了React支持,它会验证每个响应式值是否被正确地作为依赖项列出。依赖项列表必须具有固定数量的项目,并且要像
[dep1, dep2, dep3]
这样写在一行内。React会使用Object.is
比较方式来对比每个依赖项与其上一次的值。如果你省略此参数,每次组件重渲染后,你的Effect都将重新执行。请参阅传递依赖项数组、空数组与完全不传递依赖项之间的区别。
响应式值的监听包含了props、state,以及在组件主体中直接声明的所有变量和函数。所以根据描述,dependencies参数只监听已注册的响应式值,或者普通的变量和函数引用,对函数的返回值的确是不直接监听的。
为什么需求代码能正常用?
现在回到这个问题,为什么需求的代码中效果是符合预期的?能在表单值变化的时候,执行useMemo更新值呢?其实整个过程并不是一个监听到变化更新的过程,而是 props 更新引起重渲染再次执行组件逻辑的过程。下面我们来具体剖析为什么会有这么个结果。
一开始我考虑过是不是 semi 组件有什么特殊的处理,因为需求代码中的 dom 是用立即执行函数的形式传递的,但实际上并没有,函数的形式只是会额外通过函数透传一些表单相关的参数,例如formApi等,并不会直接影响react的监听,对应源码如下图:
排除了三方组件库的原因,现在我们对比需求demo的代码和打脸的代码,可以发现其中有两处不同:
-
useMemo执行的地方,不是以组件props的方式传递使用的,需求的代码中useMemo写的地方是在组件中,而且formApi 以组件 props 的形式传递。
-
需求代码的 dom 以立即执行函数(IIFE)的形式传递,而打脸的代码以常规 dom 的形式直接传递。
把两个因素结合起来其实就可以破案了,需求代码的 useMemo 并不是因为监听到了函数返回值变化而引起重渲染,而是因为 props (formApi)的更新,导致组件再次渲染,执行了 useMemo。这个猜想也非常好验证,我们只需要在组件定义首处加一个断点,如下:
ts
const TestComponent = ({ formApi }) => {
debugger;
const reasonContent = useMemo(() => {
return `reason: ${formApi.current?.getValues()?.reason}`;
}, [formApi.current?.getValues()?.reason]);
return <div>{reasonContent}</div>;
};
需求的代码会走进这个 debugger 断点,而打脸的代码即是把对应内容拆出来,封装成组件,但仍以常规 dom 形式传递也不能够走进 debugger 断点。如果是监听而更新 reasonContent,是不会走进重渲组件的断点的,而是只执行useMemo中的逻辑。
那么为什么使用立即执行函数(IIFE)的时候,就会触发 formApi props的更新,而常规 dom 就不会呢?
useRef 的数据持久化
semi form组件中提供的formApi
使用useRef
注册,在组件的整个生命周期内持久地存储一个可变的值。与React的状态管理工具(如useState
)不同,useRef
不直接参与React的渲染流程,也就是说,即使useRef
中的值发生变化,也不会直接导致组件或其子组件重新渲染。
因为这个特性,当直接使用dom传递时,formApi中getValues的更新并不会触发组件的重渲染,而使用IIFE(立即执行函数表达式)的方式包裹子组件的渲染逻辑,每次渲染时都会创建一个新的函数,这个新函数作为children传递给Form
组件。所以IIFE本身不改变formApi.current
的引用,但它确保了在Form
内部状态变化触发重渲染时,子组件也有机会重新评估props(包括formApi
)。
所以总结一下,需求的代码之所以能按预期运行,并不是 dependencies 能监听函数返回值了,而是IIFE的方式让每次渲染时创建了一个新的函数,对子组件重新传递了props,使得子组件有机会再次执行评估props(包括formApi
)。真正起作用依靠的是子组件的再次执行,而非是 dependencies 参数对函数返回值的监听。
如果的确想用 dependecies 参数监听某个函数返回值,有解法吗?
上面我们的疑问其实已经解了,但如果的确想用 dependecies 参数监听某个函数返回值,也是有一些 hack 的解法的,我们上文已经学习了钩子响应式值的监听包含了props、state,以及在组件主体中直接声明的所有变量和函数。所以如果将某个函数的返回值控制为 state ,通过更新 state 就能做到监听函数返回值,下面提供一个 demo,大家可以下来自行体会一下。
ts
import { Button } from "@ies/semi-ui-react";
import { useEffect, useMemo, useRef, useState } from "react";
class A {
private a;
setValue(val) {
this.a = val;
}
getValue() {
return this.a;
}
}
const test = () => {
const [time, setTime] = useState({ a: 1 });
const testRef = useRef<A>();
useEffect(() => {
testRef.current = new A();
}, []);
useMemo(() => {
debugger; // 点击ref function就能触发这个断点
}, [testRef.current?.getValue().a]);
return (
<div>
<Button
onClick={() => {
testRef.current?.setValue(time);
setTime({ a: new Date().getTime() });
}}
>
ref function
</Button>
</div>
);
};
export default test;
小结
通过本文的吃瓜,大家应该可以收获满满,首先我们对 react hooks需要有一个预期,提供 dependencies 监听参数的钩子(如useEffect、useState)只能监听包含props、state,以及在组件主体中直接声明的所有变量和函数。
本文讲的 case 之所以能跑成功,依赖的是将 formApi 作为组件 props 传递,并使用自执行函数触发了组件的再次执行进行的更新,而不是 dependencies 监听到了函数本身的变化。对于这个 case 更好的做法还是使用 onChange 等回调通信,尽量避免对 dependencies 参数使用函数等嵌套模式,存在风险的同时,代码易读性也会差很多。
最后我们还讲了一个题外话,从实现角度上,使用 dependencies 参数实现真正的函数返回值变化监听也并非不可以,只需要将指定函数的返回控制成 state,相当于 dependencies 参数监听了一个已经注册的响应式 state,通过这样简洁实现监听函数返回值的目的也是可以的。