今天犯了一个很蠢的“错误“- React钩子dependencies参数如何“监听”某函数返回值变化?

前言

大家好,我是祯民。今天犯了一个很蠢的"错误",回想起来很羞愧,这是一个很基础的问题,但中间包含了不少知识点,收获满满,所以这里还是想拿这个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的代码和打脸的代码,可以发现其中有两处不同:

  1. useMemo执行的地方,不是以组件props的方式传递使用的,需求的代码中useMemo写的地方是在组件中,而且formApi 以组件 props 的形式传递。

  2. 需求代码的 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,通过这样简洁实现监听函数返回值的目的也是可以的。

相关推荐
Myli_ing28 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维1 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~1 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
彭世瑜2 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4042 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five2 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序2 小时前
vue3 封装request请求
java·前端·typescript·vue