什麼時候使用 useMemo 和 useCallback

  • 2020 年 3 月 15 日
  • 筆記

性能優化總是會有成本,但並不總是帶來好處。我們來談談 useMemouseCallback 的成本和收益。

這裡是一個糖果提售貨機:

(原文中可點擊交互,點擊 「grab」 按鈕後「提取」對應的糖果,對應項會從頁面刪除;全部提取完後會出現 「refill」 按鈕,點擊重置所有糖果)

以下是它的實現方式:

function CandyDispenser() {    const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']    const [candies, setCandies] = React.useState(initialCandies)    const dispense = candy => {      setCandies(allCandies => allCandies.filter(c => c !== candy))    }    return (      <div>        <h1>Candy Dispenser</h1>        <div>          <div>Available Candy</div>          {candies.length === 0 ? (            <button onClick={() => setCandies(initialCandies)}>refill</button>          ) : (            <ul>              {candies.map(candy => (                <li key={candy}>                  <button onClick={() => dispense(candy)}>grab</button> {candy}                </li>              ))}            </ul>          )}        </div>      </div>    )  }

現在我想問你一個問題,我希望你在繼續之前好好想想。我要做一個改變,我想讓你告訴我哪一個會有更好的性能特徵。

我唯一要改變的是在 React.useCallback 里包裹 dispense 函數:

  const dispense = React.useCallback(candy => {      setCandies(allCandies => allCandies.filter(c => c !== candy))    }, [])

這是原來的程式碼:

const dispense = candy => {    setCandies(allCandies => allCandies.filter(c => c !== candy))  }

所以我的問題是,在這個特定的例子中,哪一個對性能更好?原來的還是 useCallback

如果你選擇的是 useCallback,再好好思考下。

正確答案是:使用原來的程式碼性能會更好?

為什麼 useCallback 更糟糕?!

我們聽到很多你應該使用 React.useCallback 來提高性能,並且「內聯函數可能會對性能造成問題」,那麼不使用callCallback 是如何變得更好的?

從我們的具體例子中退後一步,甚至從React那裡考慮一下:執行的每行程式碼都有成本。讓我稍微重構一下 useCallback 的例子來更清楚地說明事情(沒有實際的改變,只是移動下程式碼):

const dispense = candy => {    setCandies(allCandies => allCandies.filter(c => c !== candy))  }  const dispenseCallback = React.useCallback(dispense, [])

這是原來的:

const dispense = candy => {    setCandies(allCandies => allCandies.filter(c => c !== candy))  }

注意到了嗎?讓我們看一下 diff:

const dispense = candy => {      setCandies(allCandies => allCandies.filter(c => c !== candy))  }  + const dispenseCallback = React.useCallback(dispense, [])

是的,除了useCallback版本做了更多的工作之外,它們完全相同。我們不僅需要定義函數,還要定義一個數組([])並調用 React.useCallback,它本身會設置屬性和運行邏輯表達式等。

因此,在這兩種情況下,JavaScript 必須在每次渲染中為函數定義分配記憶體,並且根據 useCallback 的實現方式,你可能會獲得更多的函數定義記憶體分配(實際情況並非如此,但重點還在這裡)。這就是我試圖通過我的 Twitter 民意調查得到的

我還想提一下,在組件的第二次渲染中,原來的 dispense 函數被垃圾收集(釋放記憶體空間),然後創建一個新的 dispense 函數。但是使用 useCallback 時,原來的 dispense 函數不會被垃圾收集,並且會創建一個新的 dispense 函數,所以從記憶體的角度來看,這會變得更糟。

作為一個相關的說明,如果你有其它依賴,那麼React很可能會掛起對前面函數的引用,因為 memoization 通常意味著我們保留舊值的副本,以便在我們獲得與先前給出的相同依賴的情況下返回。特別聰明的你會注意到,這意味著React還必須掛在對這個等式檢查依賴項的引用上(由於閉包,這種情況可能會偶然發生,但無論如何它都值得一提)。

useMemo 雖然不同,但卻是相似的?

useMemo 類似於 useCallback,除了它允許你將 memoization 應用於任何值類型(不僅僅是函數)。它通過接受一個返回值的函數來實現這一點,然後只在需要檢索值時調用該函數(通常這隻有在每次渲染中依賴項數組中的元素髮生變化時才會發生一次)。

所以,如果我不想在每次渲染時初始化那個 initialCandies 數組,我可以做這個改變:

- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']  + const initialCandies = React.useMemo(  +  () => ['snickers', 'skittles', 'twix', 'milky way'],  +  [],  + )

我可以避免那個問題,但是節省的成本是如此之小,以至於換來使程式碼更加複雜的成本是不值得的。實際上,這裡使用useMemo 也可能會更糟,因為我們再次進行了函數調用,並且程式碼會執行屬性賦值等。

在這個特定的場景中,更好的方法是進行這個更改:

+ const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']    function CandyDispenser() {  -   const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']      const [candies, setCandies] = React.useState(initialCandies)

