V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
liumingyi1
V2EX  ›  React

放弃使用 useCallback 吧,我们有更好的方式

  •  
  •   liumingyi1 · 2021-11-04 15:06:51 +08:00 · 3011 次点击
    这是一个创建于 1150 天前的主题,其中的信息可能已经有所发展或是发生改变。

    自从 React Hooks 面世以来,我们对其讨论便层出不穷。今天我们来谈谈 React.useCallback 这个 API 。先说结论:几乎所有场景,我们有更好的方式代替 useCallback

    我们先看看 useCallback 的用法

    const memoizedFn = React.useCallback(() => {
      doSomething(a, b);
    }, [a, b]);
    

    React 官方把这个 API 当作 React.memo 的性能优化手段而打造。看介绍:

    把内联回调函数及依赖项数组作为参数传入 useCallback ,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate )的子组件时,它将非常有用。

    那我们就来从性能优化的角度看看 useCallback

    示例:

    const ChildComponent = React.memo(() => {
      // ...
      return <div>Child</div>;
    });
    
    function DemoComponent() {
      function handleClick() {
        // 业务逻辑
      }
    
      return <ChildComponent onClick={handleClick} />;
    }
    

    DemoComponent 组件自身或跟随父组件触发 render 时,handleClick 函数会被重新创建。 每次 renderChildComponent 参数中会接受一个新的 onClick 参数,这会直接击穿 React.memo,导致性能优化失效,并联动一起 render

    当然,官方文档指出,在组件内部中每次跟随 render 而重新创建函数的开销几乎可以忽略不计。若不将函数传给自组件,完全没有任何问题,而且开销更小。

    接下来我们用 useCallback 包裹:

    // ...
    
    function DemoComponent() {
      const handleClick = React.useCallback(() => {
        // 业务逻辑
      }, []);
    
      return <ChildComponent onClick={handleClick} />;
    }
    

    这样 handleClick 就是 memoized 版本,依赖不变的话则永远返回第一次创建的函数。但每次 render 还是创建了一个新函数,只是没有使用罢了。 React.memoPureComponent 类似,它们都会对传入组件的新旧数据进行 浅比较,如果相同则不会触发渲染。

    接下来我们在 useCallback 加上依赖:

    function DemoComponent() {
      const [count, setCount] = React.useState(0);
    
      const handleClick = React.useCallback(() => {
        // 业务逻辑
        doSomething(count);
      }, [count]);
    
      // 其他逻辑操作 setState
    
      return <ChildComponent onClick={handleClick} />;
    }
    

    我们定义了 count 状态作为 useCallback 的依赖。若 count 变化后,render 则会产生新的函数。这便会击穿 React.memo,联动子组件 render

    const handleClick = React.useCallback(() => {
      // 业务逻辑
      doSomething(count);
    }, []);
    

    如果去除依赖,这时内部逻辑取得的 count 的值永远为初始值即 0 ,也就是拿不到最新的值。如果将内部的逻辑作为 function 提取出来作为依赖,这又会导致 useCallback 失效。

    我们看看 useCallback 源码

    ReactFiberHooks.new.js

    // 装载阶段
    function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
      // 获取对应的 hook 节点
      const hook = mountWorkInProgressHook();
      // 依赖为 undefiend ,则设置为 null
      const nextDeps = deps === undefined ? null : deps;
      // 将当前的函数和依赖暂存
      hook.memoizedState = [callback, nextDeps];
      return callback;
    }
    
    // 更新阶段
    function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
      const hook = updateWorkInProgressHook();
      const nextDeps = deps === undefined ? null : deps;
      // 获取上次暂存的 callback 和依赖
      const prevState = hook.memoizedState;
      if (prevState !== null) {
        if (nextDeps !== null) {
          const prevDeps: Array<mixed> | null = prevState[1];
          // 将上次依赖和当前依赖进行浅层比较,相同的话则返回上次暂存的函数
          if (areHookInputsEqual(nextDeps, prevDeps)) {
            return prevState[0];
          }
        }
      }
      // 否则则返回最新的函数
      hook.memoizedState = [callback, nextDeps];
      return callback;
    }
    

    通过源码不难发现,useCallback 实现是通过暂存定义的函数,根据前后依赖比较是否更新暂存的函数,最后返回这个函数,从而产生闭包达到记忆化的目的。 这就直接导致了我想使用 useCallback 获取最新 state 则必须要将这个 state 加入依赖,从而产生新的函数。

    大家都知道,普通 function 可以变量提升,从而可以互相调用而不用在意编写顺序。如果换成 useCallback 实现呢,在 eslint 禁用 var 的时代,先声明的 useCallback 是无法直接调用后声明的函数,更别说递归调用了。

    组件卸载逻辑:

    const handleClick = React.useCallback(() => {
      // 业务逻辑
      doSomething(count);
    }, [count]);
    
    React.useEffect(() => {
      return () => {
        handleClick();
      };
    }, []);
    

    在组件卸载时,想调用获取最新值,是不是也拿不到最新的状态?其实这不能算 useCallback 的坑,React 设计如此。

    好了,我们列出了一些无论是不是 useCallback 的问题。

    1. 记忆效果差,依赖值变化则重新创建
    2. 想要记忆效果好,又是个闭包,无法获取最新值
    3. 上下文调用顺序的问题
    4. 组件卸载时获取最新 state 的问题

    我都想避免这些问题可以吗?拿来吧你!

    我们先看看用法

    function DemoComponent() {
      const [count, setCount] = React.useState(0);
    
      const { method1, method2, method3 } = useMethods({
        method1() {
          doSomething(count);
        },
        method2() {
          // 直接调用 method1
          this.method1();
          // 其他逻辑
        },
        method3() {
          setCount(3);
          // 更多...
        },
      });
    
      React.useEffect(() => {
        return () => {
          method1();
        };
      }, []);
    
      return <ChildComponent onClick={method1} />;
    }
    

    用法是不是很简单?还不用写依赖,这不仅完美避开了上述所有的问题。而且还让我们的 function 聚合便于阅读。废话不多说,上源码:

    export default function useMethods<T extends Record<string, (...args: any[]) => any>>(methods: T) {
      const { current } = React.useRef({
        methods,
        func: undefined as T | undefined,
      });
      current.methods = methods;
    
      // 只初始化一次
      if (!current.func) {
        const func = Object.create(null);
        Object.keys(methods).forEach((key) => {
          // 包裹 function 转发调用最新的 methods
          func[key] = (...args: unknown[]) => current.methods[key].call(current.methods, ...args);
        });
        // 返回给使用方的变量
        current.func = func;
      }
    
      return current.func as T;
    }
    

    实现很简单,利用 useRef 暂存 object,在初始化时给每个值包裹一份 function,用于转发获取最新的 function。从而既拿到最新值,又可以保证引用值在声明周期内永远不改变。 完美,就这样~

    那么是不是 useCallback 没有使用场景了呢?答案是否定的,在某些场景下,我们需要通过 useCallback 暂存某个状态的闭包的值,以供需求时调用。比如消息弹出框,需要弹出当时暂存的状态信息,而不是最新的信息。

    最后,推荐一下我写的状态管理 HeouseMethods 已经包含其中。后面会分享写 Heo 库的动机,欢迎大家持续关注。

    9 条回复    2022-03-15 17:14:36 +08:00
    AsZr
        1
    AsZr  
       2021-11-04 15:23:29 +08:00
    woc 大佬 哈哈哈哈哈哈
    最近一个项目 pov 区域的状态管理
    一开始用 useContext 然后用 unstated-next 然后用 Heo 感觉不错
    huai
        2
    huai  
       2021-11-04 16:36:10 +08:00
    > 我们定义了 count 状态作为 useCallback 的依赖。若 count 变化后,render 则会产生新的函数。这便会击穿 React.memo ,联动子组件 render 。

    这个有什么问题?在我看来,这正常的现象。你提供的方法,只是进一步优化,虽然我也不知道 到底有没有啥好处。

    还有就是 继续引入 this ,官方推出 hook ,有一部分原因就是避免 this
    liumingyi1
        3
    liumingyi1  
    OP
       2021-11-04 16:54:10 +08:00
    @huai 你的子组件用了 React.memo ,也就意味着你想避免这里的性能消耗,复杂的应用不加 memo 真的卡得要命。useCallback 满足不了需求,当然可以另辟蹊径。应用不复杂也用不上 useCallback ,直接用 function 就行了。

    官方推出 hooks 的确是想避免 this ,那只是理想状态,react 推崇函数式编程,但事实一点都不纯。代码,我们写得爽不就更好吗?你看很多知名的 hooks 库,一堆 useRef ,this 之类的。只要不把 this 让使用者承担心智负担都是 ok 的
    JoStar
        4
    JoStar  
       2021-11-05 09:25:52 +08:00
    我觉得楼主对 hook 进行重新封装是好事儿,毕竟这也是 react 一向鼓励的。

    但是开头说替代 useCallback 文末又说 useCallback 还是有使用场景的,这样回看标题觉得有点标题党了。
    JoStar
        5
    JoStar  
       2021-11-05 09:28:15 +08:00
    我认为而且 useCallback 不单是性能优化,还有一个传递依赖项的作用也是非常重要的。useMethods 好像做不到这点
    liumingyi1
        6
    liumingyi1  
    OP
       2021-11-05 11:20:18 +08:00
    @JoStar 但是 useCallback 的使用场景非常的少,几乎用不到,谈不上标题党。第二点 传递依赖项有什么作用?如果要处理副作用,直接用 useEffect 就好了。
    xingguang
        7
    xingguang  
       2021-11-05 18:06:57 +08:00
    @JoStar #5 我记得官方好像不推荐用 useCallback 传递依赖,因为会产生不确定性
    magicdawn
        8
    magicdawn  
       2022-01-26 02:28:11 +08:00 via Android
    这不就是社区里的 useEventCallback / ahooks usePersistFn 么
    fernandoxu
        9
    fernandoxu  
       2022-03-15 17:14:36 +08:00
    this+ref 都是违背 react 哲学的东西...
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2846 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 08:31 · PVG 16:31 · LAX 00:31 · JFK 03:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.