useCallback 使用的4个阶段

2023-12-15 13:42:00 浏览数 (1)

非 React 使用者估计看了都要摇头啊。一个破回调函数的运用,居然能折腾出来这么多事。一大堆文章都在探讨如何使用它更合理。事实上确实如此,在 React 独特的单向数据流刷新机制下,对于 useCallback 认知的逐渐深入实际上也代表着对 React 本身这个机制的理解更进一步,因此在你彻底消化 React 刷新机制之前,这个过程中的每一个知识点可能都有巨大的探讨空间

前几天我的一位学生跟我探讨了一种 useCallback 的用法,他的想法是:当我们在封装开源工具库时,对自定义 hook 中暴露出来的钩子函数使用 useCallback 缓存。因为我们并不确定使用者是否需要一个引用稳定的钩子函数,他们有可能是需要的,因此用 useCallback 来包一层是有意义的。但是他并不确定这样的做法是否合适,是否具备较大的正向收益。

那么我就借着这个案例,来跟大家探讨一下,我们在 React 进阶的过程中,使用 useCallback 的四个阶段。

01

阶段一:敬畏

这个时候你还是一个初学者,对 React 的理解还不够深刻不够全面,但是常常看到文章,或者听别人说 useCallback 跟性能优化有关,可对于你而言,你并不是非常清楚它跟性能优化的具体关系在哪里,想知道,但不知道或者不够确定,因此对这个 hook api 有一种敬畏之心,各个论坛里对于 useCallback 的介绍很多很嘲杂,但你不敢随便用。

因此你很想去看看别人的代码里,useCallback 是怎么用的,是在什么场景下使用的,但是想要看到别人的代码也并不容易,因此你可能会在这个阶段徘徊。

02

阶段二:懂了

随着学习的深入,你逐渐开始深入理解了 React 的单向数据机制,也对 React 的使用更加熟练,知道 React 经常会存在许多 re-render,你终于搞懂了 useCallback 的使用场景,它结合 React.memo 能够缓存组件,避免组件的冗余 re-render.

于是你在项目中大量的使用了他们,就像当初 PureComponent 一样,你恨不得每个函数都用 useCallback 套一层,以确保自己的项目能最大限度减少 re-render,从而达到一个极致的性能体验。

代码语言:javascript复制
function App() {
  ...

  const clickHankler = useCallback(() => {
    ...
  }, [count])

  const onOpen = useCallback(() => {
    ...
  }, [])
  
  ...
}

但是不管你用还不用,是大量使用还是大量不使用,从页面的运行结果中,都看不出来你这样写带来了什么实质的提升,甚至你有可能在依赖项的使用上感到难受,因为闭包的影响导致实际运行结果跟你预想的有出入。但是你能明确感受到 re-render 次数减少了。因此这个阶段你非常坚信自己达到了性能优化的目的

直到一次偶然的面试中,你被面试官一个问题问得哑口无言:只用 useCallback 能达到减少 re-render 的次数吗?为什么

03

阶段三:精通

这个时候你阅读了我的上一篇文章理解这个机制,是成为 React 性能优化高手的关键,听了我的直播分享,彻底搞懂了 React 的底层 DIFF 机制,你发现原来在 React 底层机制的逻辑下,我们大量的缓存工作其实是没有必要的。React.memo 也有不小的使用成本,有的时候他的损耗不一定比 re-render 更低,于是你懂得了如何在项目中合理的使用 useCallback React.memo,一通优化下来,项目里的 useCallback 都被删得差不多了,只在关键位置剩下几个。

优化的结果很理想,re-render 的情况不仅没有变多,项目还减负了,性能又得到了提升,你很开心很有成就感。心想我终于又有了成长,再次遇到上次那个面试官,我必定能吊打他

04

阶段四:贯通

你终于明白了 useCallback 只是一个非常普通的记忆函数。在 React hooks 特定的机制下记忆函数本身就被大量运用。React 的许多 hook 都有类似的记忆能力,useCallback 只是最普通的那一个,另外的 hook 都在记忆能力的基础之上又添加了一些别的语义

代码语言:javascript复制
useState
useEffect
useLayoutEffect
useCallback
useMemo
useRef
useReducer
useSyncExternalStore
...

