放弃使用 useCallback 吧,我们有更好的方式
自从 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
函数会被重新创建。 每次 render
时 ChildComponent
参数中会接受一个新的 onClick
参数,这会直接击穿 React.memo
,导致性能优化失效,并联动一起 render
。
当然,官方文档指出,在组件内部中每次跟随 render
而重新创建函数的开销几乎可以忽略不计。若不将函数传给自组件,完全没有任何问题,而且开销更小。
接下来我们用 useCallback
包裹:
// ... function DemoComponent() { const handleClick = React.useCallback(() => { // 业务逻辑 }, []); return <ChildComponent onClick={handleClick} />; } 复制代码
这样 handleClick
就是 memoized 版本,依赖不变的话则永远返回第一次创建的函数。但每次 render
还是创建了一个新函数,只是没有使用罢了。 React.memo
与 PureComponent
类似,它们都会对传入组件的新旧数据进行 浅比较
,如果相同则不会触发渲染。
接下来我们在 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
的问题。
记忆效果差,依赖值变化则重新创建
想要记忆效果好,又是个闭包,无法获取最新值
上下文调用顺序的问题
组件卸载时获取最新 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
暂存某个状态的闭包的值,以供需求时调用。比如消息弹出框,需要弹出当时暂存的状态信息,而不是最新的信息。
最后,推荐一下我写的状态管理 heo
, useMethods
已经包含其中。后面会分享写 heo
库的动机,欢迎大家关注微信公众号 前端星辰
。
作者:MinJie
链接:https://juejin.cn/post/7026605205990932494