用useCallback优化React性能?这篇文章会让你改变看法
前言
本文将分为两部分,旨在全面介绍React中的useCallback hook。首先,我将结合官方文档来探讨是否应该在代码的每个地方都使用useCallback。其次,我将结合个人开发经验,介绍使用useCallback的一些业务场景。
你应该在所有地方添加 useCallback 吗?
api的详细介绍见官方文档:useCallback。
正如我们所了解的,useCallback的作用是用来优化React函数组件中的子组件渲染问题。但是,是否应该在所有地方都使用useCallback呢?官方文档给出了建议,我将在下面详细解读(官方文档将使用蓝框中引用标记)。
如果您的app类似于这个网站(react官网),并且大多数交互是粗粒度的(例如替换页面或整个模块),则记忆化(useCallback)通常无关紧要。另一方面,如果您的app更像绘图编辑器,大多数交互都是细粒度的(例如移动形状),那么记忆化(useCallback)可能非常有用。 使用 useCallback 缓存函数仅在少数情况下才具有价值:
- 如果您将函数作为prop传递给memo包装的组件,并且希望在值未更改时跳过重新渲染,那么使用记忆化可以帮助避免不必要的重新渲染。记忆化可以让组件在依赖项更改时重新渲染,从而提高性能。
这段话向我们说明了useCallback最主要的一个作用,那就是优化子组件的渲染问题。但是,同时也对子组件提出了一个要求,即必须使用memo进行包裹。那么为什么必须要使用memo包裹呢?我们可以通过下面的例子来了解。
import { useState } from 'react';
interface AProps {
a: string;
}
function A(props: AProps) {
console.log('a');
return <div>{props.a}</div>;
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestRender() {
const [id, setId] = useState(0);
return (
<div onClick={() => setId(id + 1)}>
<A a="a" />
<B />
</div>
);
}
每次点击时,我们会发现控制台都会打印a、b,这意味着A、B组件尽管传入的props中的属性值没有改变,但组件依然每次都重新渲染了。这是因为,React本身并没有对组件re-render做过多优化,而是赋予了我们优化re-render的能力。要解决上述问题,我们只需要给组件包上memo即可,如下所示:
import { useState, memo } from 'react';
interface AProps {
a: string;
}
const A = memo(function A(props: AProps) {
console.log('a');
return <div>{props.a}</div>;
});
const B = memo(function B() {
console.log('b');
return <div>b</div>;
});
export default function TestRender() {
const [id, setId] = useState(0);
return (
<div onClick={() => setId(id + 1)}>
<A a="a" />
<B />
</div>
);
}
从上面的示例中,我们可以看到,要完整优化子组件的渲染问题,除了使用useCallback,还要求子组件必须包裹memo才能生效。
我们再来看看react当中使用比较多的组件库antd,这里我随机挑了两个比较常用的组件table(源码见:github.com/ant-design/…),button(源码见:github.com/ant-design/…),它们均没有使用memo包裹,组件内的方法更没有使用useCallback做任何优化。所以当我们下次使用react组件时,传入组件的方法不用考虑使用useCallback包裹,因为这根本起不到任何性能优化的效果,反而影响性能。
那为什么antd中都没有使用useCallback来做子组件re-render的优化呢?这个问题我在文章结尾会给出,但是我相信你在读完全文应该也能自己得出答案。
其实,仅凭这一点,就注定了useCallback不会成为常用API。因为我们往往无法保证子组件是否已经使用memo进行了优化,而且当子组件的props中含有复杂的对象时,由于memo对props的比较是浅比较,我们需要使用memo的第二个参数来自定义比较逻辑。那么,如果不使用memo,还有其他的方案吗?当然有,那就是使用useMemo。对于上面的代码,我们可以做出如下修改来避免re-render:
import { useState, useMemo } from 'react';
interface AProps {
a: string;
}
function A(props: AProps) {
console.log('a');
return <div>{props.a}</div>;
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestRender() {
const [id, setId] = useState(0);
return useMemo(
() => (
<div onClick={() => setId(id + 1)}>
<A a="a" />
<B />
</div>
),
[]
);
}
通过使用useMemo来缓存整个组件的返回值,我们可以在不影响子组件的情况下,同样达到优化的目的。
提到这里,细心的读者可能已经发现了,为什么不直接使用useMemo来进行优化,而要使用useCallback和memo的组合呢?实际上,在大多数情况下,我们可以直接使用useMemo来进行优化。
- 如果您传递的函数被用作某个 Hook 的依赖,例如另一个使用useCallback包裹的函数依赖于它,或者您从useEffect中依赖于这个函数,那么使用useCallback可以确保Hook对函数的依赖不会在每次重新渲染时发生变化,从而避免不必要的Hook计算和渲染。
结合我个人开发经验,虽然确实会出现这样的情况,但这种情况并不是很常见。而且,大多数情况下,我们都有更好的解决方案。就像React官网所举的例子一样,如下所示:
// 不使用useCallback,我们就会频繁调用useEffect中的连接函数
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
// ...
// 使用useCallback解决上面代码的问题
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const createOptions = useCallback(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...
// 更优的方案,将函数放到useEffect中,依赖变为roomId
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() { // ✅ No need for useCallback or function dependencies!
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only changes when roomId changes
// ...
在其他情况下,将函数包裹在useCallback中并没有任何好处。虽然这样做也没有什么显著的坏处,但一些团队选择尽可能地对函数进行记忆化处理,而不考虑个别情况。缺点是代码变得不易读懂。此外,并非所有的记忆化都是有效的:一个“总是新的”单个值就足以打破整个组件的记忆化处理。因此,在使用useCallback进行记忆化处理时需要小心,仅在真正需要时使用,否则可能会带来意想不到的问题。
在官方文档中,针对useCallback的主要使用场景已经讲得很清楚了。即使我们将所有函数都使用useCallback包装,也不会有什么坏处,确实有一些团队这样做。但是,我并不完全认同这个观点。我更赞同的是在真正存在性能问题,已经影响用户使用体验时再进行优化,这符合“不要过早优化”的理念。此外,使用useCallback不仅会降低代码的可读性,对于一些React的新手开发者,很容易出现闭包问题。尽管我们可以使用react-hooks/exhaustive-deps插件来避免这些问题,但是useCallback的依赖项很多时,也很难达到优化的目的。
需要注意的是,useCallback并不能防止创建函数。在使用useCallback时,你总是在创建函数(这是正常的!),但React会忽略它,并在函数依赖项未更改时返回一个缓存的函数。换句话说,useCallback会在函数依赖项未更改的情况下返回之前缓存的函数,从而提高组件的性能。
这一点也值得我们注意:在使用useCallback时,每次都会创建一个新的函数。useCallback的作用是通过对比依赖项是否改变,来决定是返回新创建的函数还是缓存的函数。下面这种写法可能更容易理解useCallback的工作原理:
function Test() {
const clickCallbackParam = () => {
...
}
const handleClick = useCallback(clickCallbackParam, []);
// 上述写法等价于:const handleClick = useCallback(() => { ... }, [])
return <div onClick={handleClick}>test</div>;
}
实际上,您可以遵循以下几个原则来避免很多不必要的记忆化处理:
- 当一个组件在视觉上包装其他组件时,请让它接受JSX作为子组件。如果包装组件更新自己的状态,React就知道它的子组件不需要重新渲染。
例如,在以下场景中,当B组件作为A组件的子组件传入时,A组件的点击事件触发re-render时,不会触发B组件的re-render。
import React, { useState } from 'react';
function A(props: { children: React.ReactElement }) {
const [id, setId] = useState(0);
console.log('a');
return (
<div
onClick={() => {
setId(id + 1);
}}
>
{props.children}
</div>
);
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestChildren() {
return (
<A>
<B />
</A>
);
}
// 等价于如下写法,这里把children换成了comp字段,当我们需要有多个子组件,且渲染到不同位置时,可采用如下写法
function A(props: { comp: JSX.Element }) {
const [id, setId] = useState(0);
console.log('a');
return (
<div
onClick={() => {
setId(id + 1);
}}
>
{props.comp}
</div>
);
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestChildren() {
return <A comp={<B />} />;
}
在平时的业务开发中,我们也可能会看到以下写法,它们都会触发子组件的re-render。需要注意的是,这些写法并不好,我们尽量应该避免使用它们。在平时的业务开发中,我们也能看到如下的写法,它们都会触发子组件的re-render,注意:这都是不好的写法,尽量避免。
// 子组件作为渲染函数的方式
function A(props: { renderA: () => React.ReactElement }) {
const [id, setId] = useState(0);
console.log('a');
return (
<div
onClick={() => {
setId(id + 1);
}}
>
{props.renderA()}
</div>
);
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestChildren() {
return <A renderA={() => <B />} />;
}
// 直接传入组件构造函数,由父组件实例化
function A(props: { Comp: React.FunctionComponent }) {
const [id, setId] = useState(0);
console.log('a');
const Comp = props.Comp;
return (
<div
onClick={() => {
setId(id + 1);
}}
>
<Comp />
</div>
);
}
function B() {
console.log('b');
return <div>b</div>;
}
export default function TestChildren() {
return <A Comp={B} />;
}
- 更推荐使用本地状态,并避免将状态提升到不必要的高层次。不要将瞬态状态(例如表单和表单项是否悬停)保存在全局状态库中或组件树的顶层。
这一点可以看文章overreacted.io/zh-hans/bef…有详细解释,本文不详述
- 保持渲染逻辑的纯净。如果重新渲染组件导致问题或产生一些明显的视觉效果,则说明组件中存在问题。应该先修复这些问题,而不是通过添加记忆化来绕过它们
- 尽量避免不必要的更新状态的Effects。React应用程序中大多数性能问题都源于Effects的更新链,因为它们会导致组件反复渲染。
这一点之后我会单独写一篇文章介绍useEffect的正确使用
5.尽量减少Effects的依赖项。例如,可以将某些对象或函数移动到Effect内部或组件外部,而不是使用记忆化,这通常更加简单易行。 如果特定交互仍然感到不流畅,请使用React Developer Tools分析器,查看哪些组件最需要记忆化,并在必要时添加记忆化。这些原则使您的组件更易于调试和理解,因此在任何情况下都应该遵循它们。从长远来看,我们正在研究自动进行记忆化,以一劳永逸地解决这个问题。
这里所说的自动记忆化是指React的自动优化机制,也称为"React Forget"。通过编译手段,React可以替代我们做上面提到的memo和useCallback,从而让我们即使不使用memo和useCallback,也可以优化组件的重新渲染。更详细的信息可以参考::React without memo(注:负责的黄玄大佬已从meta离职…)
业务场景
函数防抖节流
一个简单的防抖函数如下所示
function debounce(fn, delay) {
var timer; // 维护一个 timer
return function () {
var _this = this; // 取debounce执行作用域的this
var args = arguments;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(function () {
fn.apply(_this, args); // 用apply指向调用debounce的对象,相当于_this.fn(args);
}, delay);
};
}
调用 debounce 函数时,它会返回一个新的函数。这个新函数在执行时,会通过闭包保存 debounce 函数中定义的局部变量 timer。每次调用这个新函数时,都会清除上一次的定时器,并重新设定新的定时器,从而保证每次函数被调用时,timer 变量的值都是最新的。这样就可以实现有效的防抖效果。
如果我们直接使用 debounce 函数而不是使用 useCallback 包裹的话,将不能起到函数防抖的作用,因为每次函数被调用时都会生成一个新的函数和一个新的 timer,这样就无法实现防抖效果。如下所示:
function Test() {
const handleMouseMove = debounce(() => {
...
}, 100)
return <div onMouseMove={handleMouseMove}>test</div>
}
而是应该使用useCallback包裹才行,这样每次返回的都是初始时创建的函数,从而达到函数防抖的作用
function Test() {
const handleMouseMove = useCallback(debounce(() => {
...
}, 100),[])
return <div onMouseMove={handleMouseMove}>test</div>
}
通用hook的封装
这里指的是,当我们封装一些通用性的hook供其他人使用时,最好使用 useCallback 包裹或其他方式生成记忆化函数来对外暴露。这样,其他人在使用你的hook进行性能优化时也可以有优化的空间。
以 ahooks 中的 useLockFn 为例,最终我们向用户暴露的函数是通过 useCallback 包裹的记忆化函数。
import { useRef, useCallback } from 'react';
function useLockFn<P extends any[] = any[], V extends any = any>(fn: (...args: P) => Promise<V>) {
const lockRef = useRef(false);
return useCallback(
async (...args: P) => {
if (lockRef.current) return;
lockRef.current = true;
try {
const ret = await fn(...args);
lockRef.current = false;
return ret;
} catch (e) {
lockRef.current = false;
throw e;
}
},
[fn],
);
}
export default useLockFn;
其它
除了上面提到的两个场景,我们还会遇到以下两个主要场景:
- 非常耗性能组件的re-render优化,或者单位时间内频繁触发子组件re-render并造成页面卡顿
- 有相互依赖的hook(即我们上面文章中react提到的第二个场景)
场景1的话,通常情况下我们使用useMemo替代useCallback来做优化会更加简单有效。
针对场景2,我的建议是使用useEffectEvent(原useEvent,详细见:react.dev/learn/separ…,react稳定版暂未发布,现在可使用ahooks的useMemoizedFn做替代,详细见ahooks.js.org/zh-CN/hooks…)。因为你会发现,当你的组件里面有很多hook,并且它们之间又存在很多依赖关系时,将会大大增加我们代码的阅读和维护成本。
总结
在总结之前,先给出上文问题的答案:为什么antd组件基本都没有使用memo+useCallback做优化?
我个人的一个理解是:antd中大部分组件都不是特别耗能的组件,组件多一次render,也就是多了一次Dom diff的时间,我们要相信js和浏览器的性能,通常情况下这段时间都不会很长。并且在React Concurrent Mode的加持下,Dom diff的优先级是低于用户行为的,一般也不会造成页面明显的卡顿。即使在使用antd组件时遇到了性能问题,一般情况下我们也可以通过外部使用useMemo来解决。
最后,结合我个人的开发经验,给出一个我对useCallback这个hook的使用总结:
一般业务场景下,我们都可以不使用useCallback,仅当页面出现卡顿时再考虑使用。
转载自:https://juejin.cn/post/7231940493256097852