这个阶段你不再特殊看待他,在你的知识结构里面你也不再特意的把他跟性能优化挂上勾,而是把他标记为一个记忆函数,他能够保持一个函数的引用,当你在 React 这个不稳定的上下文环境中过,需要一个稳定的引用时,你才会使用 useCallback

因此,当你在封装一个开源工具库时,你想到了你会对外抛出一个钩子函数,但是你并不确定使用者会如何使用这个钩子函数,使用者有可能会把他传递给子组件,此时如果钩子函数引用不稳,那么就有可能导致子组件 re-render

例如在我们前面学习自定义 hook 的文章中,我们封装了一个 hook useFetch,代码如下

代码语言:javascript复制
import { useState, useRef, useLayoutEffect } from 'react'

type API<T, P> = (param?: P) => Promise<T>

export default function useFetch<T, P>(api: API<T, P>) {
  const param = useRef<P>()
  const [list, setList] = useState<T>()
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(true)

  function getList() {
    api(param.current).then(res => {
      setList(res)
      setLoading(false)
      setError('')
    }).catch(err => {
      setLoading(false)
      setError(err)
    })
  }

  useLayoutEffect(() => {
    loading && getList()
  }, [loading])

  return { 
    param, 
    setParam: (p: P) => param.current = p,
    list, 
    error, 
    loading, 
    setLoading 
  }
}

我们可以看到代码里,在这个自定义 hook 中,返回了两个钩子函数 setLoading setParam

为了验证他们的引用是否稳定,我们在使用 useFectch 的组件中使用如下代码来验证函数的引用是否发生了变化

代码语言:javascript复制
useEffect(() => {
  console.log('setLoading')
}, [setLoading])

验证结果非常神奇,setLoading 的引用居然非常的稳定。但对于此时的你来说,这并没有什么值得奇怪的地方。因为他是直接从 useState 中获取出来的。useState 本身就具备记忆能力,因此对于 setLoading 来说,我们不再需要想任何办法来让他的引用来保持稳定

setParam 跟预期一样,一点也不稳定,每次状态变化,他的引用都会发生变化。因为在定义它的时候,每次都是新生成的函数给他赋值

代码语言:javascript复制
  return { 
    param, 
     setParam: (p: P) => param.current = p,
    list, 
    error, 
    loading, 
    setLoading 
  }

此时到了 useCallback 大展身手的时候了,我们使用 useCallback 包一层

代码语言:javascript复制
  return { 
    param, 
-    setParam: (p: P) => param.current = p,
     setParam: useCallback((p: P) => param.current = p, []),
    list, 
    error, 
    loading, 
    setLoading 
  }

再次验证,发现引用果然变稳定了。

nice.

但是你害怕这样做有什么你没想到的点,因为 useCallback 太善变了,所以你就跑来跟我沟通,想确定一下这样子做到底能不能带来很大的正向收益

万万没想到,我一开口就说:没必要

我引导你去看一下引用稳定的 setLoading 是如何使用的,你就去翻了一下代码,结果一看,坏事了,setLoading 因为传了一个参数,导致在使用的时候又套了一层函数,....

代码如下。此时 onClick 接收到的还是一个引用不稳定的匿名函数... setLoading 的引用白考虑了...

代码语言:javascript复制
<Button
  className={s.button}
  onClick={() => setLoading(true)}
>

然后你又看了一眼 setParam 的使用,还是这么个情况...

代码语言:javascript复制
<input
  className={s.input}
  placeholder="请输入您要搜索的内容"
  onChange={(e) => setParam(e.target.value)}
/>

最后一想,发现好像 useCallback 又做了无用功...

至此,你彻底悟了...

就说总有一种不确定感,原来少考虑了一步。当自定义 hook 传出来的 函数在执行时需要传入参数时,就不得不在这个函数外面包一层匿名函数,再传递给子组件使用,如果它不需要参数,useCallback 才会发挥它的效果

代码语言:javascript复制
function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}
代码语言:javascript复制
const {goBack} = useRouter()

... 

<Child onBack={goBack}  />

当真是真是步步惊心啊。

你终于悟到了要结合实际使用的场景去考虑使用 useCallback 的准确时机,自此,融汇贯通成就达成

0 人点赞