自从 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​​ 的问题。


  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​​ 暂存某个状态的闭包的值,以供需求时调用。比如消息弹出框,需要弹出当时暂存的状态信息,而不是最新的信息。

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