效能優化函式
本章節介紹的功能旨在改善應用程式中的效能。在不必要的情況下使用這些函式不僅會降低程式碼的可讀性,也會增加維護的難度。
一般來說,若您的應用程式中沒有效能問題,那就不要費心使用這些功能!(除了 useMemo()
之外,因為它有時候可以作為別種用途)。
memo()
memo()
是一個內建的 HOC,用於建立一個基於屬性的可記憶版本的元件。簡化版的 memo()
介面如下:
const memo = (
component: FunctionOrClass,
arePropsEqual?: CompareFunction
): Component => {
// ...
}
type CompareFunction<T> = (currentProps: T, nextProps: T) => boolean
// 使用 `memo()`
const Component = () => {
return (
// ...
)
}
const MemoizedComponent = memo(Component, () => {
// ...
})
memo()
的運作方式如下:
- 元件完成首次渲染後,React 會記住這次的渲染結果。
- 當元件因為父元件的重新渲染而隨之重新渲染時,React 會使用
Object.is()
來檢查 props 中每個屬性的數值是否和前一次渲染相同。- 若沒有任何屬性發生變化,React 就會直接回傳上次記住的渲染結果,不會執行元件中的任何程式碼。
- 否則元件就會照常重新渲染,並將先前的記憶值替換為新的渲染結果。
- 若您想要元件僅在特定的屬性發生變化時才重新渲染,您可以傳遞一個函式給第二個參數
arePropsEqual()
來自訂屬性相等的檢查邏輯。
因此,只有在被記憶的元件作為子元件時,memo()
的效果才得以顯現。
何時該使用 memo()
?
通常 memo()
會用在較消耗資源的元件,並且某些屬性會導致不必要的重新渲染的情況。這通常發生在父元件重新渲染較頻繁,例如涉及拖拉功能 (drag and drop),或是子元件較龐大的情況,例如編輯器。
以下範例是如何使用 memo()
來解決我們在元件渲染中提到的一個問題:
import { memo, useState, useEffect } from 'react'
const Child = memo(() => {
useEffect(() => {
console.log('[Child] re-renders')
})
return <h1>I am child</h1>
})
export const Parent = () => {
const [count, setCount] = useState(0)
const increment = () => {
setCount(count + 1)
}
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<Child />
</div>
)
}
這種寫法中的 Child
永遠不會隨著 Parent
一起重新渲染,因為這裡的 arePropsEqual()
永遠回傳 true
。
假設某個元件被 memo()
記憶起來,這是否代表只要 arePropsEqual()
回傳的是真值 (truthy),該元件就不會重新渲染?
不,並不是這樣!我們知道元件會在響應式數值改變時重新渲染,但是屬性並不是元件中唯一一個響應式數值。memo()
僅有在該次重新渲染是由父元件觸發時才會作用,如果該次重新渲染是由非屬性的響應式數值 (例如狀態) 所導致的,那麼元件依然會重新渲染。
您可以這樣想:memo()
記憶的並不是元件輸出的 HTML,也不是元件在某一個時刻的快照 (snapshot);相反地,他運作的方式比較像是指向某個特定元件實體的指標。當 arePropsEqual()
回傳的為假值 (falsy) 時,新的元件實體會被產生,然後該指標就會從舊的實體轉向新的實體。因此元件內部的變化依然會照常發生,不受 memo()
影響。
useMemo()
若您曾經學過 Vue,可以把 useMemo()
看成是不知道何時該更新自己的 computed()
。
useMemo()
是一個內建的鉤子,用於記憶任何您想記憶的東西。與 useEffect()
相似,useMemo()
接收一個回呼函式 (calback function) 和一個依賴值陣列 作為參數。簡化版的 useMemo()
介面如下:
type useMemo<T> = (
callback: () => T,
dependencies: any[],
) => void
// 使用 `useMemo()`
const something = useMemo(() => {
return ...
}, [])
useMemo()
的運作方式如下:
- React 在元件首次渲染時呼叫
callback
,並記住他的回傳值。 - 當元件重新渲染時,React 會使用
Object.is()
來檢查dependencies
中每個元素的值是否和前一次渲染相同。- 若沒有任何元素發生變化,React 就會直接回傳上次記住的數值。
- 否則
callback
就會被呼叫,並用他的回傳值取代先前的記憶值。
何時該使用 useMemo()
?
通常 useMemo()
適用於以下情況:
- 在元件重新渲染時跳過較消耗資源的運算。
- 使變數在不同渲染間仍然能指向相同的記憶體位置。
- 當
useEffect()
和useState()
一起使用。
在元件重新渲染時跳過較消耗資源的運算
有時候我們需要在元件內執行較消耗資源的運算。若這些運算在每次渲染都被執行,我們可能就會在元件在重新渲染時感受到明顯的延遲。然而,在 useMemo()
的幫助下,我們可以確保這些運算只會在某些數值發生變化時執行。例如:
import { useState, useMemo } from 'react'
export const Example = () => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])
// 這會在每次渲染時執行。
const matchedUsers = users.filter(
(user) => user.name.includes('A')
)
// 這只會在 `users` 改變時執行。
const matchedUsers = useMemo(
() => users.filter((user) => user.name.includes('A')),
[users]
)
return (
// ...
)
}
使變數在不同渲染間仍然能指向相同的記憶體位置
有時候我們需要將某個非原始型別的數值 (例如函式) 當做子元件的屬性。由於未被記憶的值會隨著元件的重新渲染被重新宣告,他們每次都會指向不同的物件,導致子元件上的 memo()
失效。
要解決這個問題,我們可以使用 useMemo()
來將數值記憶起來,這樣我們就能在不同的渲染中取得相同的值。例如:
import { useMemo } from 'react'
export const Example = () => {
// 小心!
// 這會導致 `user` 在每次渲染中都指向不同的物件。
const user = {
age: 5,
}
// 這會使得 `user` 總是指向相同的物件。
const user = useMemo(() => ({
age: 5,
}), [])
return (
// ...
)
}
當 useEffect()
和 useState()
一起使用
有時候我們需要在某些屬性或狀態改變時更新另外一個狀態。在某些情況下,使用 useMemo()
會比使用 useEffect()
+ setState()
還要理想。
長話短說,這種寫法:
import { useState, useMemo } from 'react'
interface IExampleProps {
keyword: string
}
export const Example = ({ keyword }: IExampleProps) => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])
const matchedUsers = useMemo(
() => users.filter((user) => user.name.includes(keyword)),
[keyword]
)
return (
// ...
)
}
會比下面這種寫法還要簡潔:
import { useState, useEffect } from 'react'
interface IExampleProps {
keyword: string
}
export const Example = ({ keyword }: IExampleProps) => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])
const [matchedUsers, setMatchedUsers] = useState([])
useEffect(() => {
setMatchedUsers(
users.filter((user) => user.name.includes(keyword))
)
}, [keyword])
return (
// ...
)
}
我們可以使用 useMemo()
來記憶某個元件嗎?
我們可以這麼做!和 memo()
相似,若元件中任何非屬性的響應式數值發生變化,被記憶的元件就會重新渲染。主要的差別是 memo()
會在 arePropsEqual()
的回傳值為假值時建立新的元件實體,而 useMemo()
則會在 dependencies
發生變化時建立新的元件實體。
很重要的一點是,傳入 useMemo()
的 callback
不該有副作用,例如修改變數或是呼叫 API。該函式應該要是純淨的,意即相同的輸入總是會得到相同的輸出,而且不會影響到其他的變數。
useCallback()
useCallback()
是一個內建的鉤子,用於記憶一個函式。與 useEffect()
相似,useMemo()
接收一個回呼函式和一個依賴值陣列作為參數。簡化版的 useCallback()
介面如下:
type useCallback<T extends Function> = (
callback: T,
dependencies: any[],
) => void
// 使用 `useCallback()`
const myFunction = useCallback(() => {
// ...
}, [])
useCallback()
的運作方式如下:
- 在元件首次渲染時,React 會記住
callback
。 - 當元件重新渲染時,React 會使用
Object.is()
來檢查dependencies
中每個元素的值是否和前一次渲染相同。- 若沒有任何元素發生變化,React 就會直接回傳上次記住的數值。
- 否則舊的記憶值就會被新的
callback
取代。
舉例來說:
import { useState, useCallback } from 'react'
export const Example = () => {
const [count, setCount] = useState(0)
const increment = () => {
setCount(count + 1)
}
const showCount = () => {
console.log(count)
}
const memoizedShowCount = useCallback(showCount, [])
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<button onClick={showCount}>
Show Count
</button>
<button onClick={memoizedShowCount}>
Show Count (Memoized)
</button>
</div>
)
}
在這個範例中,一開始點擊 "Show Count" 和 "Show Count (Memoized)" 都會在主控台中顯示 0
。在點擊 "Increment" 三次後,點擊 "Show Count" 顯示了 3
,而點擊 "Show Count (Memoized)" 卻依然顯示 0
。
發生這種情況是因為在首次渲染中,count
的數值為 0
,代表元件中所有的 count
都會被取代成 0
。我們並沒有放置任何數值到 useCallback()
的依賴值陣列中,因此 memoizedShowCount()
中的 count
永遠不會被更新,從而在呼叫的時候顯示了 0
。
何時該使用 useCallback()
?
通常 useCallback()
使用在函式被作為子元件的屬性,或是函式是某個副作用的依賴值的情況。舉例來說:
import { memo, useState } from 'react'
const MemoizedChild = memo(() => {
// ...
})
export const Example = () => {
const [count, setCount] = useState(0)
const increment = () => {
setCount(count + 1)
}
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<MemoizedChild increment={increment} />
</div>
)
}
在這個範例中,儘管 MemoizedChild
已經用 memo()
記憶起來了,他還是會隨著 Example
一同重新渲染。
這是因為每次 Example
重新渲染時,increment()
都會被重新宣告;由於 increment()
屬於非原始型別,他每次都會指向不同的物件,導致 memo()
認為 increment()
在兩次渲染之間發生變化了。
要解決這個問題,我們可以將 increment()
包裹在 useCallback()
中,這樣即使 Example
重新渲染,他也能指向相同的物件:
import { memo, useState, useCallback } from 'react'
const MemoizedChild = memo(() => {
// ...
})
export const Example = () => {
const [count, setCount] = useState(0)
const increment = useCallback(() => {
setCount(prev => prev + 1)
}, [])
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<MemoizedChild increment={increment} />
</div>
)
}
請注意,我們在 setCount()
中使用了更新函式,這樣我們就不需要將 count
放在 useCallback()
的依賴值陣列中。如此一來我們就能保證被傳遞給 MemoizedChild
的 increment()
在每次渲染中都會指向相同的物件,從而使 memo()
能如預期的運作。
您可能已經注意到 useCallback()
和 useMemo()
非常相似,確實是如此!您也可以使用 useMemo()
來記憶一個函式,然而這可能會稍微降低程式碼的可讀性。例如:
import { useMemo } from 'react'
// 這種寫法有點難閱讀。
const increment = useMemo(() => () => {
setCount(prev => prev + 1)
}, [])
// 這種寫法比較好讀,但是他的作用和 `useCallback()` 一模一樣。
const increment = useMemo(() => {
return () => {
setCount(prev => prev + 1)
}
}, [])
雖然您可以藉由顯性回傳來讓程式碼變得好看一些 (看起來其實挺不錯的!),但是那樣做的結果就和 useCallback()
一模一樣。總而言之,只要將 useCallback()
視為回傳 callback
本身,而不是回傳 callback
呼叫的結果的 useMemo()
即可。
import { useMemo } from 'react'
const useCallback = (callback: () => any, dependencies: any[]) => {
return useMemo(
() => callback,
dependencies
)
}
我們不得不再說一次:盡量不要在不需要這些功能的地方使用他們!