理解和使用 React.memo / useCallback / useMemo 可以进行性能优化
首先
以下是关于React(v16.12.0)的React.memo、useCallback和useMemo的基本用法和适用场景的备忘录。
-
- 「React でのパフォーマンス最適化の手段を知りたい」
- 「なぜReact.memo、useCallback、useMemoを利用するのかわからない」
這是為了這樣的人所寫的文章。
这个示范放在 CodeSandbox 上。编辑并确认运作情况后,我认为你会更深刻地理解。
该文章所使用的术语
-
- メモ化
- 計算結果
记忆化
保留计算结果并再次利用的方法。
我认为可以将其想象为类似于缓存的东西。
因此,以下这些词语的意思大致相同。
-
- 「メモ化された値」=「計算結果が保持された値」
- 「メモ化する」=「計算結果を再利用できるように保持する」
由于使用了记忆化技术,不再需要每次进行计算,因此可以期望性能的提升。
計算的結果
以下是计算的结果之类的。
// result は 1 + 2 の計算結果を格納している変数
const result = 1 + 2;
// result2 は [1, 2, 3, 4, 5].map(number => number * 2) の計算結果を格納している変数
const result2 = [1, 2, 3, 4, 5].map(number => number * 2);
// result3 は React.createElement("div", null, `Hello ${this.props.name}`) の計算結果を格納している変数
const result3 = React.createElement("div", null, `Hello ${this.props.name}`);
在 React 中进行性能优化。
在React中,抑制不必要的重新计算和组件重新渲染是基本的性能优化策略。
使用React.memo、useCallback和useMemo作为实现这些的手段。
无论是在React之外的性能调优,还是在其他方面,测量都是必不可少的。
在没有意义的情况下,随意使用并不能提高性能,有时甚至没有意义,所以请注意。
React.memo是React中的一个高阶组件。
React 的 API(方法),用于对组件(组件渲染结果)进行记忆化。
通过对组件进行记忆化处理,可以跳过组件的重新渲染。
为什么要使用 React.memo?
通过跳过重新渲染类似的组件,可以期待提高性能。
-
- レンダリングコストが高いコンポーネント
- 頻繁に再レンダリングされるコンポーネント内の子コンポーネント
对于常规组件,没必要费心使用React.memo。
React.memo 的语法
React.memo(コンポーネント);
比如,如果要对名为Hello的组件进行记忆化,可以按照如下方式进行。
const Hello = React.memo(props => {
return <h1>Hello {props.name}</h1>;
});
React.memo会检查Props的等价性(即值是否相等)来判断是否重新渲染。
对比新传递的 Props 和上次的 Props ,若它们相等,则重用已经进行了记忆化的组件,而不进行重新渲染。
因此,在上述的Hello组件中,只要props.name不被更新,该组件就不会重新渲染。
React.memo 的使用示例
尝试比较一下使用 React.memo 和不使用 React.memo 有哪些区别。
如果不使用React.memo
一般来说,当组件的状态更新时,该组件将重新渲染。
import React, { useState } from "react";
const Child = props => {
console.log("render Child");
return <p>Child: {props.count}</p>;
};
export default function App() {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />
</>
);
}
这是正常的行为,这样写没有问题也没有错误。
如果遇到组件不必要的重新渲染导致性能问题的情况,可以考虑使用React.memo来解决。
由于本次情况下,即使Child组件总是重新渲染也没有问题,因此无需使用React.memo。
如果您使用React.memo
import React, { useState } from "react";
const Child = React.memo(props => {
console.log("render Child");
return <p>Child: {props.count}</p>;
});
export default function App() {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />
</>
);
}
当更新计数1并重新渲染App组件时,传递给子组件Child的属性count2不会更新,因此跳过了重新渲染过程。
只有当传递给子组件的count2更新时,才会重新渲染。
缓存高成本的渲染组件。
import React, { useState } from "react";
const Child = React.memo(props => {
let i = 0;
while (i < 1000000000) i++;
console.log("render Child");
return <p>Child: {props.count}</p>;
});
export default function App() {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />
</>
);
}
对于频繁重新渲染的组件内的子组件进行记忆化处理。
import React, { useState, useEffect, useRef } from "react";
const Child = React.memo(() => {
console.log("render Child");
return <p>Child</p>;
});
export default function App() {
console.log("render App");
const [timeLeft, setTimeLeft] = useState(100);
const timerRef = useRef(null);
const timeLeftRef = useRef(timeLeft);
useEffect(() => {
timeLeftRef.current = timeLeft;
}, [timeLeft]);
const tick = () => {
if (timeLeftRef.current === 0) {
clearInterval(timerRef.current);
return;
}
setTimeLeft(prevTime => prevTime - 1);
};
const start = () => {
timerRef.current = setInterval(tick, 10);
};
const reset = () => {
clearInterval(timerRef.current);
setTimeLeft(100);
};
return (
<>
<button onClick={start}>start</button>
<button onClick={reset}>reset</button>
<p>App: {timeLeft}</p>
<Child />
</>
);
}
接收 Props 作为回调函数的组件必定会触发重新渲染。
import React, { useState } from "react";
const Child = React.memo(props => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
});
export default function App() {
console.log("render App");
const [count, setCount] = useState(0);
// 関数はコンポーネントが再レンダリングされる度に再生成されるため、
// 関数の内容が同じでも、新しい handleClick と前回の handleClick は
// 異なるオブジェクトなので、等価ではない。
// そのため、コンポーネントが再レンダリングされる。
const handleClick = () => {
console.log("click");
};
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
}
如果引用不同的函数,则会成为另一个对象。
function doSomething() {
console.log("doSomething");
}
const func1 = doSomething;
const func2 = doSomething;
console.log(doSomething === doSomething); // true
console.log(func1 === func2); // true
const func3 = () => {
console.log("doSomething");
};
const func4 = () => {
console.log("doSomething");
};
console.log(func3 === func4); // false
由于App组件每次重新渲染时,前述的handleClick所引用的函数也会被重新生成,因此它们并不是等价的。
因此,即使函数内容相同,Child组件也会重新渲染。
为了解决这个问题,我们需要使用useCallback来对函数进行记忆化。
使用回调函数
返回一个存储了回调函数的钩子。
为什么要使用 useCallback?
由于可以与React.memo同时使用,因此可以跳过组件的不必要重新渲染。
更具體地說,通過將使用useCallback進行記憶化的回調函數作為Props傳遞給使用React.memo進行記憶化的組件,可以跳過組件的不必要重新渲染。
使用 useCallback 的语法。
useCallback(コールバック関数, 依存配列);
依存数组指的是存储了回调函数所依赖的元素的数组。
如果想要将一个记忆化函数应用在需要在console.log中输出变量count的函数中,可以按照以下方式实现。
const callback = useCallback(() => console.log(count), [count]);
如果依存元素更新,函数将被重新生成。
如果没有任何依赖元素,那么依赖数组可以为空。
const callback = useCallback(() => console.log("doSomething"), []);
使用 useCallback 的示例
import React, { useState, useCallback } from "react";
const Child = React.memo(props => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
});
export default function App() {
console.log("render App");
const [count, setCount] = useState(0);
// 関数をメモ化すれば、新しい handleClick と前回の handleClick は
// 等価になる。そのため、Child コンポーネントは再レンダリングされない。
const handleClick = useCallback(() => {
console.log("click");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
}
useCallback 的注意事项
由于useCallback必须与React.memo一起使用,因此以下用法是无意义的(无法跳过组件的不必要重新渲染),请注意。
React.memoでメモ化をしていないコンポーネントにuseCallbackでメモ化をしたコールバック関数を渡す
useCallbackでメモ化したコールバック関数を、それを生成したコンポーネント自身で利用する
将未使用React.memo进行记忆化的组件,通过useCallback传递记忆化的回调函数。
即使向未进行记忆化的组件传递了一个记忆化的回调函数,该组件仍然会被不断重新渲染。
import React, { useState, useCallback } from "react";
// React.memo でメモ化をしていないコンポーネントのため、メモ化されたコールバック関数を渡されても意味がない。
// App コンポーネントがレンダリングされる度に再レンダリングされる。
const Child = props => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
};
export default function App() {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("click");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
}
在生成了回调函数的组件本身中使用使用useCallback进行记忆的回调函数。
在下面的例子中,App组件自身使用了经过记忆化的回调函数。
虽然进行了操作,但并未达成“跳过组件重新渲染”的目标。
import React, { useState, useCallback } from "react";
export default function App() {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("memonized callback");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<button onClick={handleClick}>logging</button>
</>
);
}
使用useMemo
返回记忆化的值的钩子函数。
在组件重新渲染时可以重新使用值。
为什么要使用 useMemo?
因此可以期望通过跳过不需要的重新计算来提高性能。
useMemo 的语法
useMemo(() => 値を計算するロジック, 依存配列);
依存数组是一个存储有计算逻辑依赖的元素(用于计算值所需的元素)的数组。
如果你想要将一个名为”count”的变量的值加倍并进行缓存,可以按照以下方式进行操作。
const result = useMemo(() => count * 2, [count]);
如果依赖的元素被更新,那么值将被重新计算。
useMemo 的应用示例
比较使用useMemo和不使用useMemo的区别。
如果不使用 useMemo
import React, { useState } from "react";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 引数の数値を2倍にして返す。
// 不要なループを実行しているため計算にかなりの時間がかかる。
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// count2 を2倍にした値
// double(count2) はコンポーネントが再レンダリングされる度に実行されるため、
// count1 を更新してコンポーネントが再レンダリングされた時にも実行されてしまう。
// そのため、count1 を更新してコンポーネントを再レンダリングする時も時間がかかる。
// count1 を更新しても doubledCount の値は変わらないため、count1 を更新した時に
// double(count2) を実行する意味がない。したがって、不要な再計算が発生している状態である。
// count1 が更新されてコンポーネントが再レンダリングされた時は double(count2) が実行されないようにしたい。
const doubledCount = double(count2);
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
<p>
Counter: {count2}, {doubledCount}
</p>
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
当更新 count1 时,由于 double(count2) 会被执行,因此在更新 count1 并重新渲染组件时,会花费时间。
如果使用useMemo的话。
import React, { useState, useMemo } from "react";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 引数の数値を2倍にして返す。
// 不要なループを実行しているため計算にかなりの時間がかかる。
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// count2 を2倍にした値をメモ化する。
// 第2引数に count2 を渡しているため、count2 が更新された時だけ値が再計算される。
// count1 が更新され、コンポーネントが再レンダリングされた時はメモ化した値を利用するため再計算されない。
const doubledCount = useMemo(() => double(count2), [count2]);
return (
<>
<h2>Increment(fast)</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment(fast)</button>
<h2>Increment(slow)</h2>
<p>
Counter: {count2}, {doubledCount}
</p>
<button onClick={() => setCount2(count2 + 1)}>Increment(slow)</button>
</>
);
}
通过使用useMemo,将值进行了记忆化处理,因此在更新count1时,不会再执行double(count2)。
因此,当更新count1时,组件的重新渲染变得更加快速。
跳过组件的重新渲染
由于useMemo可以对渲染结果进行记忆,因此可以像React.memo一样跳过组件的重新渲染。
import React, { useState, useMemo } from "react";
export default function App() {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 引数の数値を2倍にして返す。
// 無駄なループを実行しているため計算にかなりの時間がかかる。
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// レンダリング結果(計算結果)をメモ化する
// 第2引数に count2 を渡しているため、count2 が更新された時だけ再レンダリングされる。
// count1 が更新され、コンポーネントが再レンダリングされた時はメモ化したレンダリング結果を
// 利用するため再レンダリングされない。
const Counter = useMemo(() => {
console.log("render Counter");
const doubledCount = double(count2);
return (
<p>
Counter: {count2}, {doubledCount}
</p>
);
}, [count2]);
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
{Counter}
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
如果在函数组件中想要对组件进行记忆,可以使用useMemo。
import React, { useState } from "react";
export default function App() {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 引数の数値を2倍にして返す。
// 無駄なループを実行しているため計算にかなりの時間がかかる。
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// App コンポーネントが再レンダリングされたら
// このコンポーネントも必ず再レンダリングされる
const Counter = React.memo(props => {
console.log("render Counter");
const doubledCount = double(props.count2);
return (
<p>
Counter: {props.count2}, {doubledCount}
</p>
);
});
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
<Counter count2={count2} />
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
为了防止函数的重新生成,应该使用useCallback吗?
有些人可能会想,如果 useMemo 用于避免重新计算,那么使用 useCallback 也可以用于避免函数的重新生成,这是有意义的吗?
然而,我认为利用它作为目的是不合理的。
因为我认为“函数重新生成的成本永远大于useCallback的执行成本”。
无论是useCallback还是useMemo,对于进行记忆化处理本身都有成本。
在使用 useMemo 的情况下
对于 useMemo,我意识到有时候 “重新计算的成本 < useMemo的执行成本”,但也有时候 “重新计算的成本 > useMemo的执行成本”。
這是一個極端的例子,以下是「重新計算成本 < 使用useMemo的執行成本」的實例。
const result = useMemo(() => value * 2, [value]);
因为value * 2是一个简单的计算,所以使用useMemo也没有效果。
我认为,在上述情况下使用useMemo可能是不合理的,因为实际上useMemo的执行成本可能更高。
而且,以下是一个关于“重新计算的成本 > useMemo执行成本”的例子。
const result = useMemo(() => {
let i = 0;
while (i < 1000000000) i++;
count * 2;
}, [value]);
在这种情况下,显然重新计算的成本更高,因此使用useMemo可以获得显著的效果。
当使用 useCallback 时。
以下是一个例子,表达的含义是“重新生成函数的成本小于执行useCallback的成本”。
const handleClick = useCallback(() => {
console.log(value);
}, [value]);
我认为与前面提到的useMemo类似,没有必要特意使用它。
在这种情况下,我无法想象出“函数重新生成的成本大于useCallback的执行成本”的情况。
// ?
所以,我认为使用useCallback来防止函数重新生成不合理且没有必要。
由于没有进行严格的测量,所以这种认识可能是错误的。
如果我对此的理解是错误的(即存在情况「重新生成函数的成本 > 使用useCallback的执行成本」,或者有其他用途),请您带上具体示例(代码),并提供意见和指导,这将非常有帮助。
需要正确指定依存数组。
如果不正确地指定useCallback和useMemo的依赖数组,会导致错误的bug。
因此,以下的代码是错误的。
// 依存要素である count2 が依存配列にないため NG
const result = useMemo(() => count * count2, [count]);
// これが正しい
// const result = useMemo(() => count * count2, [count, count2]);
因此,使用eslint-plugin-react-hooks等工具来确保进行Lint检查。
用途
通过测量性能,并将其应用于成为瓶颈的部分,是最有效的方法。
尽管如此,在GitHub上检查了一下代码和技术书,发现大量使用。
只要对各个功能和角色有透彻的理解,即使不设定严格的使用标准,我认为积极利用也不会带来大问题。
结束
这次准备的演示虽然是极端的例子,但根据使用场景来看,它是非常有用的功能。
根据情况来使用。
如果你对React感兴趣,我还写了一些与之相关的文章,希望你也能看一看。
- React の Context の更新による不要な再レンダリングを防ぐ 〜useContext を利用時に発生する不要な再レンダリングを防ぐ方法に関して〜
消息
我在Udemy上发布了有关webpack的课程,还在Kindle上出版了技术书籍。
Udemy: webpack 速成课程(原价10,800円,现价2,000円)
Kindle(如果是Kindle Unlimited,那就是免费的):
React Hooks初级指南(500日元)
如果您对此感到兴趣并购买的话,我会非常高兴。谢谢!