React.memo 防止無意義的 re-render

Lastor
Code 隨筆放置場
14 min readJan 30, 2022

接續上篇:
React useState 與 batch update

上篇討論了 React FC hook 碰到非同步時,state 的 batch update 會失效導致多次 re-render 的話題。這次來討論一下,父層 re-render 牽動子層跟著 re-render 的現象。

直接先上重點,React 有一個頂層 API 叫做 React.memo,是前陣子被人問到之後才知道原來有這樣的 API。深入了解之後,這玩意概念是類似 cache function,只是他 cache 的是 React 組件,如果某個 action 並不會導致組件 render 結果發生變化,那就不重新計算,直接使用上一筆 cache 下來的結果。

React 父層 render 也會帶動子層 render

在聊為何需要 React.memo 之前,首先要先知道 React 的渲染機制。一個畫面要不要重渲,取決於該畫面的 state 是否發生了改變。例如本來購物車是 0 元,加了一筆商品之後,變成了 100 元,此時就需要重渲畫面,更新這筆數值。

講直接一點,就是 set state 這個 React API 會觸發 Class 組件的 render method,亦或是 Function 組件被整包重新呼叫。

而如果被改變的是父層的 state,父層重渲的同時,會牽動所有的子層跟著被 re-render,無論有沒有傳 props,無論是 Class 還是 Function 組件,都會被連帶重渲。

例如有個 Parent 組件,包裹了兩個 Child,並有一個簡單的 onClick 事件,改變 Parent 的 state。

function Parent() {
const [num, setNum] = useState(0)
const onClickBtn = () => {
console.log('set state')
setNum(num + 1)
}
console.log('Parent render')
return (
<>
<Child />
<ChildWithProps num={num} />
<button onClick={onClickBtn}>Change State</button>
</>
)
}
// log
Parent render
Child render
ChildWithProps render
set state
Parent render
Child render
ChildWithProps render

初次渲染時,Parent 跟兩個 Child 都被 render。當按鈕被點擊後,會發現所有組件依舊全被觸發了 render。

這勢必會造成性能上的疑慮,一個 Page 可能會有很多的 child 組件,如果父層的 stateA 明明只影響 childA,但卻因此牽動了其他 child 跟著被重渲。又如果,其中有某個 child 渲染特別耗時,那就非常尷尬了。

由 Child 個別管理 state 避免連帶 re-render

要解決這連鎖 re-render 的問題,一個可行的思路是,乾脆 state 都由 child 個別管理,父層本身不進行任何 setState 的操作就行了。

但這會影響到整個程式流程的設計,操作起來也頗麻煩。因此這招可能不是所有情況都適用。

之前曾分享過一篇文章,parent 可以透過 ref 來控制 child 的 state,這樣就可以做到 stateA 在 childA 管理,但觸發事件的 handler 則統一由父層控管。

class Parent extends React.Component {
childRef = React.createRef()
componentDidMount() {
this.childRef.current?.setState({ title: 'something' })
}
render() {
return <Child ref={this.childRef} />
}
}

之前的文章:
[React筆記] 使用 ref 傳 data,降低 render 次數

這作法限定 child 必須是 Class 組件,如果是 Function 組件的話,就無法對組件的 instance 本身套上 ref。且 Function 組件,顧名思義是個函式,並不帶有 state 屬性與 setState 方法。

// FC 沒有 ref 屬性
<Child ref={ X } />

當然,硬要的話或許也可以做到,目前能想到的手法是用 forwardRef 做個包裝,把父層宣告的 RefObject 帶進去,然後把子層的 state 跟 set 函式裝進去,一起回給父層。

