理解這個機制,是成為React性能優(yōu)化高手的關(guān)鍵
本來是準備優(yōu)先分享兩個官方定義的 Hook useMemo,useCallback,不過這兩個 hook 本身其實沒有太多探討的空間,他們只是兩個記憶函數(shù),本身并沒有特殊的、更進一步的含義。
許多人的困惑往往來源于對于它們兩個過度解讀,認為他們的存在對 React 性能的優(yōu)化有非常重要的意義。過渡解讀導致了對他們的濫用。在我看過的項目中,有個別優(yōu)秀前端團隊里的項目規(guī)范里,也錯誤抬高了他們的作用,把他們用在了每一個組件里。
出現(xiàn)這樣問題的根源就在于對 React 的自身機制理解不夠精準。因此我決定換一個角度去帶大家理解 React 本身的優(yōu)化機制,從而能夠正確的使用 useMemo 與 useCallback。
本文將會從應(yīng)用層面來為大家分析我們應(yīng)該怎么做。后續(xù)的章節(jié)將會從 Fiber 的雙緩存策略開始分享底層的優(yōu)化機制。
一、精簡節(jié)點
首先我們要明確一些前置知識。
React 在內(nèi)存中維護了一顆 虛擬 DOM 樹,這顆樹的每一個節(jié)點是一個 Fiber,每一個 Fiber 都由 JSX 中的組件解析而來。
type Fiber = {
// 用于標記fiber的WorkTag類型,主要表示當前fiber代表的組件類型如FunctionComponent、ClassComponent等
tag: WorkTag,
// ReactElement里面的key
key: null | string,
// ReactElement.type,調(diào)用`createElement`的第一個參數(shù)
elementType: any,
// The resolved function/class/ associated with this fiber.
// 表示當前代表的節(jié)點類型
type: any,
// 表示當前FiberNode對應(yīng)的element組件實例
stateNode: any,
// 指向他在Fiber節(jié)點樹中的`parent`,用來在處理完這個節(jié)點之后向上返回
return: Fiber | null,
// 指向自己的第一個子節(jié)點
child: Fiber | null,
// 指向自己的兄弟結(jié)構(gòu),兄弟節(jié)點的return指向同一個父節(jié)點
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,
// 當前處理過程中的組件props對象
pendingProps: any,
// 上一次渲染完成之后的props
memoizedProps: any,
// 該Fiber對應(yīng)的組件產(chǎn)生的Update會存放在這個隊列里面
updateQueue: UpdateQueue<any> | null,
// 上一次渲染的時候的state
memoizedState: any,
// 一個列表,存放這個Fiber依賴的context
firstContextDependency: ContextDependency<mixed> | null,
mode: TypeOfMode,
// Effect
// 用來記錄Side Effect
effectTag: SideEffectTag,
// 單鏈表用來快速查找下一個side effect
nextEffect: Fiber | null,
// 子樹中第一個side effect
firstEffect: Fiber | null,
// 子樹中最后一個side effect
lastEffect: Fiber | null,
// 代表任務(wù)在未來的哪個時間點應(yīng)該被完成,之后版本改名為 lanes
expirationTime: ExpirationTime,
// 快速確定子樹中是否有不在等待的變化
childExpirationTime: ExpirationTime,
// fiber的版本池,即記錄fiber更新過程,便于恢復
alternate: Fiber | null,
}
state 的每次變化,都會引發(fā)整棵樹的前后對比,從而導致許多組件重新執(zhí)行。這也是 React 性能消耗的主要成本。但是 React 內(nèi)部采用緩存機制和優(yōu)秀的 Diff 算法極大的減少了這里的成本,后續(xù)我們會詳細介紹這兩個機制。
這里我要重點介紹的是,在使用中,我們可以通過減小這顆 Fiber tree 的方式來達到性能優(yōu)化的目的。只要 Fiber tree 足夠小,diff 的成本就會非常的低。
例如,我們有一個非常大的巨石項目,當我們路由切換的時候,會直接刪掉前一個頁面的所有內(nèi)容,只渲染新頁面的內(nèi)容,那么,雖然隨著訪問頁面的數(shù)量越來越多,緩存在全局狀態(tài)管理器中的數(shù)據(jù)越來越復雜,但是 Fiber tree 的大小其實并沒有變得越來越大,依然維持在一個頁面的量級,此時的 diff 壓力跟一個小型項目沒有什么區(qū)別。通過這種手段,我們可以輕松保持一個巨石項目的高性能。
落實到具體的頁面上,特別是在一些管理系統(tǒng)里,許多開發(fā)者喜歡在在列表頁中,維護一個內(nèi)容超級復雜的彈窗組件,彈窗的內(nèi)容是列表的詳情。此時,彈窗內(nèi)容和列表內(nèi)容同時存在,從而導致了 Fiber tree 的龐大。
從交互上,我們可以將復雜的彈窗內(nèi)容移植到一個新的詳情頁,就能極大的緩解 diff 壓力。
在某些項目中,一個詳情頁有幾百條表單需要填寫。我們可以通過分步驟的方式,把這幾百個表單項切分到不同的步驟里,從而讓同時渲染出來的表單項大量減少,性能也會有很大的提高。
總的來說,只要我們把 Fiber 節(jié)點數(shù)量控制在一定范圍內(nèi),React 都能保持一個非常高的性能。因此大多數(shù)情況下,我們并不需要做額外的性能優(yōu)化。
二、比較方式
由于大量的 re-render 存在,我們很容易能想到一個優(yōu)化策略,在 diff 過程中,當我比較之后發(fā)現(xiàn)有的節(jié)點并沒有發(fā)生任何變化,那么我們就可以跳過該組件的 re-render,從而提高性能。
而要讓這個優(yōu)化想法落地,我們就必須了解內(nèi)部的比較規(guī)則,首先要考慮的第一個問題就是。
如何知道一個組件是否發(fā)生了變化
一個 React 組件是否發(fā)生了變化由三個因素決定。
- props
- state
- context
這三個因素中的任何一個發(fā)生了變化,組件都會認為自己應(yīng)該發(fā)生變化。state 和 context 都是不可變數(shù)據(jù),而且由于是我們主動調(diào)用 dispatch 去觸發(fā)他們發(fā)生改變,因此 state 和 context 的變化一般不會對我們造成理解上的困擾。
最麻煩的是 props。
React 組件的每次執(zhí)行,都會傳入新的 props 對象,雖然內(nèi)容可能一樣,但是在內(nèi)存中卻已經(jīng)發(fā)生了變化。
function Child(props) {}
// 執(zhí)行一次傳入新的對象
Child({})
// 執(zhí)行一次傳入新的對象
Child({})
與 state 不一樣的是,props 并沒有緩存在別的地方,因此,一個組件 的 props 哪怕什么都沒有變化,比較的結(jié)果也是 false。
var preProps = {}
var curProps = {}
preProps === curProps // false
var preProps = { name: 'Jake' }
var curProps = { name: 'Jake' }
preProps === curProps // false
呵!沒想到吧。
也就是說,當一個子組件接收一個函數(shù)作為 props,為了保證函數(shù)的引用不發(fā)生變化,有的人選擇使用 useCallback 來緩存函數(shù)引用,從而期望子組件不會因為 props 發(fā)生了變化而導致子組件重新渲染。
function Demo() {
...
const change = useCallback(() => {}, [])
return (
<div>
...
<Filter change={change} />
</div>
)
}
結(jié)合我們剛才說的,這里只使用 useCallback 是做了無用功。
preProps = { change: change }
curProps = { change: change }
preProps === curProps // false
那么問題就來了,如果這樣子的話,豈不是每個組件的 props 都會發(fā)生變化了?
當然不是,React 內(nèi)部針對 props 有另外一個策略:
如果父組件被判定為沒有變化,那么,在判斷子組件是否發(fā)生變化時,不會比較子組件的 props。
源碼里少一個判斷,卻衍生出這樣一個精妙的設(shè)計。
高級!
除此之外,F(xiàn)iber Tree 的根節(jié)點,被判定為始終不會發(fā)生變化。
這樣,根節(jié)點的子組件在比較時,react 就一定會跳過 props 的比較,以此類推。我們就有機會構(gòu)造一個高性能的更新過程。
回到我們經(jīng)典的數(shù)字遞增案例,來分析這個案例。
function Child() {
console.log('我不想重新渲染')
return (
<div>我不想重新渲染</div>
)
}
export default function Demo02() {
const [count, setCount] = useState(0)
return (
<div className="wrapper">
<div onClick={() => setCount(count + 1)}>{count}</div>
<Child />
</div>
)
}
當我們點擊數(shù)字的時候,數(shù)字遞增,父組件 Demo02 被判定為改變,因此,內(nèi)部的所有子組件都需要比較 props,props 為不可變數(shù)據(jù),子組件 Child 的 props 進行了如下比較,結(jié)果為 false 。
{} === {} // false
因此,Child 雖然不想 re-render,但是每次 count 變化都 render 了。
調(diào)整的方式非常簡單,只需要讓父組件的 state 沒有發(fā)生變化即可,把變化的部分單獨封裝在另外一個子組件里。
function Change() {
const [count, setCount] = useState(0)
return (
<div onClick={() => setCount(count + 1)}>{count}</div>
)
}
export default function Demo02() {
return (
<div className="wrapper">
<Change />
<Child />
</div>
)
}
這個時候,父組件被判定為沒有發(fā)生變化,因此子組件就會跳過 props 的比較,從而 Child 判定為沒有發(fā)生變化。這樣我們的目的就達到了。
但是,這里有一個前期條件,那就是我們需要確保 Demo02 的父組件也被判定為沒有發(fā)生變化,因此,如果你是 React 架構(gòu)師,頂層結(jié)構(gòu)的設(shè)計是你需要關(guān)注的重中之重,因為如果頂層出了問題,導致父組件不滿足這樣的穩(wěn)定結(jié)構(gòu),那么后續(xù)的子組件都會 re-render。
那么理解這個規(guī)則很難嗎?其實不難,難就難在,在看這篇文章之前,可能你壓根就不知道這個設(shè)計啊。
如果我們有一個不靠譜的 React 架構(gòu)師,頂層組件的穩(wěn)定結(jié)構(gòu)出了問題,那么我們有什么手段,能夠低成本的讓你能接觸到的頁面結(jié)構(gòu)保持穩(wěn)定呢?
答案就是 React.memo。
memo 函數(shù)會讓組件的 props 比較方式發(fā)生變化,我們之前都是一直用的 === 全等比較,使用 memo 包裹組件之后,React 內(nèi)部會改變比較策略,他會遍歷 props 的每個屬性,如果每個屬性都能通過全等比較,那么就判定為 props 沒有發(fā)生變化。
這個遍歷過程只會發(fā)生在 props 對象的第一層屬性,不會更進一步深入。
因此,當我們無法確定上層組件是否發(fā)生變化時,我們可以在某一個節(jié)點使用 memo 來確保從這一層開始建立穩(wěn)定的高性能模式。
function _Child() {
console.log('我不想重新渲染')
return (
<div>我不想重新渲染</div>
)
}
var Child = memo(_Child)
export default function Demo02() {
const [count, setCount] = useState(0)
return (
<div className="wrapper">
<div onClick={() => setCount(count + 1)}>{count}</div>
<Child />
</div>
)
}
當我們使用 memo 包裹子組件導致 props 的比較方式發(fā)生變化時,useCallback 緩存引用就有用了。這也是 useCallback 的主要作用,他一定要結(jié)合 memo 去使用。
當然,我們也可以用一些騷操作來達到同樣的目標,利用 useMemo 來緩存組件。
export default function Demo02() {
const [count, setCount] = useState(0)
const _child = useMemo(() => {
return <Child />
}, [])
return (
<div className="wrapper">
<div onClick={() => setCount(count + 1)}>{count}</div>
{_child}
</div>
)
}
當你決定要自己設(shè)計比較規(guī)則時就可以采用這樣的方式。
三、總結(jié)
這篇文章分享了兩個 React 項目性能優(yōu)化的最重要的手段。我們只要了解了真實的底層機制,就能寫出高性能的代碼,他們的理解難度并不高。我們只需要在項目中正確的去編寫符合他們機制的代碼即可。
如果你是 React 項目架構(gòu)師,那么你一定要吃透這個機制,在頂層架構(gòu)中,我們會額外添加 Router/Redux 等諸多頂層組件,他們會不會導致高性能結(jié)構(gòu)的崩塌,你一定要非常明確。
除此之外,當頂層的父組件不變判定被破壞,我們也不需要每一個組件都用 memo 包裹起來,只需要在合適的節(jié)點包裹一個組件即可。因為 memo 的比較本身也會增加程序的執(zhí)行成本,大量的 memo 反而會導致性能變得更低。
除此之外,我們要明確,組件的 re-render 是內(nèi)存行為,他是執(zhí)行了一次 JS 函數(shù),他并不會導致瀏覽器真的發(fā)生渲染行為,因此 re-render 的執(zhí)行也是非??焖俚?,大多數(shù)情況下的 re-render 都可以接受,不過超大量的 re-render 會導致執(zhí)行壓力變大,所以用大量 memo 減少 re-render 并不一定是一件劃算的事情。
利用少量的 memo 與 React 本身的緩存機制減少大量的 re-render 才是合理的方案。