非 React 使用者估计看了都要摇头啊。一个破回调函数的运用,居然能折腾出来这么多事。一大堆文章都在探讨如何使用它更合理。事实上确实如此,在 React 独特的单向数据流刷新机制下,对于 useCallback 认知的逐渐深入实际上也代表着对 React 本身这个机制的理解更进一步,因此在你彻底消化 React 刷新机制之前,这个过程中的每一个知识点可能都有巨大的探讨空间
前几天我的一位学生跟我探讨了一种 useCallback 的用法,他的想法是:当我们在封装开源工具库时,对自定义 hook 中暴露出来的钩子函数使用 useCallback 缓存。因为我们并不确定使用者是否需要一个引用稳定的钩子函数,他们有可能是需要的,因此用 useCallback 来包一层是有意义的。但是他并不确定这样的做法是否合适,是否具备较大的正向收益。
那么我就借着这个案例,来跟大家探讨一下,我们在 React 进阶的过程中,使用 useCallback 的四个阶段。
阶段一:敬畏
这个时候你还是一个初学者,对 React 的理解还不够深刻不够全面,但是常常看到文章,或者听别人说 useCallback 跟性能优化有关,可对于你而言,你并不是非常清楚它跟性能优化的具体关系在哪里,想知道,但不知道或者不够确定,因此对这个 hook api 有一种敬畏之心,各个论坛里对于 useCallback 的介绍很多很嘲杂,但你不敢随便用。
因此你很想去看看别人的代码里,useCallback 是怎么用的,是在什么场景下使用的,但是想要看到别人的代码也并不容易,因此你可能会在这个阶段徘徊。
阶段二:懂了
随着学习的深入,你逐渐开始深入理解了 React 的单向数据流机制,也对 React 的使用更加熟练,知道 React 经常会存在许多 re-render,你终于搞懂了 useCallback 的使用场景,它结合 React.memo 能够缓存组件,避免组件的冗余 re-render。
于是你在项目中大量的使用了他们,就像当初 PureComponent 一样,你恨不得每个函数都用 useCallback 套一层,以确保自己的项目能最大限度减少 re-render,从而达到一个极致的性能体验。
function App() {
...
const clickHankler = useCallback(() => {
...
}, [count])
const onOpen = useCallback(() => {
...
}, [])
...
}
但是不管你用还不用,是大量使用还是大量不使用,从页面的运行结果中,都看不出来你这样写带来了什么实质的提升,甚至你有可能在依赖项的使用上感到难受,因为闭包的影响导致实际运行结果跟你预想的有出入。但是你能明确感受到 re-render 次数减少了。因此这个阶段你非常坚信自己达到了性能优化的目的。
直到一次偶然的面试中,你被面试官一个问题问得哑口无言:只用 useCallback 能达到减少 re-render 的次数吗?为什么?
阶段三:精通
听了我的直播分享,彻底搞懂了 React 的底层 DIFF 机制,你发现原来在 React 底层机制的逻辑下,我们大量的缓存工作其实是没有必要的。React.memo 也有不小的使用成本,有的时候他的损耗不一定比 re-render 更低,于是你懂得了如何在项目中合理的使用 useCallback + React.memo,一通优化下来,项目里的 useCallback 都被删得差不多了,只在关键位置剩下几个。
优化的结果很理想,re-render 的情况不仅没有变多,项目还减负了,性能又得到了提升,你很开心很有成就感。心想我终于又有了成长,再次遇到上次那个面试官,我必定能吊打他。
阶段四:贯通
你终于明白了 useCallback 只是一个非常普通的记忆函数。在 React hooks 特定的机制下记忆函数本身就被大量运用。React 的许多 hook 都有类似的记忆能力,useCallback 只是最普通的那一个,另外的 hook 都在记忆能力的基础之上又添加了一些别的语义。
useState
useEffect
useLayoutEffect
useCallback
useMemo
useRef
useReducer
useSyncExternalStore
...
这个阶段你不再特殊看待他,在你的知识结构里面你也不再特意的把他跟性能优化挂上勾,而是把他标记为一个记忆函数,他能够保持一个函数的引用,当你在 React 这个不稳定的上下文环境中过,需要一个稳定的引用时,你才会使用 useCallback。
因此,当你在封装一个开源工具库时,你想到了你会对外抛出一个钩子函数,但是你并不确定使用者会如何使用这个钩子函数,使用者有可能会把他传递给子组件,此时如果钩子函数引用不稳,那么就有可能导致子组件 re-render。
例如在我们前面学习自定义 hook 的文章中,我们封装了一个 hook useFetch,代码如下:
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 的组件中使用如下代码来验证函数的引用是否发生了变化。
useEffect(() => {
console.log('setLoading')
}, [setLoading])
验证结果非常神奇,setLoading 的引用居然非常的稳定。但对于此时的你来说,这并没有什么值得奇怪的地方。因为他是直接从 useState 中获取出来的。useState 本身就具备记忆能力,因此对于 setLoading 来说,我们不再需要想任何办法来让他的引用来保持稳定。
setParam 跟预期一样,一点也不稳定,每次状态变化,他的引用都会发生变化。因为在定义它的时候,每次都是新生成的函数给他赋值。
return {
param,
+ setParam: (p: P) => param.current = p,
list,
error,
loading,
setLoading
}
此时到了 useCallback 大展身手的时候了,我们使用 useCallback 包一层。
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 的引用白考虑了。
<Button
className={s.button}
onClick={() => setLoading(true)}
>
然后你又看了一眼 setParam 的使用,还是这么个情况。
<input
className={s.input}
placeholder="请输入您要搜索的内容"
onChange={(e) => setParam(e.target.value)}
/>
最后一想,发现好像 useCallback 又做了无用功。
至此,你彻底悟了。
就说总有一种不确定感,原来少考虑了一步。当自定义 hook 传出来的 函数在执行时需要传入参数时,就不得不在这个函数外面包一层匿名函数,再传递给子组件使用,如果它不需要参数,useCallback 才会发挥它的效果。
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,
};
}
const {goBack} = useRouter()
...
<Child onBack={goBack} />
当真是真是步步惊心啊。
你终于悟到了要结合实际使用的场景去考虑使用 useCallback 的准确时机,自此,融汇贯通成就达成。