// 父層
type ChildState = {
num: number;
setNum: React.Dispatch<React.SetStateAction<number>>
}
function Parent() {
console.log('Parent render')
const childRef = useRef<ChildState>(null)
const onClickBtn = () => {
console.log('set state')
if (!childRef.current) return void 0
const { num, setNum } = childRef.current
setNum(num + 1)

}
return (
<>
<ChildWithProps ref={childRef} />
<button onClick={onClickBtn}>Change State</button>
</>
)
}
// 子層
const ChildWithProps = forwardRef((props, ref: any) => {
const [num, setNum] = useState(0)
ref.current = { num, setNum }
console.log('ChildWithProps render')
return <p>ChildWithProps: {num}</p>
})

這寫法有個很直接的問題,就是 TS 型別會有點麻煩,會卡在 forwardRef 參數的 ref 型別,在 child 呼叫 ref.current 會被判定不存在,但它其實是可以的。研究這個 TS 該怎麼搞得花點功夫,這邊先無腦帶上 any 來忽略掉。

雖然 FC 可以透過這樣比較強硬的方法來做到,但如果真的碰上這種需求,child 直接使用 Class 組件應該會容易很多。

使用 React.memo 來防止連帶 render

上述的作法只適合子層管理 state 的情況,如果必須要在父層統一管理 state 就不適用了。此時,為了解決 child 被連帶 re-render 的問題,就可以使用 React.memo 來包裝 child,讓它來幫我們控管是否需要重渲。

假設父層包裹了兩個 child,點擊 Button 後改變的是 ChildB 的 num 屬性,與 ChildA 無關,也就不希望 ChildA 無緣無故被重渲。

// Parent FC
const [num, setNum] = useState(0)
const increment = () => {
console.log('set state')
setNum(num + 1)
}
return (
<>
<ChildA />
<ChildB num={num} />
<button onClick={increment}>Increment num</button>
</>
)

只要簡單的把 ChildA 包進 React.memo,就可以起到類似 cache 的效果,它會幫我們自動檢查 props 有無改變,如果沒變就不觸發 render,維持原本的渲染結果。

const ChildA = React.memo(() => {
console.log('ChildA render')
return <p>Child</p>
})
// log onClick, ChildA 沒有被呼叫
set state
Parent render
ChildB render

這邊 ChildA 沒有注入 props,也就可以預期,只要頁面沒被重新加載,那它就只會被 render 一次。

接著可以進一步的多帶一個與 ChildB 無關的 props title 進去做實驗。

// Add props to ChildA
const [title, setNum] = useState(0)
<ChildA title={title} />
// log onClick, ChildA 仍舊不會被呼叫
set state
Parent render
ChildB render

此時去點 button,觸發 ChildB 的 num 屬性被改變,但 ChildA 的 title 不變,所以 ChildA 依舊不會被觸發 render。

如此,使用 React.memo 就能輕鬆做到防止 child 被連帶 re-render 的效果。

手動控制 React.memo 是否 re-render

React.memo 本身有兩個參數,第一個是放 Component,要餵 FC 或是 Class 都可以,第二個則是決定是否重渲的判斷用 callback。

function areEqual(prevProps, nextProps): booleanReact.memo(MyComponent, areEqual)

areEqual 是個 callback,其回傳一個 boolean。正如它的命名,如果屬性「是相等」也就是回傳 true,則不進行重渲。反之,則 re-render。

我們可以用上面 ChildA 的例子稍作修改做實驗,手動控制它強制重渲。

const ChildA = React.memo(
({ title }: { title: string }) => {
console.log('ChildA render')
return <p>Child {title}</p>
},
() => false // 加上 areEqual callback
)

淺比較 (shallow compare) 的陷阱

瞭解了 React.memo 的基本使用之後,就要來談談勢必會遇到的坑。如果餵給組件的 props 不是 string、number 之類的 Primitive Value,而是 Object、Array、Function 這類的 Reference Value。

每次父層 (FC) 被重渲時,裡面的所有內容都會重建一次,By Reference 的值,就會因 React 淺比較,被認為 props 有發生改變,導致非預期的 re-render。

