React 性能優化:使用 memo、useCallback、useMemo

发布时间 2023-06-20 16:22:18作者: 随漫人

在寫網頁時,我們通常習慣把一個頁面切割成很多的元件 (Component) ,讓我們容易組織與管理頁面的組成。但是在 React 中複雜的元件關係,如果沒有經過優化,將有可能會造成性能上的問題。

在 Function Component 中,重新渲染 (re-render) 很輕易就會被觸發,少量的元件時還不會發生太大的問題,但是,如果遇到大型的網站平台,大量的元件不斷地被重新渲染,將會給瀏覽器重大的負擔,會造成使用者體驗不佳。

以下將介紹 memo 、 useMemo、 useCallback 這三種方法,這三種方法都是 React 提供用來減少不必要的元件重新渲染所造成的問題。

React.memo

我們經常會讓子元件依賴於父元件的狀態 (state) 或事件 (event),在父元件中宣告狀態與事件方法,並利用 props 將兩者傳遞到子元件中。

如果父元件的狀態被改變了,但是 props 的結果沒有變,子元件仍然會被重新渲染。可是子元件的結果根本沒有改變,多餘的渲染造成性能上的浪費。

所以,React 提供了 React.memo 來幫助我們解決這個的問題:

 1 const MyComponent = React.memo(function MyComponent(props) { 2 /* render using props */ 3 }); 

React.memo 是以 HOC (higher order component) 的方法使用,我們只要在需要減少渲染的元件外面再包一層 React.memo ,就可以讓 React 幫我們記住原本的 props。

Example

在範例中,可以改變父元件中的 input 綁定的狀態觸發重新渲染,可以看到使用 React.memo 的子元件在 props 都沒有改變的情況下不會觸發渲染,不會讓 refCount 的值遞增;反之,沒有使用 React.memo 的子元件在父元件渲染時,每次都被強迫重新渲染,refCount 不斷遞增。

https://codepen.io/Airwavess/embed/yLLRbNr?

然而,React.memo 是用 shallowly compare 的方法確認 props 的值是否一樣, shallowly compare 在 props 是 Number 或 String 比較的是數值當 props 是 Object 時,比較的是記憶體位置 (reference)。

因此,當父元件重新渲染時,在父元件宣告的 Object 都會被重新分配記憶體位址,所以想要利用 React.memo 防止重新渲染就會失效。

 1 const MyComponent = ({ myprops, someObject }) => {
 2   const refCount = React.useRef(0);
 3 
 4   refCount.current++;
 5 
 6   return (
 7     <p>
 8       {myprops}, Ref Count: {refCount.current}
 9     </p>
10   );
11 };
12 
13 const MemorizeMyComponent = React.memo(MyComponent);
14 
15 const App = () => {
16   const [state, setState] = React.useState("");
17   
18   const handleSetState = e => {
19     setState(e.target.value)
20   }
21 
22   const handleSomething = () => {};
23 
24   return (
25     <div className="App">
26       <input type="text" value={state} onChange={handleSetState}/>
27       <MyComponent
28         myprops="MyComponent"
29         someObject={handleSomething}
30       />
31       <MemorizeMyComponent
32         myprops="MemorizeMyComponent"
33         someObject={handleSomething}
34       />
35     </div>
36   );
37 };
38 //这里面每次传给子组件都是新的handleSomething函数
39 ReactDOM.render(<App />, document.getElementById("root"));

要解決這個問題的方法有兩種,第一種是 React.memo 提供了第二個參數,讓我們可以自訂比較 props 的方法,讓 Object 不再只是比較記憶體位置。

 1 function MyComponent(props) {
 2   /* render using props */
 3 }
 4 function areEqual(prevProps, nextProps) {
 5   /*
 6   return true if passing nextProps to render would return
 7   the same result as passing prevProps to render,
 8   otherwise return false
 9   */
10 }
11 export default React.memo(MyComponent, areEqual);

第二種方法則是 React.useCallback,讓 React 可以自動記住 Object 的記憶體位址,解決 shallowly compare 的比較問題。

接下來,我們就來看看 React.useCallback

React.useCallback

當父元件傳遞的 props 是 Object 時,父元件的狀態被改變觸發重新渲染,Object 的記憶體位址也會被重新分配。React.memo 會用 shallowly compare 比較 props 中 Object 的記憶體位址,這個比較方式會讓子元件被重新渲染。

因此,React 提供了 React.useCallback 這個方法讓 React 在元件重新渲染時,如果 dependencies array 中的值在沒有被修改的情況下,它會幫我們記住 Object,防止 Object 被重新分配記憶體位址。

所以,當 React.useCallback 能夠記住 Object 的記憶體位址,就可以避免父元件重新渲染後,Object 被重新分配記憶體位址,造成 React.memo 的 shallowly compare 發現傳遞的 Object 記憶體位址不同。

