大家好,我卡頌。
我們知道,Hooks
使用時存在所謂的閉包陷阱,考慮如下程式碼:
function Chat() {
const [text, setText] = useState('');
const onClick = useCallback(() => {
sendMessage(text);
}, []);
return <SendButton onClick={onClick} />;
}
我們期望點選後sendMessage
能傳遞text
的最新值。
然而實際上,由於回撥函式被useCallback
快取,形成閉包,所以點選的效果始終是sendMessage('')
。
這就是閉包陷阱。
以上程式碼的一種解決方式是為useCallback增加依賴項:
const onClick = useCallback(() => {
sendMessage(text);
}, [text]);
但是這麼做了後,每當依賴項(text
)變化,useCallback
會返回一個全新的onClick
引用,這就失去了useCallback
快取函式引用的作用。
閉包陷阱的出現,加大了Hooks
的上手門檻,也讓開發者更容易寫出有bug
的程式碼。
現在,React
官方團隊要出手解決這個問題。
歡迎加入人類高質量前端框架群,帶飛
useEvent
解決方式是引入一個新的原生Hook
—— useEvent
。
他用於定義一個函式,這個函式有2個特性:
- 在元件多次
render
時保持引用一致 - 函式內始終能獲取到最新的
props
與state
上面的例子使用useEvent
改造後:
function Chat() {
const [text, setText] = useState('');
const onClick = useEvent(() => {
sendMessage(text);
});
return <SendButton onClick={onClick} />;
}
在Chat
元件多次render
時,onClick
始終指向同一個引用。
並且onClick
觸發時始終能獲取到text
的最新值。
之所以叫useEvent
,是因為React
團隊認為這個Hook
的主要應用場景是:封裝事件處理函式。
useEvent的實現
useEvent
的實現並不困難,程式碼類似如下:
function useEvent(handler) {
const handlerRef = useRef(null);
// 檢視渲染完成後更新`handlerRef.current`指向
useLayoutEffect(() => {
handlerRef.current = handler;
});
// 用useCallback包裹,使得render時返回的函式引用一致
return useCallback((...args) => {
const fn = handlerRef.current;
return fn(...args);
}, []);
}
整體包括兩部分:
- 返回一個沒有依賴項的
useCallback
,使得每次render
時函式的引用一致
useCallback((...args) => {
const fn = handlerRef.current;
return fn(...args);
}, []);
- 在合適的時機更新
handlerRef.current
,使得實際執行的函式始終是最新的引用
與開源Hooks的差異
很多開源Hooks
庫已經實現類似功能(比如ahooks
中的useMemoizedFn
)
useEvent
與這些開源實現的差異主要體現在:
useEvent
定位於處理事件回撥函式這一單一場景,而useMemoizedFn
定位於快取各種函式。
那麼問題來了,既然功能類似,那useEvent
為什麼要限制自己的使用場景呢?
答案是:為了更穩定。
useEvent
能否獲取到最新的state
與props
取決於handlerRef.current
更新的時機。
在上面模擬實現中,useEvent
更新handlerRef.current
的邏輯放在useLayoutEffect
回撥中進行。
這就保證了handlerRef.current
始終在檢視完成渲染後再更新:
useLayoutEffect(() => {
handlerRef.current = handler;
});
而事件回撥觸發的時機顯然在檢視完成渲染之後,所以能夠穩定獲取到最新的state
與props
。
注:原始碼內的實際更新時機會更早些,但不影響這裡的結論
再來看看ahooks
中的useMemoizedFn
,fnRef.current
的更新時機是useMemoizedFn執行時(即元件render時):
function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
// 更新fnRef.current
fnRef.current = useMemo(() => fn, [fn]);
// ...省略程式碼
}
當React18
啟用併發更新後,元件render
的次數、時機並不確定。
所以useMemoizedFn
中fnRef.current
的更新時機也是不確定的。
這就增加了在併發更新下使用時潛在的風險。
可以說,useEvent
通過限制handlerRef.current
更新時機,進而限制應用場景,最終達到穩定的目的。
總結
useEvent
當前還處於RFC(Request For Comments)階段。
很多熱心的開發者對這個Hook
的命名提出了建議,比如:useStableCallback
:
又比如:useLatestClosure
:
從這些命名看,他們顯然擴大了useEvent
的應用場景。
經過本文的分析我們知道,擴大應用場景意味著增加開發者使用時出錯的風險。