今天犯了一个很蠢的“错误“- 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,通过这样简洁实现监听函数返回值的目的也是可以的。

相关推荐
喵叔哟26 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特1 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解1 小时前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django