舉個常見的例子,有一顆 button 在 ChildA,它僅負責操作 onClick 控制父層 state 的改變,改變之後的 state 會響應在 ChildB 的 DOM 上。

function Parent() {
const [num, setNum] = useState(0)
const increment = () => {
console.log('set state')
setNum(num + 1)
}
console.log('Parent render')
return (
<>
<ChildA onClick={increment} />
<ChildB num={num} />
</>
)
}
const ChildA = React.memo(
props: { onClick: React.MouseEventHandler }
) => {
console.log('ChildA render')
return <button onClick={props.onClick}>Increment num</button>
})
function ChildB(props: { num: number }) {
console.log('ChildB render')
return <p>ChildWithProps: {props.num}</p>
}

ChildA 只傳了一個點擊事件的 increment 函式,每次渲染時,increment 這函式看似沒有改變,但 Parent 它是個 Function 組件,所以每次渲染時,都會宣告一個全新的 increment,導致 React.memo 判斷餵進來的函式跟之前不一樣,進而觸發了 re-render。

這問題僅存在於 Function 組件,Class 組件並不會有這種情況。因為increment 函式會被獨立存在 prototype 裡,與 render 區分開來。每次 render 時,訪問到的都會是最初被宣出來的,同一個 increment。

class Parent extends React.Component {
state = { num: 0 }
increment = () => {...} // 獨立於 render 保存
render() {...}
}

Class 組件要注意的是,無論是把 increment 用箭頭函式宣成 property 還是直接宣成 method 再去從建構子 bind,都是不影響的。

但如果是用裝飾器來進行 bind 就會有坑。因為裝飾器要在描述符的 getter 裡才能拿到正確的 this 對象,而 getter 是每次調用都會被呼叫一次,也就是每次都會重新建立新的。

// decorator.tsx
function bindMethod(
target: any,
key: string,
descriptor: PropertyDescriptor
) {
return {
get() {
// 每次被呼叫都會執行一次
return descriptor.value.bind(this)
},

}
}
// component.tsx
class Parent extends React.Component {
@bindMethod
increment = () => {...}
}

把話題拉回 FC,我們只要能確保 increment 函式不要被重建,就可以保證 React.memo 不會判定 props 發生改變。

這時可以使用 useMemo 或是 useCallback 來做 cache,這樣 FC 每次執行時,都會拿到最初建立的值,配合 React.memo 達到防止重渲的效果。

function Parent() {
// ...
const increment = useCallback(() => {
console.log('set state')
setNum(num + 1)
}, [])
return (
<>
<ChildA onClick={increment} />
<ChildB num={num} />
</>
)
}
const ChildA = React.memo(...)

Class 的 React.PureComponent 與 shouldComponentUpdate

最後再來提一嘴,React.memo 是相對比較新的 HOC,雖然 Class 組件也可以用,但它看起來更多是用來服務 FC 的。

原本 Class 就可以透過 React.PureComponent 來達到 React.memo 的效果,只需要把延伸的對象從 React.Component 換一下就行。

class ChildA extends React.PureComponent {
// ...
}

而它其實是幫我們自動添加 shouldComponentUpdate 方法,與前面提到的,React.memo 的第二個參數 areEqual 是差不多的東西。只是因為命名語意的關係,回傳的 boolean 會是相反的。

type ChildAProps = {
onClick: React.MouseEventHandler
}
class ChildA extends React.Component<ChildAProps> {
shouldComponentUpdate(nextProps: ChildAProps) {
if (this.props.onClick === nextProps.onClick) {
// 如果 props 相同, 回傳 false, 不重渲
return false
}

// 如果 props 不相同, 回傳 true, 重渲
return true
}
// ...
}

關於 React.PureComponent 詳細可以參考這篇文章:
在 React.memo 實作 re-render 條件

其他參考:
React Hook重复渲染问题处理:useMemo, memo, useCallback — 掘金
新版react中,usecallback和usememo是不是值得大量使用? — 知乎

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。