關注 小賊先生,查看更多前端文章
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
。
特別注意
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
這樣的高階函數就束手無策了。
那麼,如果不使用useMemo
,你有什麼好的解決方案呢,歡迎留言討論。
關注 小賊先生,查看更多前端文章