但有時你沒有那麼奢侈,因為這個值要麼來源於 props 或者函數體內初始化的其它變數。

關鍵是這兩種方式無關緊要,優化這些程式碼的好處是如此微不足道,以至於你可以更好地花時間來改善產品品質。

重點是什麼?

重點是:

性能優化不是免費的。它們總是帶來成本,但這並不總是帶來好處來抵消成本。

因此,負責任地進行優化。

所以我應該什麼時候使用 useMemo 和 useCallback?

這兩個 hooks 內置於 React 都有特別的原因:

  1. 引用相等
  2. 昂貴的計算

引用相等

如果你是 JavaScript 或者編程新手,你很快就會明白為什麼會這樣:

true === true // true  false === false // true  1 === 1 // true  'a' === 'a' // true    {} === {} // false  [] === [] // false  () => {} === () => {} // false    const z = {}  z === z // true    // NOTE: React actually uses Object.is, but it's very similar to ===

我不打算深入研究這個問題,但是當你在React函數組件中定義一個對象時,它跟上次定義的相同對象,引用是不一樣的(即使它具有所有相同值和相同屬性),這足以說明這個問題。

在React中,有兩種情況下引用相等很重要,讓我們一個個地來看。

依賴列表

讓我們來回顧一個例子。

「警告,你將看到一些人為故意設計的程式碼。請不要吹毛求疵,只關注概念,謝謝。

