如何做 React 性能優(yōu)化?
大家好,我是前端西瓜哥。今天帶大家來學習如何做 React 性能優(yōu)化。
使用 React.memo()
一個組件可以通過 React.memo 方法得到一個添加了緩存功能的新組件。
const Comp = props => { //}const MemorizedComp = React.memo(Comp);
再次渲染時,如果 props 沒有發(fā)生改變,就跳過該組件的重渲染,以實現性能優(yōu)化。
這里的 關鍵在于 props 不能改變,這也是最惡心的地方。
對于像是字符串、數值這些基本類型,對比沒有問題。但對于對象類型,就要做一些緩存工作,讓原本沒有改變的對象或函數仍舊指向同一個內存對象。
因為每次函數組件被執(zhí)行時,里面聲明的函數都是一個全新的函數,和原來的函數指向不同的內存空間,全等比較結果是 false。
處理 props 比較問題
React.memo() 最疼痛的就是處理 props 比較問題。
我們看個例子:
const MemorizedSon = React.memo(({ onClick }) => { // ...})const Parent() { // ... const onClick = useCallback(() => { // 一些復雜的判斷和邏輯 }, [a, setA, b, onSava]); return ( <div> <MemorizedSon onClick={onClick} /> </div> )}
上面為了讓函數的引用不變,使用了 useCallback。函數里用到了一些變量,因為函數組件有閉包陷阱,可能會導致指向舊狀態(tài)問題,所以需要判斷這些變量是否變化,來決定是否使用緩存函數。
這里就出現了一個 連鎖反應,就是我還要給變量中的對象類型做緩存,比如這里的 setA 和 onSave 函數。然后這些函數可以又依賴其他函數,一直連鎖下去,然后你發(fā)現有些函數甚至來自其他組件,通過 props 注入。
啊我真的是麻了呀,我優(yōu)雅的 React 一下變得丑陋不堪。
怎么辦,一個方式是用 ref。ref 沒有閉包問題,且能夠在組件每次更新后保持原來的指向。
const MemorizedSon = React.memo(({ onClickRef }) => { const onClick = onClickRef.current;})const Parent() { // ... const onClick = () => { // 一些復雜的判斷和邏輯 }; const onClickRef = useRef(onClick); onClickRef.current = onClick; return ( <div> <MemorizedSon onClickRef={onClickRef} /> </div> )}
或者
const MemorizedSon = React.memo(({ onClick }) => { // ...})const Parent() { // ... const onClick = useCallback(() => { const {a, b} = propsRef.current; const {setA, setSave} = stateRef.current; // 一些復雜的判斷和邏輯 }, []); return ( <div> <MemorizedSon onClick={onClick} /> </div> )}
當然官方也注意到這種場景,提出了 useEvent 的提案,希望能盡快實裝吧。
function Chat() { const [text, setText] = useState(''); const onClick = useEvent(() => { sendMessage(text); }); return <SendButton onClick={onClick} />;}
The code inside useEvent “sees” the props/state values at the time of the call. The returned function has a stable identity even if the props/state it references change. There is no dependency array。
用了 useEvent 后,指向是穩(wěn)定的,不需要加依賴項數組。
提案詳情具體看下面這個鏈接:
https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md。
跳過中間組件
假設我們的組件嵌套是這樣的:A -> B -> C。
其中 C 需要拿到 A 的一個狀態(tài)。B 雖然不需要用到 A 的任何狀態(tài),但為了讓 C 拿到狀態(tài),所以也用 props 接收了這個,然后再傳給 C。
這樣的話,A 更新狀態(tài)時,B 也要進行不必要的重渲染。
對于這種情況,我們可以讓中間組件 B 跳過渲染:
- 給 B 應用 React.memo,A 的狀態(tài)不再傳給 B。
- A 的狀態(tài)通過發(fā)布訂閱的方式傳給 C(比如 useContext,或通過狀態(tài)管理)。
狀態(tài)下放
假設同樣還是 A -> B -> C 形式的組件嵌套。
C 需要來自 A 的狀態(tài),B 會幫忙通過 props 傳遞狀態(tài)過來。A 狀態(tài)更新時,A、B、C 都會重渲染。
如果狀態(tài)只有 C 一個組件會用到,我們可以考慮直接把狀態(tài)下放到 C。這樣當狀態(tài)更新時,就只會渲染 C。
組件提升
將組件提升到父組件的 props 上。
export default function App() { return ( <ColorPicker> <p>Hello, world!</p> <ExpensiveTree /> </ColorPicker> );}function ColorPicker({ children }) { let [color, setColor] = useState("red"); return ( <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> {children} </div> );}
在這里 ColorPicker 更新 color 狀態(tài)后,因為 ExpensiveTree 來自外部 props,不會改變,不會重渲染。除非是 App 中發(fā)生了狀態(tài)改變。
正確使用列表 key
進行列表渲染時,React 會要求你給它們提供 key,讓 React 識別更新后的位置變化,避免一些不必要的組件樹銷毀和重建工作。
比如你的第一個元素是 div,更新后發(fā)生了位置變化,第一個變成了 p。如果你不通過 key 告知新位置,React 就會將 div 下的整棵樹銷毀,然后構建 p 下的整棵樹,非常耗費性能。
如果你提供了位置,React 就會做真實 DOM 的位置移動,然后做樹的更新,而不是銷毀和重建。
注意狀態(tài)管理庫的觸發(fā)更新機制
對于使用 Redux 的進行狀態(tài)管理的朋友來說,我們會在函數組件中通過 useSelector 來訂閱狀態(tài)的變化,自動更新組件。
const Comp() { const count = useSelector(state => state.count);}
useSelector 做了什么事?它會訂閱 state 的變化,當 state 變化時,會運行回調函數得到返回值,和上一次的返回值進行全等比較。如果相等,不更新組件;如果不等,更新組件。然后緩存這次的返回值。
上面這種情況還好,我們再看看寫成對象的形式。
import { shallowEqual, useSelector } from 'react-redux'const Comp() { const { count, username } = useSelector(state => ({ count: state.count, username: state.username }), shallowEqual);}
上面這種寫法,因為默認用的是全等比較,所以每次 state 更新后比較結果都是 false,組件每次都更新。對于組合成的對象,你要用 shallowEqual 淺比較來替代全等比較,避免不必要的更新。
有一種情況比較特別,假設 state.userInfo 有多個屬性,username、age、acount、score、level 等。有些人會這樣寫:
const Comp() { const { username, age } = useSelector(state => state.userInfo), shallowEqual);}
看起來沒什么問題,但里面是有陷阱的:雖然我們的組件只用到 username 和 age,但 useSelector 卻會對整個 userInfo 對象做比較。
假設我們只更新了 userInfo.level,useSelector 的比較結果就為 false 了,導致組件更新,即使你沒有用上 level,這不是我們期望的。
所以正確的寫法應該是:
const Comp() { const { username, age } = useSelector(state => { const { username, age } = state.userInfo; return { username, age }; }), shallowEqual);}
使用 useSelector 監(jiān)聽狀態(tài)變化,一定要關注 state 的粒度問題。
Context 是粗粒度的
React 提供的 Context 的粒度是粗粒度的。
當 Context 的值變化時,用到該 Context 的組件就會更新。
有個問題,就是 我們提供的 Context 值通常都是一個對象,比如:
const App = () => { return ( <EditorContext.Provider value={ visible, setVisible }> <Editor /> </EditorContext.Provider> );}
每當 Context 的 value 變化時,用到這個 Context 的組件都會被更新,即使你只是用這個 value 的其中一個屬性,且它沒有改變。
因為 Context 是粗粒度的。
所以你或許可以考慮在高一些層級的組件去獲取 Context,然后通過 props 分別注入到用到 Context 的不同部分的組件中。
順便一提,Context 的 value 在必要時也要做緩存,以防止組件的無意義更新。
const App = () => { const EditorContextVal = useMemo(() => ({ visible, setVisible }), [visible, setVisible]); return ( <EditorContext.Provider value={ visible, setVisible }> <Editor /> </EditorContext.Provider> );}
批量更新
有一個經典的問題是:React 的 setState 是同步還是異步的?
答案是副作用或合成事件響應函數內,是異步的,會批量執(zhí)行。其他情況(比如 setTimeout)則會同步執(zhí)行,同步的問題是會立即進行組件更新渲染,一次有多個同步 setState 就可能會有性能問題。
我們可以用ReactDOM.unstable_batchedUpdates 來將本來需要同步執(zhí)行的狀態(tài)更新變成批量的。
ReactDOM.unstable_batchedUpdates(() => { setScore(score + 1); setUserName('前端西瓜哥');})
不過到了 React18 后,開啟并發(fā)模式的話,就沒有同步問題了,所有的 setState 都是異步的。
Redux 的話,你可以考慮使用批量更新插件:redux-batched-actions。
import { batchActions } from 'redux-batched-actions';dispatch(batchActions([ setScoreAction(score + 1), setUserName('前端西瓜哥')]));
redux-batched-actions 中間件確實會將多個 actions 做一個打包組合再 dispatch,你會發(fā)現 store.subscribe 的回調函數觸發(fā)次數確實變少了。
但如果你用了 react-redux 庫的話,這個庫其實在多數情況下并沒有什么用。
因為 react-redux 其實已經幫我們做了批量處理操作,同步的多個 dispatch 執(zhí)行完后,才會通知組件進行重渲染。
懶加載
有些組件,如果可以的話,可以讓組件直接不渲染,做一個懶加載。比如:
{visible && <Model />}
結尾
React 的優(yōu)化門道還是挺多的,其中的 React.memo 優(yōu)化起來確實復雜,一不小心還會整成負優(yōu)化。
所以,不要 過早進行優(yōu)化。