useMemo、useCallback、useContext 你真的玩明白了嗎
最近一直在做專案效能優化的工作,在排查效能方面的問題時發現專案中很多地方都存在重複渲染的問題,審查程式碼後發現其中存在不少濫用或者說誤用 useMemo、useCallback、useContext 的場景,導致了頁面的冗餘渲染。於是決定總結這一篇幫助小組內成員正確理解這三個 hook 的使用。
一、正確理解 useMemo、useCallback、memo 的使用場景
在我們平時的開發中很多情況下我們都在濫用 useMemo、useCallback這兩個 hook, 實際上很多情況下我們不需要甚至說是不應該使用,因為這兩個 hook 在首次 render 時需要做一些額外工作來提供快取,
同時既然要提供快取那必然需要額外的記憶體來進行快取,綜合來看這兩個 hook 其實並不利於頁面的首次渲染甚至會拖慢首次渲染,這也是我們常說的“不要在一開始就優化你的元件,出現問題的時候再優化也不遲”的根本原因。
那什麼時候應該使用呢,無非以下兩種情況:
- 快取 useEffect 的引用型別依賴;
- 快取子元件 props 中的引用型別。
1. 快取 useEffect 的引用型別依賴
js
import { useEffect } from 'react'
export default () => {
const msg = {
info: 'hello world',
}
useEffect(() => {
console.log('msg:', msg.info)
}, [msg])
}
此時 msg 是一個物件該物件作為了 useEffect 的依賴,這裡本意是 msg 變化的時候列印 msg 的資訊。但是實際上每次元件在render 的時候 msg 都會被重新建立,msg 的引用在每次 render 時都是不一樣的,所以這裡 useEffect 在每次render 的時候都會重新執行,和我們預期的不一樣,此時 useMemo 就可以派上用場了:
```js import { useEffect, useMemo } from "react"; const App = () => { const msg = useMemo(() => { return { info: "hello world", }; }, []); useEffect(() => { console.log("msg:", msg.info); }, [msg]); };
export default App; ```
同理對於函式作為依賴的情況,我們可以使用 useCallback:
```js import { useEffect, useCallback } from "react"; const App = (props) => { const print = useCallback(() => { console.log("msg", props.msg); }, [props.msg]); useEffect(() => { print(); }, [print]); };
export default App; ```
2. 快取子元件 props 中的引用型別。
做這一步的目的是為了防止元件非必要的重新渲染造成的效能消耗,所以首先要明確元件在什麼情況下會重新渲染。
- 元件的 props 或 state 變化會導致元件重新渲染
- 父元件的重新渲染會導致其子元件的重新渲染
這一步優化的目的是:在父元件中跟子元件沒有關係的狀態變更導致的重新渲染可以不渲染子元件,造成不必要的浪費。
大部分時候我們是明確知道這個目的的,但是很多時候卻並沒有達到目的,存在一定的誤區:
誤區一:
``` import { useCallback, useState } from "react";
const Child = (props) => {}; const App = () => { const handleChange = useCallback(() => {}, []); const [count, setCount] = useState(0); return ( <>
); };
export default App; ```
專案中有很多地方存在這樣的程式碼,實際上完全不起作用,因為只要父元件重新渲染,Child 元件也會跟著重新渲染,這裡的 useCallback 完全是白給的。
誤區二:
```js import { useCallback, useState, memo } from "react";
const Child = memo((props) => {}); const App = () => { const handleChange = () => {}; const [count, setCount] = useState(0); return ( <>
); };
export default App; ```
對於複雜的元件專案中會使用 memo 進行包裹,目的是為了對元件接受的 props 屬性進行淺比較來判斷元件要不要進行重新渲染。這當然是正確的做法,但是問題出在 props 屬性裡面有引用型別的情況,例如陣列、函式,如果像上面這個例子中這樣書寫,handleChange 在 App 元件每次重新渲染的時候都會重新建立生成,引用當然也是不一樣的,那麼勢必會造成 Child 元件重新渲染。所以這種寫法也是白給的。
正確姿勢:
``` import { useCallback, useState, memo, useMemo } from "react";
const Child = memo((props) => {}); const App = () => { const [count, setCount] = useState(0); const handleChange = useCallback(() => {}, []); const list = useMemo(() => { return []; }, []); return ( <>
); };
export default App;
``` 其實總結起來也很簡單,memo 是為了防止元件在 props 沒有變化時重新渲染,但是如果元件中存在類似於上面例子中的引用型別,還是那個原因每次渲染都會被重新建立,引用會改變,所以我們需要快取這些值保證引用不變,避免不必要的重複渲染。
二、useContext 使用注意事項
在專案中我們已經重度依賴於 useContext 這個 api,同時結合 useReducer 代替 redux 來做狀態管理,這也引入了一些問題。我們把官方Demo整合下,先來看看如何結合使用 useContext 和 useReducer。
```js import React, { createContext, useContext, useReducer } from "react";
const ContainerContext = createContext({ count: 0 }); const initialState = { count: 0 };
function reducer(state, action) { switch (action.type) { case "increment": return { count: state.count + 1 }; case "decrement": return { count: state.count - 1 }; default: throw new Error(); } }
function Counter() {
const { state, dispatch } = useContext(ContainerContext);
return (
<>
Count: {state.count}
);
}
function Tip() { return 計數器; }
function Container() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
export default Container; ``` 使用起來非常方便,乍一看似乎都挺美好的,但是其實有不少陷阱或者誤區在裡面。
useContext 的機制是使用這個 hook 的元件在 context 發生變化時都會重新渲染。這樣會導致一些問題,我把我遇到過的和能想到的問題總結到下面,如果有補充的可以再討論。
1. Provider 單獨封裝
在上面的 demo 中我們應該看到了在 Provider 中有兩個元件,Counter 元件在 state 發生變化的時候需要重新渲染這個沒什麼問題,那 Tip 元件呢,在 Tip 元件裡面顯然沒有用到 Context 實際上是沒有必要進行重新渲染的。但是現在這種寫法每次state變化都會導致 Provider 中所有的子元件都跟著渲染。有沒有什麼辦法解決呢,實際上也很簡單,我們把狀態管理單獨封裝到一個 Provider 元件裡面,然後把子元件通過 props.children 的方式傳進去
```js
...
function Provider(props) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
const App = () => {
return (
那這樣是不是就萬事大吉呢,對不起沒有,還有坑,接著看第二點。
2. 快取 Provider value
在官方文件裡面也提到了這個坑,簡單說就是,如果 Provider 元件還有父元件,當 Provider 的父元件進行重渲染時,Provider 的value 屬性每次渲染都會重新建立,原理和上面 useMemo useCallback 中提到的一樣,所以最好的辦法是對 value 進行快取:
js
...
function Provider(props) {
const [state, dispatch] = useReducer(reducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return (
<ContainerContext.Provider value={value}>
{props.children}
</ContainerContext.Provider>
);
}
...
3. memo 優化直接被穿透,不再起作用
在開發中我們會使用 memo 來對元件進行優化,如上文中提到的,但是很多時候我們又會在使用 memo 的元件中使用 context,用 context 的地方在context發生變化的時候無論如何都會發生重新渲染,所以很多時候會導致 memo 優化實效,具體可以看這裡的討論,react 官方解釋說設計如此,同時也給出了相應的建議,我們專案中主要解決方案是把 context 往上提,然後通過屬性傳遞,就是說我們的元件一開始是這樣寫的:
js
React.memo(()=> {
const {count} = useContext(ContainerContext);
return <span>{count}</span>
})
這個時候context更新了,memo 屬於是白給,我們把 context 往上提一層,其實就可以解決這個問題:
js
const Child = useMemo((props)=>{
....
})
function Parent() {
const {count} = useContext(ContainerContext);
return <Child count={count} />;
}
這樣保證了 Child 元件的外部狀態的變化只會來自於 props,這樣當然 memo 可以完美工作了。
4. 對 context 進行拆分整合
context 的使用場景應該是為一組享有公共狀態的元件提供便利來獲取狀態的變化。 但是隨著業務程式碼越來越複雜,在不經意間我們就會把一些不相關的資料放在同一個context 裡面。這樣就導致了context 中任何資料的變化都會導致使用這個 context 的元件重新 render。這顯然不是我們想看到的。這種情況下我們應該要對contex 進行更細粒度的拆分,把真正相關的資料整合在一起,然後再提供給元件,至少這樣不相關元件的狀態變化不會相互影響,也就不會導致多餘的重複渲染。
總結
不過話又說話來,寫個程式碼要注意這注意那,心智負擔確實也蠻重的,只能說“要說愛你不容易”,這些基礎 api 的使用給我們帶來便利的同時有時候也會讓我們感覺到難以控制,理解其中的內部渲染邏輯和api的設計初衷能幫助我們寫出更好的程式碼。