同樣地,當我們在父元件改變 input 綁定的狀態觸發重新渲染,使用 useCallback 記住記憶體位址的 function 就沒有讓子元件重新渲染,也沒有讓 refCount 遞增;反之,沒有被記住記憶體位址的 function 都使得子元件被父元件強迫重新渲染。

另外,React 在官方文件中有提到, every value referenced inside the callback should also appear in the dependencies array,因此在使用 useCallback 時必須注意 dependencies array 中是否都包含了在 useCallback 中使用的變數,否則可能會讓 useCallback 失效。

useCallback 用于 缓存函数。它接受一个回调函数,和一个依赖项。

在组件第一次渲染时,useCallback 将传入的回调函数缓存起来。后面重新渲染时,如果依赖项没有发生更新,useCallback 会返回缓存的函数;如果依赖项更新了,就更新缓存。

 1 const memoriedFn = useCallback(() => { console.log('我被缓存了') }, []); 

只有 useCallback:负优化

每次重新渲染时,函数组件中函数的声明依旧无法跳过,而造成消耗。

然后被声明的函数被传入到 useCallback 中,可能会用到,可能会被丢掉,得看依赖项是否被改动过。

使用缓存函数还会有一个问题:闭包陷阱。假如你这样写:

 1 const [count, setCount] = useState(0); 2 3 const onClick = useCallback(() => { 4 setCount(count + 1); 5 }, []); 

那这里的 count 永远只能加一次。因为 onClick 永远指向组件第一次渲染时生成的函数,这个函数所在的闭包的 count 变量永远是 0。

一套流程下来就是:使用了 useCallback,除了声明的函数,还要额外缓存一个函数,还会有闭包的陷阱。

所以说,只是简单使用 useCallback,带来的是负优化。

我们需要一个好兄弟帮帮忙:React.memo()

父组件

 1 import React, {useState, memo, useCallback} from "react";
 2 import CountSon from "./CountSon";
 3 
 4 const countries = [
 5     '中国',
 6     '美国',
 7     '日本',
 8     '德国',
 9     '法国',
10     '英国',
11     '韩国',
12     '意大利',
13     '加拿大',
14     '澳大利亚',
15     '巴西',
16     '印度',
17     '俄罗斯',
18     '墨西哥',
19     '荷兰',
20     '瑞典',
21     '丹麦',
22     '挪威',
23     '新加坡',
24     '南非'
25 ];
26 const CounterFunc = memo((props) => {
27 
28         const [count, setCount] = useState(0);
29         const [message, setMessage] = useState('尼加拉瓜');
30         const [a, b] = ['蔡洁', '姚子羚']
31         // const handle = (e) => {
32         //     setCount(count + 1);
33         //
34         // };
35 
36         const modifyCountry = () => {
37             const randomIndex = Math.floor(Math.random() * countries.length);
38             setMessage(countries[randomIndex]);
39         }
40 
41         const handle = useCallback((e) => {
42             setCount(count + 1);
43 
44         }, [count])
45         return (
46             <div>
47                 <h3>{count}</h3>
48                 <h3>{`我爱${a}我喜欢${b}`}</h3>
49                 <button onClick={handle}>加1</button>
50                 <h3>{message}</h3>
51                 <button onClick={modifyCountry}>修改信息</button>
52                 <CountSon handle={handle}/>
53             </div>
54         )
55     }
56 )
57 
58 export default CounterFunc

子组件:

 1 import React,{memo} from "react";
 2 
 3 
 4 const CountSon = memo(props=>{
 5     console.log('子组件被更新')
 6     const {handle} = props
 7     return (
 8         <div>
 9             <button onClick={handle}>子组件加</button>
10         </div>
11     )
12 })
13 
14 export default CountSon

 

通常情况下,父组件会传递给子组件一个方法让子组件去调用这个方法。如果在父组件执行一个方法,这个方法和传递的方法无关,通常,父组件重新执行函数,那么这个传入的方法也会被重新执行,从而导致子组件的props进行了更新那么,整个子组件也会更新。那么这时候给将要传入的子组件方法包裹一层useCallback的hook,那么函数重新执行的时候,里面的方法返回的还是之前的记忆值,从而不会导致props进行更新,进而子组件不会重新渲染。

基于上述的问题,我们进一步使得在count发生更新的时候,那个子组件也不再重新渲染,也就是那个父组件的函数不再更新,还是使用之前的函数。当count变化时也是使用同一个函数

方法1:将count依赖移除掉,缺点:闭包陷阱

方法2:使用useRef,在组件多次渲染时,返回的是同一个值,countRef是一个对象,这个对象是不会变得,对象存在一个内存地址中,这个内存地址是不变的,所以整个函数也就不会重新执行。

1  const countRef = useRef();
2     countRef.current = count;
3     const handle = useCallback((e) => {
4             setCount(countRef.current + 1);
5 
6         }, [])

方法3:将count依赖移除掉,setCount()传入一个函数 setCount(preCount=>preCount+1);使用之前的count值进行相加