從源碼中來,到業(yè)務(wù)中去,React性能優(yōu)化終極指南
前言
React性能優(yōu)化是在業(yè)務(wù)迭代過程中不得不考慮的問題,大部分情況是由于項(xiàng)目啟動(dòng)之初,沒有充分考慮到項(xiàng)目的復(fù)雜度,定位該產(chǎn)品的用戶體量及技術(shù)場景并不復(fù)雜,那么我們在業(yè)務(wù)前期可能并不需要考慮性能優(yōu)化。但是隨著業(yè)務(wù)場景的復(fù)雜化,性能優(yōu)化就變得格外重要。
我們從React源碼入手,結(jié)合有道精品課大前端的具體業(yè)務(wù),運(yùn)用優(yōu)化技巧對系統(tǒng)進(jìn)行外科手術(shù)式的優(yōu)化。同時(shí)介紹一下React Profiler這款性能優(yōu)化的利器是如何幫我們定位性能瓶頸的。
本文中的項(xiàng)目代碼全部是在有道大前端組開發(fā)項(xiàng)目中的工作記錄,如有不足歡迎在留言區(qū)討論交流,筆芯❤
頁面加載流程
-
假設(shè)用戶首次打開頁面(無緩存),這個(gè)時(shí)候頁面是完全空白的;
-
html 和引用的 css 加載完畢,瀏覽器進(jìn)行 首次渲染 ;
-
react、react-dom、業(yè)務(wù)代碼加載完畢,應(yīng)用第一次渲染,或者說 首次內(nèi)容渲染 ;
-
應(yīng)用的代碼開始執(zhí)行,拉取數(shù)據(jù)、進(jìn)行動(dòng)態(tài)import、響應(yīng)事件等等,完畢后頁面進(jìn)入 可交互 狀態(tài);
-
接下來 lazyload 的圖片等多媒體內(nèi)容開始逐漸加載完畢;
-
直到頁面的其它資源(如錯(cuò)誤上報(bào)組件、打點(diǎn)上報(bào)組件等)加載完畢,整個(gè)頁面加載完成。
我們主要來針對React進(jìn)行剖析:
React 針對渲染性能優(yōu)化的三個(gè)方向,也適用于其他軟件開發(fā)領(lǐng)域,這三個(gè)方向分別是:
-
減少計(jì)算的量:React 中就是減少渲染的節(jié)點(diǎn)或通過索引減少渲染復(fù)雜度;
-
利用緩存:React 中就是避免重新渲染(利用 memo 方式來避免組件重新渲染);
-
精確重新計(jì)算的范圍:React 中就是綁定組件和狀態(tài)關(guān)系, 精確判斷更新的'時(shí)機(jī)'和'范圍'. 只重新渲染變更的組件(減少渲染范圍)。
如何做到這三點(diǎn)呢?我們從React本身的特性入手分析。
React 工作流
React 是聲明式 UI 庫,負(fù)責(zé)將 State 轉(zhuǎn)換為頁面結(jié)構(gòu)(虛擬 DOM 結(jié)構(gòu))后,再轉(zhuǎn)換成真實(shí) DOM 結(jié)構(gòu),交給瀏覽器渲染。State 發(fā)生改變時(shí),React 會(huì)先進(jìn)行Reconciliation,結(jié)束后立刻進(jìn)入Commit階段,Commit結(jié)束后,新 State 對應(yīng)的頁面才被展示出來。
React 的 Reconciliation 需要做兩件事:
-
計(jì)算出目標(biāo) State 對應(yīng)的虛擬 DOM 結(jié)構(gòu)。
-
尋找「將虛擬 DOM 結(jié)構(gòu)修改為目標(biāo)虛擬 DOM 結(jié)構(gòu)」的最優(yōu)方案。
React 按照深度優(yōu)先遍歷虛擬 DOM 樹的方式,在一個(gè)虛擬 DOM 上完成Render和Diff的計(jì)算后,再計(jì)算下一個(gè)虛擬 DOM。Diff 算法會(huì)記錄虛擬 DOM 的更新方式(如:Update、Mount、Unmount),為Commit做準(zhǔn)備。
React 的 Commit 也需要做兩件事:
-
將Reconciliation結(jié)果應(yīng)用到 DOM 中。
-
調(diào)用暴露的hooks如:componentDidUpdate、useLayoutEffect 等。
下面我們將針對三個(gè)優(yōu)化方向進(jìn)行精準(zhǔn)分析。
01 減少計(jì)算的量
關(guān)于以上 Reconciliation 與 Commit 兩個(gè)階段的優(yōu)化辦法,我在實(shí)現(xiàn)的過程中遵循 減少計(jì)算量 的方法進(jìn)行優(yōu)化( 列表項(xiàng)使用 key 屬性 )該過程是優(yōu)化的重點(diǎn),React 內(nèi)部的 Fiber 結(jié)構(gòu)和并發(fā)模式也是在減少該過程的耗時(shí)阻塞。對于 Commit 在執(zhí)行hooks時(shí),開發(fā)者應(yīng)保證hooks中的代碼盡量輕量,避免耗時(shí)阻塞,同時(shí)應(yīng)避免在 CDM、CDU周期 中更新組件。
列表項(xiàng)使用 key 屬性
特定框架中,提示也做的十分友好。假如你沒有在列表中添加key屬性,控制臺(tái)會(huì)為你展示一片大紅
系統(tǒng)會(huì)時(shí)刻提醒你記得加Key哦~~
1.1 優(yōu)化Render 過程
Render 過程:即 Reconciliation 中計(jì)算出目標(biāo) State 對應(yīng)的虛擬 DOM 結(jié)構(gòu)這一階段 。
觸發(fā) React 組件的 Render 過程目前有三種方式:
-
forceUpdate、
-
State 更新、
-
父組件 Render 觸發(fā)子組件 Render 過程。
●優(yōu)化技巧
PureComponent、React.memo
在 React 工作流中,如果只有父組件發(fā)生狀態(tài)更新,即使父組件傳給子組件的所有 Props 都沒有修改,也會(huì)引起子組件的 Render 過程。
從 React 的聲明式設(shè)計(jì)理念來看,如果子組件的 Props 和 State 都沒有改變,那么其生成的 DOM 結(jié)構(gòu)和副作用也不應(yīng)該發(fā)生改變。當(dāng)子組件符合聲明式設(shè)計(jì)理念時(shí),就可以忽略子組件本次的 Render 過程。
PureComponent 和 React.memo 就是應(yīng)對這種場景的,PureComponent 是對類組件的 Props 和 State 進(jìn)行淺比較,React.memo 是對函數(shù)組件的 Props 進(jìn)行淺比較。
-
useMemo、useCallback 實(shí)現(xiàn)穩(wěn)定的 Props 值
如果傳給子組件的派生狀態(tài)或函數(shù),每次都是新的引用,那么 PureComponent 和 React.memo 優(yōu)化就會(huì)失效。所以需要使用 useMemo 和 useCallback 來生成穩(wěn)定值,并結(jié)合 PureComponent 或 React.memo 避免子組件重新 Render。
-
useMemo 減少組件 Render 過程耗時(shí)
useMemo 是一種緩存機(jī)制提速,當(dāng)它的依賴未發(fā)生改變時(shí),就不會(huì)觸發(fā)重新計(jì)算。一般用在「計(jì)算派生狀態(tài)的代碼」非常耗時(shí)的場景中,如:遍歷大列表做統(tǒng)計(jì)信息。
大列表渲染
-
顯然 useMemo 的作用是緩存昂貴的計(jì)算(避免在每次渲染時(shí)都進(jìn)行高開銷的計(jì)算),在業(yè)務(wù)中使用它去控制變量來更新表格
shouldComponentUpdate
在類組件中,例如要往數(shù)組中添加一項(xiàng)數(shù)據(jù)時(shí),當(dāng)時(shí)的代碼很可能是 state.push(item),而不是 const newState = [...state, item]。
在此背景下,當(dāng)時(shí)的開發(fā)者經(jīng)常使用 shouldComponentUpdate 來深比較 Props,只在 Props 有修改才執(zhí)行組件的 Render 過程。如今由于數(shù)據(jù)不可變性和函數(shù)組件的流行,這樣的優(yōu)化場景已經(jīng)不會(huì)再出現(xiàn)了。
為了貼合shouldComponentUpdate的思想:給子組件傳props的時(shí)候一定只傳其需要的而并非一股腦全部傳入:
傳入到子組件的參數(shù)一定保證其在自組件中被使用到。
1.2 批量更新,減少 Render 次數(shù)
在 React 管理的事件回調(diào)和生命周期中,setState 是異步的,而其他時(shí)候 setState 都是同步的。這個(gè)問題根本原因就是 React 在自己管理的事件回調(diào)和生命周期中,對于 setState 是批量更新的,而在其他時(shí)候是立即更新的。
批量更新 setState 時(shí),多次執(zhí)行 setState 只會(huì)觸發(fā)一次 Render 過程。相反在立即更新 setState 時(shí),每次 setState 都會(huì)觸發(fā)一次 Render 過程,就存在性能影響。
假設(shè)有如下組件代碼,該組件在 getData() 的 API 請求結(jié)果返回后,分別更新了兩個(gè) State 。
該組件會(huì)在 setList(data.list) 后觸發(fā)組件的 Render 過程,然后在 setInfo(data.info) 后再次觸發(fā) Render 過程,造成性能損失。那我們該如何解決呢:
-
將多個(gè) State 合并為單個(gè) State。例如 useState({ list: null, info: null }) 替代 list 和 info 兩個(gè) State。
-
使用 React 官方提供的 unstable_batchedUpdates 方法,將多次 setState 封裝到 unstable_batchedUpdates 回調(diào)中。
修改后代碼如下:
02 精細(xì)化渲染階段
2.1 按優(yōu)先級(jí)更新,及時(shí)響應(yīng)用戶
優(yōu)先級(jí)更新是批量更新的逆向操作,其思想是:優(yōu)先響應(yīng)用戶行為,再完成耗時(shí)操作。常見的場景是:頁面彈出一個(gè) Modal,當(dāng)用戶點(diǎn)擊 Modal 中的確定按鈕后,代碼將執(zhí)行兩個(gè)操作:
-
關(guān)閉 Modal。
-
頁面處理 Modal 傳回的數(shù)據(jù)并展示給用戶。
當(dāng)操作2需要執(zhí)行500ms時(shí),用戶會(huì)明顯感覺到從點(diǎn)擊按鈕到 Modal 被關(guān)閉之間的延遲。
以下為一般的實(shí)現(xiàn)方式,將 slowHandle 函數(shù)作為用戶點(diǎn)擊按鈕的回調(diào)函數(shù)。
非延遲執(zhí)行
slowHandle() 執(zhí)行過程耗時(shí)長,用戶點(diǎn)擊按鈕后會(huì)明顯感覺到頁面卡頓。
如果讓頁面優(yōu)先隱藏輸入框,用戶便能立刻感知到頁面更新,不會(huì)有卡頓感。
實(shí)現(xiàn)優(yōu)先級(jí)更新的要點(diǎn)是將耗時(shí)任務(wù)移動(dòng)到下一個(gè)宏任務(wù)中執(zhí)行,優(yōu)先響應(yīng)用戶行為。
例如在該例中,將 setNumbers 移動(dòng)到 setTimeout 的回調(diào)中,用戶點(diǎn)擊按鈕后便能立即看到輸入框被隱藏,不會(huì)感知到頁面卡頓。mhd項(xiàng)目中優(yōu)化后的代碼如下:
延遲執(zhí)行
2.2 發(fā)布者訂閱者跳過中間組件 Render 過程
React 推薦將公共數(shù)據(jù)放在所有「需要該狀態(tài)的組件」的公共祖先上,但將狀態(tài)放在公共祖先上后,該狀態(tài)就需要層層向下傳遞,直到傳遞給使用該狀態(tài)的組件為止。
傳統(tǒng)redux數(shù)據(jù)流
每次狀態(tài)的更新都會(huì)涉及中間組件的 Render 過程,但中間組件并不關(guān)心該狀態(tài),它的 Render 過程只負(fù)責(zé)將該狀態(tài)再傳給子組件。在這種場景下可以將狀態(tài)用發(fā)布者訂閱者模式維護(hù),只有關(guān)心該狀態(tài)的組件才去訂閱該狀態(tài),不再需要中間組件傳遞該狀態(tài)。
當(dāng)狀態(tài)更新時(shí),發(fā)布者發(fā)布數(shù)據(jù)更新消息,只有訂閱者組件才會(huì)觸發(fā) Render 過程,中間組件不再執(zhí)行 Render 過程。
只要是發(fā)布者訂閱者模式的庫,都可以使用useContext進(jìn)行該優(yōu)化。比如:redux、use-global-state、React.createContext 等。
業(yè)務(wù)代碼中的使用如下:
從圖中可看出,優(yōu)化后只有使用了公共狀態(tài)的組件renderTable才會(huì)發(fā)生更新,由此可見這樣做可以大大減少父組件和 其他renderSon... 組件的 Render 次數(shù)(減少葉子節(jié)點(diǎn)的重渲染)。
2.3 useMemo 返回虛擬 DOM 可跳過該組件 Render 過程
利用 useMemo 可以緩存計(jì)算結(jié)果的特點(diǎn),如果 useMemo 返回的是組件的虛擬 DOM,則將在 useMemo 依賴不變時(shí),跳過組件的 Render 階段。
該方式與 React.memo 類似,但與 React.memo 相比有以下優(yōu)勢:
-
更方便。React.memo 需要對組件進(jìn)行一次包裝,生成新的組件。而 useMemo 只需在存在性能瓶頸的地方使用,不用修改組件。
-
更靈活。useMemo 不用考慮組件的所有 Props,而只需考慮當(dāng)前場景中用到的值,也可使用 useDeepCompareMemo 對用到的值進(jìn)行深比較。
該例子中,父組件狀態(tài)更新后,不使用 useMemo 的子組件會(huì)執(zhí)行 Render 過程,而使用 useMemo 的子組件會(huì)按需執(zhí)行更新。業(yè)務(wù)代碼中的使用方法:
03 精確判斷更新的'時(shí)機(jī)'和'范圍'
3.1 debounce、throttle 優(yōu)化頻繁觸發(fā)的回調(diào)
在搜索組件中,當(dāng) input 中內(nèi)容修改時(shí)就觸發(fā)搜索回調(diào)。當(dāng)組件能很快處理搜索結(jié)果時(shí),用戶不會(huì)感覺到輸入延遲。
但實(shí)際場景中,中后臺(tái)應(yīng)用的列表頁非常復(fù)雜,組件對搜索結(jié)果的 Render 會(huì)造成頁面卡頓,明顯影響到用戶的輸入體驗(yàn)。
在搜索場景中一般使用 useDebounce+ useEffect 的方式獲取數(shù)據(jù)。
在搜索場景中,只需響應(yīng)用戶最后一次輸入,無需響應(yīng)用戶的中間輸入值,debounce 更適合。而 throttle 更適合需要實(shí)時(shí)響應(yīng)用戶的場景中更適合,如通過拖拽調(diào)整尺寸或通過拖拽進(jìn)行放大縮?。ㄈ纾簑indow 的 resize 事件)。
3.2 懶加載
在 SPA 中,懶加載優(yōu)化一般用于從一個(gè)路由跳轉(zhuǎn)到另一個(gè)路由。
還可用于用戶操作后才展示的復(fù)雜組件,比如點(diǎn)擊按鈕后展示的彈窗模塊(大數(shù)據(jù)量彈窗)。
在這些場景下,結(jié)合 Code Split 收益較高。懶加載的實(shí)現(xiàn)是通過 Webpack 的動(dòng)態(tài)導(dǎo)入和 React.lazy 方法。
實(shí)現(xiàn)懶加載優(yōu)化時(shí),不僅要考慮加載態(tài),還需要對加載失敗進(jìn)行容錯(cuò)處理。
3.3 懶渲染
懶渲染指當(dāng)組件進(jìn)入或即將進(jìn)入可視區(qū)域時(shí)才渲染組件。常見的組件 Modal/Drawer 等,當(dāng) visible 屬性為 true 時(shí)才渲染組件內(nèi)容,也可以認(rèn)為是懶渲染的一種實(shí)現(xiàn)。懶渲染的使用場景有:
-
頁面中出現(xiàn)多次的組件,且組件渲染費(fèi)時(shí)、或者組件中含有接口請求。如果渲染多個(gè)帶有請求的組件,由于瀏覽器限制了同域名下并發(fā)請求的數(shù)量,就可能會(huì)阻塞可見區(qū)域內(nèi)的其他組件中的請求,導(dǎo)致可見區(qū)域的內(nèi)容被延遲展示。
-
需用戶操作后才展示的組件。這點(diǎn)和懶加載一樣,但懶渲染不用動(dòng)態(tài)加載模塊,不用考慮加載態(tài)和加載失敗的兜底處理,實(shí)現(xiàn)上更簡單。
懶渲染的實(shí)現(xiàn)中判斷組件是否出現(xiàn)在可視區(qū)域內(nèi)借助react-visibility-observer依賴:
3.4 虛擬列表
虛擬列表是懶渲染的一種特殊場景。虛擬列表的組件有 react-window和 react-virtualized,它們都是同一個(gè)作者開發(fā)的。
react-window 是 react-virtualized 的輕量版本,其 API 和文檔更加友好。推薦使用 react-window,只需要計(jì)算每項(xiàng)的高度即可:
如果每項(xiàng)的高度是變化的,可給 itemSize 參數(shù)傳一個(gè)函數(shù)。
所以在開發(fā)過程中,遇到接口返回的是所有數(shù)據(jù)時(shí),需提前預(yù)防這類會(huì)有展示的性能瓶頸的需求時(shí),推薦使用虛擬列表優(yōu)化。 使用示例可以點(diǎn)擊【閱讀原文】查看。
3.5 動(dòng)畫庫直接修改 DOM 屬性,跳過組件 Render 階段
這個(gè)優(yōu)化在業(yè)務(wù)中應(yīng)該用不上,但還是非常值得學(xué)習(xí)的,將來可以應(yīng)用到組件庫中。
參考 react-spring 的動(dòng)畫實(shí)現(xiàn),當(dāng)一個(gè)動(dòng)畫啟動(dòng)后,每次動(dòng)畫屬性改變不會(huì)引起組件重新 Render ,而是直接修改了 dom 上相關(guān)屬性值:
3.6 避免在 didMount、didUpdate 中更新組件 State
這個(gè)技巧不僅僅適用于 didMount、didUpdate,還包括 willUnmount、useLayoutEffect 和特殊場景下的 useEffect(當(dāng)父組件的 cDU/cDM 觸發(fā)時(shí),子組件的 useEffect 會(huì)同步調(diào)用),本文為敘述方便將他們統(tǒng)稱為「提交階段鉤子」。
React 工作流commit階段的第二步就是執(zhí)行提交階段鉤子,它們的執(zhí)行會(huì)阻塞瀏覽器更新頁面。
如果在提交階段鉤子函數(shù)中更新組件 State,會(huì)再次觸發(fā)組件的更新流程,造成兩倍耗時(shí)。一般在提交階段的鉤子中更新組件狀態(tài)的場景有:
-
計(jì)算并更新組件的派生狀態(tài)(Derived State)。在該場景中,類組件應(yīng)使用 getDerivedStateFromProps 鉤子方法代替,函數(shù)組件應(yīng)使用函數(shù)調(diào)用時(shí)執(zhí)行 setState 的方式代替。使用上面兩種方式后,React 會(huì)將新狀態(tài)和派生狀態(tài)在一次更新內(nèi)完成。
-
根據(jù) DOM 信息,修改組件狀態(tài)。在該場景中,除非想辦法不依賴 DOM 信息,否則兩次更新過程是少不了的,就只能用其他優(yōu)化技巧了。
use-swr 的源碼就使用了該優(yōu)化技巧。當(dāng)某個(gè)接口存在緩存數(shù)據(jù)時(shí),use-swr 會(huì)先使用該接口的緩存數(shù)據(jù),并在 requestIdleCallback 時(shí)再重新發(fā)起請求,獲取最新數(shù)據(jù)。模擬一個(gè)swr:
-
它的第二個(gè)參數(shù) deps,是為了在請求帶有參數(shù)時(shí),如果參數(shù)改變了就重新發(fā)起請求。
-
暴露給調(diào)用方的 fetch 函數(shù),可以應(yīng)對主動(dòng)刷新的場景,比如頁面上的刷新按鈕。
如果 use-swr 不做該優(yōu)化的話,就會(huì)在 useLayoutEffect 中觸發(fā)重新驗(yàn)證并設(shè)置 isValidating 狀態(tài)為 true·,引起組件的更新流程,造成性能損失。
04 工具介紹——React Profiler
4.1 React Profiler 定位 Render 過程瓶頸
React Profiler 是 React 官方提供的性能審查工具,本文只介紹筆者的使用心得,詳細(xì)的使用手冊請移步官網(wǎng)文檔。
Note:react-dom 16.5+ 在 DEV 模式下才支持 Profiling,同時(shí)生產(chǎn)環(huán)境下也可以通過一個(gè) profiling bundle react-dom/profiling 來支持。請?jiān)?fb.me/react-profi… 上查看如何使用這個(gè) bundle。
“Profiler” 的面板在剛開始的時(shí)候是空的。你可以點(diǎn)擊 record 按鈕來啟動(dòng) profile:
4.2 Profiler 只記錄了 Render 過程耗時(shí)
不要通過 Profiler 定位非 Render 過程的性能瓶頸問題
通過 React Profiler,開發(fā)者可以查看組件 Render 過程耗時(shí),但無法知曉提交階段的耗時(shí)。
盡管 Profiler 面板中有 Committed at 字段,但這個(gè)字段是相對于錄制開始時(shí)間,根本沒有意義。
通過在 React v16 版本上進(jìn)行實(shí)驗(yàn),同時(shí)開啟 Chrome 的 Performance 和 React Profiler 統(tǒng)計(jì)。
如下圖,在 Performance 面板中,Reconciliation和Commit階段耗時(shí)分別為 642ms 和 300ms,而 Profiler 面板中只顯示了 642ms:
4.3 開啟「記錄組件更新原因」
點(diǎn)擊面板上的齒輪,然后勾選「Record why each component rendered while profiling.」,如下圖 :
然后點(diǎn)擊面板中的虛擬 DOM 節(jié)點(diǎn),右側(cè)便會(huì)展示該組件重新 Render 的原因 。
4.4 定位產(chǎn)生本次 Render 過程原因
由于 React 的批量更新(Batch Update)機(jī)制,產(chǎn)生一次 Render 過程可能涉及到很多個(gè)組件的狀態(tài)更新。那么如何定位是哪些組件狀態(tài)更新導(dǎo)致的呢?
在 Profiler 面板左側(cè)的虛擬 DOM 樹結(jié)構(gòu)中,從上到下審查每個(gè)發(fā)生了渲染的(不會(huì)灰色的)組件。
如果組件是由于 State 或 Hook 改變觸發(fā)了 Render 過程,那它就是我們要找的組件,如下圖:
4.5 站在巨人的肩膀上
Optimizing Performance
https://react.docschina.org/docs/optimizing-performance.html
React 官方文檔,最好的教程, 利用好 React 的性能分析工具。
Twitter Lite and High Performance React Progressive Web Apps at Scale
https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3
看看 Twitter 是如何優(yōu)化的。