• 关于useEvent的思考


    useEvent这个Hook最近很火,我也查阅了一下这个Hook的相关资料。

    useEvent这个API实际上通常来说就是useCallback这个钩子的改良版。

    useCallback这个钩子开发者会怎么使用呢?

    其实主要就是为了保证函数引用不变使用。

    但是这种使用下会存在比较烦人的闭包问题:

    1. import {useCallback, useState} from 'react';
    2. export default function App() {
    3. const [counter, setCounter] = useState(0);
    4. const handleClick = useCallback(() => {
    5. console.log(counter);
    6. setCounter(counter => counter + 1);
    7. }, []);
    8. return (
    9. <div onClick={handleClick}>
    10. click to add counter
    11. counter: {counter}
    12. </div>
    13. )
    14. }

    这个例子中,你会发现DOM上的counter更新了,但事件处理函数中的获取的counter始终为0。

    这是因为你在事件处理函数中访问到的是闭包中的变量。

    因为在App函数首次执行完毕后,JS引擎发现函数作用域内的counter和setCounter两个变量会被事件处理函数持续引用,但是执行上下文切换后这俩变量就会销毁,所以引擎会创建一个闭包保存这两个变量,而事件处理函数中的变量则会链接上闭包,因此访问的永远是首次渲染时创建的闭包中的变量。

    但是我们通常是希望访问最近的那次re-render的新状态,而不是闭包中首次的旧状态。

    那么解决方案的话:

    1. 用ref追踪最新值
    2. deps数组中增加counter

    到了这里,就引起争议了。

    很多开发者开始抵制useCallback这个API,因为保持引用不变这种模式并不是必要的,还引起了闭包问题要解决。

    但另一派则坚持保持引用不变的模式。

    后者的声量并不比前者少,或许正是因此,React预备推出useEvent这个API,来保证引用不变的同时可以访问到最新的状态:

    1. import {useState, useEvent} from 'react';
    2. export default function App() {
    3. const [counter, setCounter] = useState(0);
    4. const handleClick = useEvent(() => {
    5. console.log(counter);
    6. setCounter(counter => counter + 1);
    7. });
    8. return (
    9. <div onClick={handleClick}>
    10. click to add counter
    11. counter: {counter}
    12. </div>
    13. )
    14. }

    当然在(2022.6.23)这个时间点React还没有推出这个API,因此上面只是看看。

    我们不妨来hack一下这个API:

    1. import {useCallback, useRef, useState} from 'react';
    2. function useEvent(callback) {
    3. const callbackRef = useRef(null);
    4. callbackRef.current = callback;
    5. const event = useCallback((...args) => {
    6. if (callbackRef.current) {
    7. callbackRef.current.apply(null, args);
    8. }
    9. }, []);
    10. return event;
    11. }
    12. export default function App() {
    13. const [counter, setCounter] = useState(0);
    14. const handleClick = useEvent(() => {
    15. console.log(counter);
    16. setCounter(counter => counter + 1);
    17. });
    18. return (
    19. <div onClick={handleClick}>
    20. click to add counter
    21. counter: {counter}
    22. </div>
    23. )
    24. }

    不难理解这个useEvent的实现,保持引用不变这个功能当然还是用useCallback实现。

    而使用最新值这个功能,稍微需要思考下,实际上是把新的函数传进来了。

    可以看到使用useEvent和直接用useCallback不一样的地方在于,useEvent每次渲染都会创建一个新的事件处理函数,这个事件处理函数中访问的是最新的状态,所以每次都用ref把引用不变的event壳子内部的实际任务更新成最新的事件处理函数就可以了:callbackRef.current = callback;

    最终event这个引用不变的壳子内部调用的是每次渲染都会改变的处理函数:

    callbackRef.current.apply(null, args);

    听起来很完美,实际效果看起来也符合预期,但是其实并没有那么好:

    1. import { useEffect, useCallback, useRef, useState } from "react";
    2. function useEvent(callback) {
    3. const callbackRef = useRef(null);
    4. callbackRef.current = callback;
    5. const event = useCallback((...args) => {
    6. if (callbackRef.current) {
    7. callbackRef.current.apply(null, args);
    8. }
    9. }, []);
    10. return event;
    11. }
    12. export default function App() {
    13. const [count, setCount] = useState(0);
    14. const onLeave = useEvent(() => {
    15. console.log("onleave:", count);
    16. });
    17. const onEnter = useEvent(() => {
    18. console.log("onenter:", count);
    19. });
    20. const onClick = () => {
    21. setCount(count + 1);
    22. };
    23. useEffect(() => {
    24. onEnter();
    25. return () => {
    26. onLeave();
    27. };
    28. });
    29. return (
    30. <div onClick={onClick}>click to add counter counter: {count}</div>
    31. );
    32. }

    来看这个例子,先来说下设计意图:

    1. 用了useEvent保证两个函数的引用不变
    2. 想在每次渲染的时候读取count最新值
    3. 想每次渲染结束的时候读取count的最新值

    意图1显然可以实现没有问题。

    意图2也可以实现,每次读取的都是本次渲染的最新值

    但意图3则有些问题,因为这里读取到的是下一次渲染的最新值。也就是说onEnter和onLeave取得的不是相同的值,这显然是不符合预期的。

    也就是说,你点击一次,onEnter输出的0,而onLeave输出的是1。

    这种行为和React的自身流程有关,React的流程是:

    1. App函数执行
    2. 执行上次的渲染的cleanup,也就是useEffect中return的函数
    3. 执行本次渲染的effect

    而我们useEvent的hack实现中,是在App函数执行阶段替换的event内容。

    也就是说是替换后才轮到cleanup执行,所以cleanup中调用event获取的下次渲染的值。

    这里还是有点小饶,我明晰一下整个流程:

    1. 首次App函数执行
    2. 替换event内容,当前读取的count为0
    3. 执行effect,也就是onEnter,输出0
    4. 点击触发re-render,App函数再次执行
    5. 替换event内容,当前读取的count为1
    6. 执行上次渲染的cleanup,也就是onLeave,输出1
    7. 执行effect,也就是onEnter,输出1
    8. 后续是同样的逻辑,不再继续表述

    这个流程已经非常明晰了,现在可以继续说下解决的问题了。

    首先cleanup的时机就是在App执行后,这点肯定是难以改变的。

    那么我们能做的也只有更改替换event内容的时机,目前是在位置5,如果移动到位置7就可以了。

    因为在位置7更新,位置6cleanup还是使用的和上次effect中一样的旧值,就符合预期了:

    1. function useEvent(callback) {
    2. const callbackRef = useRef(null);
    3. useEffect(() => {
    4. callbackRef.current = callback;
    5. });
    6. const event = useCallback((...args) => {
    7. if (callbackRef.current) {
    8. callbackRef.current.apply(null, args);
    9. }
    10. }, []);
    11. return event;
    12. }

    然而事情完美解决了吗?

    并没有,因为还有个我们之前没有考虑的useLayoutEffect,这个API用得少一些,我们先列下layout effect、effect以及它们的cleanup的顺序:

    1. 首次App函数执行
    2. layout effect
    3. effect
    4. 点击触发re-render,App函数再次执行
    5. 对上次的layout effect进行cleanup
    6. 本次的layout effect
    7. 对上次的effect进行cleanup
    8. 本次的effect

    以上顺序是React的设计结果,要牢记这个表,很重要。

    不管React为什么这么设计,总之我们要在既定事实下从表中选择event内容的更新时机,从头开始:

    在1和2之间的话,前面已经得到了不行的结论,因为会导致onLeave和onEnter不一致。

    在2和3之间的话,也就是在layout effect中更新的话,效果和上面一样。注意看6和7,React内部会先layout effect再执行上次effect的cleanup方法,所以仍然会导致更新在cleanup之前,还是不行。

    至于3之后的任何时机,也都不行,因为太靠后了,如果在这个时机更新,那么就无法在layout effect中调用事件了。结合例子来说,就是首次渲染的layout effect中调用onEnter会命中current为空的分支,相当于头一枪在layout effect中会稳定打空,这显然不符合预期。

    事情到了这一步,似乎有些没法收场了,我们反思一下问题到底出在哪里?

    我们回到起点,useEvent的功能其实就是两个:

    1. 保持引用不变
    2. 解决闭包

    表面看起来是2无法解决,因为effect和cleanup中无法使用同一个闭包中的值。

    但根源其实是1,如果不保持引用不变,直接使用原函数,那effect和cleanup永远都使用的是同一个闭包中的值,也就没有这么多事了。

    所以保持引用不变其实是成本很高的,我们必须反思一下,保持引用不变到底应不应该?

    保持引用不变的理由,最常见的有:

    1. callback作为props时避免多余的re-render
    2. callback作为deps时避免多余的effect

    下面给一个说明以上两点的经典例子(例子的来源是《React Hooks(二): useCallback 之痛》):

    1. function Child(props) {
    2. console.log("rerender:");
    3. const [result, setResult] = useState("");
    4. const { fetchData } = props;
    5. useEffect(() => {
    6. fetchData().then((result) => {
    7. setResult(result);
    8. });
    9. }, [fetchData]);
    10. return (
    11. <>
    12. <div>query:{props.query}</div>
    13. <div>result:{result}</div>
    14. </>
    15. );
    16. }
    17. export function Parent() {
    18. const [query, setQuery] = useState("react");
    19. const fetchData = useCallback(() => {
    20. const url = "<https://hn.algolia.com/api/v1/search?query=>" + query;
    21. return fetch(url).then((x) => x.text());
    22. }, [query]);
    23. return (
    24. <div>
    25. <input onChange={(e) => setQuery(e.target.value)} value={query} />
    26. <Child fetchData={fetchData} query={query} />
    27. </div>
    28. );
    29. }

    这是一个搜索场景,我理解支持保持引用不变的人就是想写这种代码,他们觉得:

    1. 只有在query更新后,fetchData的引用才更新,这样Child就可以进行memo,避免query以外的state改变导致函数引用改变,进而导致不必要的re-render
    2. 在Child中fetchData是deps,如果不用useCallback,那父组件任何无关变量导致的re-render都会导致引用改变,进而导致子组件中进行多余的effect。

    所以说,useCallback既避免了多余的re-render,又避免了多余的effect,实在是太好啦,必须都给我用起来。

    然而其实不然,存在更好的解决方案。

    实际上,任何callback都可以拆解为纯函数+state.

    我们只需要遵循以下原则:

    1. 我们永远不传函数props,也不把函数作为deps。
    2. 我们只传state props,只把state作为deps。
    3. 我们把callback拆解成纯函数+state。
    4. 至于纯函数,我们以export和import的方式复用。

    只需要按照这四点操作,就不再需要保证引用不变:

    1. import {useState, useEffect} from 'react';
    2. function Child(props) {
    3. console.log("rerender:");
    4. const [result, setResult] = useState("");
    5. const {query} = props;
    6. useEffect(() => {
    7. fetchData(query).then((result) => {
    8. setResult(result);
    9. });
    10. }, [query]);
    11. return (
    12. <>
    13. <div>query:{query}</div>
    14. <div>result:{result}</div>
    15. </>
    16. );
    17. }
    18. const fetchData = query => {
    19. const url = "<https://hn.algolia.com/api/v1/search?query=>" + query;
    20. return fetch(url).then((x) => x.text());
    21. }
    22. export default function Parent() {
    23. const [query, setQuery] = useState("react");
    24. return (
    25. <div>
    26. <input onChange={(e) => setQuery(e.target.value)} value={query} />
    27. <Child query={query} />
    28. </div>
    29. );
    30. }

    其实就是按照上述的原则,把fetchData变成了纯函数,纯函数可以干净地export和import,不需要props传递,用的时候传入参数就可以用。

    这就解决了保持引用不变的目的1,避免多余的re-render。

    至于目的2,也不攻自破,因为fetchData就是一个纯的工具函数,根本不需要把它作为deps,因为它被改造成了永远不需要更新的、忠实地根据输出返回结果、状态无关的工具(而不是你需要仔细研究代码、弄清楚到底什么时候会更新的、代码一复杂就难以debug的状态强耦合的讨厌事物)。只需要给它传query参数就可以了,也就避免了多余的effect。

    至此,保持引用不变的两个目的都已经达成了,并且这个方案:

    1. 代码量毫无疑问更少,没有用到useCallback,deps也少了,用思考的点毫无疑问少了
    2. fetchData这个强耦合query的callback拆解成了query+纯函数,耦合性也减少了

    所以,我的想法是,保持函数引用不变绝大多数情况下就是个伪需求,完全有更好的解决方案,至于基于引用不变思路下解决闭包的useEvent API,似乎也是和useCallback一样,没多大必要的。

  • 相关阅读:
    spring源码 - bean的实例化过程
    (ICCV 2021) Hierarchical Aggregation for 3D Instance Segmentation
    常见分布式事务解决方案
    macOS如何查看pkg安装包中的内部文件
    AnatoMask论文汇总
    《架构风清扬-Java面试系列第26讲》聊聊的LinkedBlockingQueue的特点及使用场景
    log4j2的简单使用
    Web(二)html5基础-文档头部(知识训练和编程训练)
    以sqlilabs靶场为例,讲解SQL注入攻击原理【15-17关】
    LRU算法
  • 原文地址:https://blog.csdn.net/qq_41635167/article/details/125436355