function Foo({bar, baz}) {    const options = {bar, baz}    React.useEffect(() => {      buzz(options)    }, [options]) // we want this to re-run if bar or baz change    return <div>foobar</div>  }    function Blub() {    return <Foo bar="bar value" baz={3} />  }

這裡有問題的原因是因為 useEffect 將對每次渲染中對 options 進行引用相等性檢查,並且由於JavaScript的工作方式,每次渲染 options 都是新的,所以當React測試 options 是否在渲染之間發生變化時,它將始終計算為 true,意味著每次渲染後都會調用 useEffect 回調,而不是僅在 barbaz 更改時調用。

我們可以做兩件事來解決這個問題:

// option 1  function Foo({bar, baz}) {    React.useEffect(() => {      const options = {bar, baz}      buzz(options)    }, [bar, baz]) // we want this to re-run if bar or baz change    return <div>foobar</div>  }

這是個不錯的選擇,如果這是真的,我就會這麼做。

但是有一種情況下:如果 bar 或者 baz 是(非原始值)對象、數組、函數等,這不是一個實際的解決方案:

function Blub() {    const bar = () => {}    const baz = [1, 2, 3]    return <Foo bar={bar} baz={baz} />  }

這正是 useCallbackuseMemo 存在的原因。你可以這樣解決這個問題(現在都放一起了):

function Foo({bar, baz}) {   React.useEffect(() => {     const options = {bar, baz}     buzz(options)   }, [bar, baz])   return <div>foobar</div>  }    function Blub() {   const bar = React.useCallback(() => {}, [])   const baz = React.useMemo(() => [1, 2, 3], [])   return <Foo bar={bar} baz={baz} />  }

「請注意,同樣的事情也適用於傳遞給 useEffect, useLayoutEffect,useCallback, 和 useMemo 的依賴項數組。

React.memo

「警告,你將看到一些人為故意設計的程式碼。請不要吹毛求疵,只關注概念,謝謝。

看看這個:

function CountButton({onClick, count}) {   return <button onClick={onClick}>{count}</button>  }    function DualCounter() {   const [count1, setCount1] = React.useState(0)   const increment1 = () => setCount1(c => c + 1)     const [count2, setCount2] = React.useState(0)   const increment2 = () => setCount2(c => c + 1)     return (     <>       <CountButton count={count1} onClick={increment1} />       <CountButton count={count2} onClick={increment2} />     </>   )  }

每次單擊其中任何一個按鈕時,DualCounter 的狀態都會發生變化,因此會重新渲染,然後重新渲染兩個CountButton。但是,實際上只需要重新渲染被點擊的那個按鈕吧?因此,如果你點擊第一個按鈕,則第二個也會重新渲染,但沒有任何變化,我們稱之為「不必要的重新渲染」。

大多數時候,你不需要考慮去優化不必要的重新渲染。React是非常快的,我能想到你可以利用時間去做很多事情,比起做這些類似的優化要好得多。事實上,我展示給你看的程式碼很少有優化的需求,以至於我在 PayPal 工作的3年里從未需要這樣做,甚至在我使用 React 更長的時間裡。

然而,有些情況下渲染可能會花費大量時間(比如重交互的圖表、動畫等)。多虧 React 的實用性,有一個逃生艙(escape hatch):

const CountButton = React.memo(function CountButton({onClick, count}) {   return <button onClick={onClick}>{count}</button>  })

現在 React 只會當 props 改變時會重新渲染 CountButton! 但我們還沒有完成,還記得引用相等嗎?在 DualCounter 組件中,我們組件函數里定義了 increment1increment2 函數,這意味著每次 DualCounter 重新渲染,那些函數會新創建,因此 React 無論如何會重新渲染兩個 CountButton

所以這是 useCallbackuseMemo 能派上用場的另外一個場景:

const CountButton = React.memo(function CountButton({onClick, count}) {   return <button onClick={onClick}>{count}</button>  })    function DualCounter() {   const [count1, setCount1] = React.useState(0)   const increment1 = React.useCallback(() => setCount1(c => c + 1), [])     const [count2, setCount2] = React.useState(0)   const increment2 = React.useCallback(() => setCount2(c => c + 1), [])     return (     <>       <CountButton count={count1} onClick={increment1} />       <CountButton count={count2} onClick={increment2} />     </>   )  }

現在我們可以避免 CountButton 的所謂「不必要的重新渲染」。

我想重申下,在沒有測量前,強烈建議不要使用 React.Memo (或者它的朋友 PureComponentshouldComponentUpdate),因為優化總會帶來成本,並且你需要確保知道會有多少成本和收益,這樣你才能決定在你的案例中它是否能真的有幫助(而不是有害的)。正如我們上面所說的那樣,一直保持正確是一件很困難的事情,所以你可能無法獲得任何好處

昂貴的計算

這是 useMemo 內置於 React 的另一個原因(注意這個不適用於 useCallback)。useMemo 的好處是你可以採用如下值:

    consta={b:props.b}

然後惰性獲取:

const a = React.useMemo(() => ({b: props.b}), [props.b])

這對於上面的情況並不是很有用,但是想像一下你有一個計算成本很高的同步計算值的函數(我的意思是有多少應用真實地需要 像這樣計算素數,但這就是一個例子):

function RenderPrimes({iterations, multiplier}) {    const primes = calculatePrimes(iterations, multiplier)    return <div>Primes! {primes}</div>  }

使用正確的 iterationsmultiplier 可能會非常緩慢,而且你沒有太多可以特別做的事情。你不能自動地用戶的硬體更快,但是你可以這樣做,這樣你就不必連續兩次計算相同的值,這就是 useMemo 為你所做的:

function RenderPrimes({iterations, multiplier}) {    const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [      iterations,      multiplier,    ])    return <div>Primes! {primes}</div>  }

可以這樣做的原因是,即使你在每次渲染時定義了計算素數的函數(非常快),React只在需要值時才調用該函數。除此之外,React還會在給定輸入的情況下存儲先前的值,並在給定跟之前相同輸入的情況下返回先前的值。這是 memoization 在起作用。

總結

最後,我想說,每個抽象(和性能優化)都是有代價的。應用 AHA 編程原則,直到確實需要抽象或優化時才去做,這樣可以避免承擔成本而不會獲得收益的情況。

具體來說,useCallbackuseMemo的成本是:對於你的同事來說,你使程式碼更複雜了;你可能在依賴項數組中犯了一個錯誤,並且你可能通過調用內置的 hook、並防止依賴項和 memoized 值被垃圾收集,而使性能變差。如果你獲得了必要的性能收益,那麼這些成本都是值得承擔的,但最好先測量一下

相關閱讀:

  • React FAQ: 「Are Hooks slow because of creating functions in render?」
  • Ryan Florence: React, Inline Functions, and Performance