Hook 是 react 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 react 特性。
useCallback和useMemo是其中的兩個 hooks,本文旨在通過解決一個需求,結合高階函數,深入理解useCallback和useMemo的用法和使用場景。 之所以會把這兩個 hooks 放到一起說,是因為他們的主要作用都是性能優化,且使用useMemo可以實現useCallback。
需求說明
先把需求拎出來說下,然后順着需求往下捋useCallback和useMemo,這樣更好理解為什么要使用這兩個 hooks。需求是:當鼠標在某個 dom 標簽上移動的時候,記錄鼠標的普通移動次數和加了防抖處理后的移動次數。
技術儲備
本文主要介紹useCallback和useMemo,所以遇到useState時就不做特殊說明了,如果對useState還不了解,請參看官方文檔。
該需求需要用到防抖函數,為方便調試,先准備一個簡單的防抖函數(一個高階函數):
function debounce(func, delay = 1000) { let timer; function debounced(...args) { debounced.cancel(); timer = setTimeout(() => { func.apply(this, args); }, delay); } debounced.cancel = function () { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } } return debounced }
不合格的解決方案
根據需求,寫出來組件大致會是這樣:
function Example() { const [count, setCount] = useState(0); const [bounceCount, setBounceCount] = useState(0); const debounceSetCount = debounce(setBounceCount); const handleMouseMove = () => { setCount(count + 1); debounceSetCount(bounceCount + 1); }; return ( <div onMouseMove={handleMouseMove}> <p>普通移動次數: {count}</p> <p>防抖處理后移動次數: {bounceCount}</p> </div> ) }
效果貌似是對的,在debounced里打印日志看下:
function debounce(func, delay = 1000) { // ... 省略其他代碼 timer = setTimeout(() => { // 在此處添加了一行打印代碼 console.log('run-do'); func.apply(this, args); }, delay); // ... 省略其他代碼 }
當鼠標在div標簽上移動時,打印結果[如圖]:
我們發現,當鼠標停止移動后,run-do被打印的次數,跟鼠標移動次數相同,這說明防抖功能並未生效。是哪里出問題了呢?
首先我們要清楚的是,使用debounce的目的是通過debounce返回一個debounced函數(注意:此處是debounced,而不是debounce,下文同樣要注意這個細節,否則意思就完全不對了),然后每次執行debounced時,通過閉包內的timer清掉之前的setTimeout,達到一段時間不活動后執行任務的目的。
再來看看我們的Example組件,每次Example組件的更新渲染,都會通過debounce(setBounceCount)生成一個新的debounceSetCount,也就是每次的更新渲染,debounceSetCount都是指向不同的debounced,不同的debounced使用着不同的timer,那么debounce函數里的閉包就失去了意義,所以才會出現截圖中的情況。
但是,為什么bounceCount的值看着像是進行過防抖處理一樣呢?
那是debounceSetCount(bounceCount + 1)在多次執行時,因為debounce內的setTimeout使得bounceCount參數值是相同的,所以通過run-do的打印次數才把問題暴露了出來。
useCallback
我們使用useCallback修改下我們的組件:
function Example() { // ... 省略其他代碼 // 相比之前的 Example 組件,我們只是增加了 useCallback hook const debounceSetCount = React.useCallback(debounce(setBounceCount), []); // ... 省略其他代碼 }
這時再用鼠標在div標簽上移動時,效果跟我們的需求一致了,[如圖]:
通過useCallback,我們貌似解決了之前存在的問題(其實這里面還有問題,我們后面會說到)。
那么,useCallback是怎么解決問題的呢?
看下useCallback的調用簽名:
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: ReadonlyArray<any>): T; // 示例: const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], );
通過useCallback的簽名可以知道,useCallback第一個參數是一個函數,返回一個 memoized 回調函數,如上面代碼中的 memoizedCallback 。useCallback的第二個參數是依賴(deps),當依賴改變時才更新 memoizedCallback ,也就是在依賴未改變時(或空數組無依賴時), memoizedCallback 總是指向同一個函數,也就是指向同一塊內存區域。當把 memoizedCallbac 當作 props 傳遞給子組件時,子組件就可以通過shouldComponentUpdate等手段避免不必要的更新。
當Example組件首次渲染時,debounceSetCount的值是debounce(setBounceCount)的執行結果,因為通過useCallback生成debounceSetCount時,傳入的依賴是空數組,所以Example組件在下一次渲染時,debounceSetCount會忽略debounce(setBounceCount)的執行結果,總是返回Example第一次渲染時useCallback緩存的結果,也就是說debounce(setBounceCount)的執行結果通過useCallback緩存了下來,解決了debounceSetCount在Example每次渲染時總是指向不同debounced的問題。
我們上面說過,這里面其實還有一個問題,那就是每次Example組件更新的時候,debounce函數都會執行一次,通過上面的分析我們知道,這是一次無用的執行,如果此處的debounce函數里有大量的計算的話,就會很影響性能。
useMemo
看下使用useMemo如何解決這個問題呢:
function Example() { const [count, setCount] = useState(0); const [bounceCount, setBounceCount] = useState(0); const debounceSetCount = React.useMemo(() => debounce(setBounceCount), []); const handleMouseMove = () => { setCount(count + 1); debounceSetCount(bounceCount + 1); }; return ( <div onMouseMove={handleMouseMove} > <p>普通移動次數: {count}</p> <p>防抖處理后移動次數: {bounceCount}</p> </div> ) }
現在,每次Example更新渲染時,debounceSetCount都是指向同一塊內存,而且debounce只會執行一次,我們的需求完成了,我們的問題也都得到了解決。
useMemo是怎么做到的呢?
看下useMemo的調用簽名:
function useMemo<T>(factory: () => T, deps: ReadonlyArray<any> | undefined): T; // 示例: const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
通過useMemo的簽名可以知道,useMemo第一個參數是一個 factory 函數,該函數的返回結果會通過useMemo緩存下來,只有當useMemo的依賴(deps)改變時才重新執行 factory 函數,memoizedValue 才會被重新計算。 也就是在依賴未改變時(或空數組無依賴時),memoizedValue 總是返回通過useMemo緩存的值。
看到這里,相信細心的你也已經發現了,useCallback(fn, deps) 其實相當於 useMemo(() => fn, deps),所以在最開始我們說:使用useMemo完全可以實現useCallback。
廣州品牌設計公司https://www.houdianzi.com
特別注意
React 官方有這么一句話:
你可以把 useMemo 作為性能優化的手段,但不要把它當成語義上的保證。將來,React 可能會選擇“遺忘”以前的一些 memoized 值,並在下次渲染時重新計算它們,比如為離屏組件釋放內存。先編寫在沒有 useMemo 的情況下也可以執行的代碼 —— 之后再在你的代碼中添加 useMemo,以達到優化性能的目的。
顯然,我們的代碼中,如果去掉useMemo是會出問題的,對此,可能有人會想,改裝下debounce防抖函數就可以了,例如:
function debounce(func, ...args) { if (func.timeId !== undefined) { clearTimeout(func.timeId); func.timeId = undefined; } func.timeId = setTimeout(() => { func(...args); }, 200); } // 使用 useCallback function Example() { // ... 省略其他代碼 const debounceSetCount = React.useCallback((...args) => { debounce(setBounceCount, ...args); }, []); // ... 省略其他代碼 } // 不使用 useCallback function Example() { // ... 省略其他代碼 const debounceSetCount = changeCount => debounce(setBounceCount, changeCount); // ... 省略其他代碼 }
貌似去掉了useMemo也能實現我們的需求,但顯然,這是一種非常將就的解決方案,一旦遇到像修改前的debounce這樣的高階函數就束手無策了。