useEvent 要解決一個問題:如何同時保持函式引用不變與訪問到最新狀態。
本週我們結合 RFC 原文與解讀文章 What the useEvent React hook is (and isn't) 一起了解下這個提案。
借用提案裡的程式碼,一下就能說清楚 useEvent
是個什麼東西:
function Chat() {
const [text, setText] = useState('');
// ✅ Always the same function (even if `text` changes)
const onClick = useEvent(() => {
sendMessage(text);
});
return <SendButton onClick={onClick} />;
}
onClick
既保持引用不變,又能在每次觸發時訪問到最新的 text
值。
為什麼要提供這個函式,它解決了什麼問題,在概述裡慢慢道來。
概述
定義一個訪問到最新 state 的函式不是什麼難事:
function App() {
const [count, setCount] = useState(0)
const sayCount = () => {
console.log(count)
}
return <Child onClick={sayCount} />
}
但 sayCount
函式引用每次都會變化,這會直接破壞 Child
元件 memo 效果,甚至會引發其更嚴重的連鎖反應(Child
元件將 onClick
回撥用在 useEffect
裡時)。
想要保證 sayCount
引用不變,我們就需要用 useCallback
包裹:
function App() {
const [count, setCount] = useState(0)
const sayCount = useCallback(() => {
console.log(count)
}, [count])
return <Child onClick={sayCount} />
}
但即便如此,我們僅能保證在 count
不變時,sayCount
引用不變。如果想保持 sayCount
引用穩定,就要把依賴 [count]
移除,這會導致訪問到的 count
總是初始值,邏輯上引發了更大問題。
一種無奈的辦法是,維護一個 countRef,使其值與 count 保持同步,在 sayCount
中訪問 countRef
:
function App() {
const [count, setCount] = useState(0)
const countRef = React.useRef()
countRef.current = count
const sayCount = useCallback(() => {
console.log(countRef.current)
}, [])
return <Child onClick={sayCount} />
}
這種程式碼能解決問題,但絕對不推薦,原因有二:
- 每個值都要加一個配套 Ref,非常冗餘。
- 在函式內直接同步更新 ref 不是一個好主意,但寫在
useEffect
裡又太麻煩。
另一種辦法就是自創 hook,如 useStableCallback
,這本質上就是這次提案的主角 - useEvent
:
function App() {
const [count, setCount] = useState(0)
const sayCount = useEvent(() => {
console.log(count)
})
return <Child onClick={sayCount} />
}
所以 useEvent
的內部實現很可能類似於自定義 hook useStableCallback
。在提案內也給出了可能的實現思路:
// (!) Approximate behavior
function useEvent(handler) {
const handlerRef = useRef(null);
// In a real implementation, this would run before layout effects
useLayoutEffect(() => {
handlerRef.current = handler;
});
return useCallback((...args) => {
// In a real implementation, this would throw if called during render
const fn = handlerRef.current;
return fn(...args);
}, []);
}
其實很好理解,我們將需求一分為二看:
- 既然要返回一個穩定引用,那最後返回的函式一定使用
useCallback
並將依賴陣列置為[]
。 - 又要在函式執行時訪問到最新值,那麼每次都要拿最新函式來執行,所以在 Hook 裡使用 Ref 儲存每次接收到的最新函式引用,在執行函式時,實際上執行的是最新的函式引用。
注意兩段註釋,第一個是 useLayoutEffect
部分實際上要比 layoutEffect
執行時機更提前,這是為了保證函式在一個事件迴圈中被直接消費時,可能訪問到舊的 Ref 值;第二個是在渲染時被呼叫時要丟擲異常,這是為了避免 useEvent
函式被渲染時使用,因為這樣就無法資料驅動了。
精讀
其實 useEvent
概念和實現都很簡單,下面我們聊聊提案裡一些有意思的細節吧。
為什麼命名為 useEvent
提案裡提到,如果不考慮名稱長短,完全用功能來命名的話,useStableCallback
或 useCommittedCallback
會更加合適,都表示拿到一個穩定的回撥函式。但 useEvent
是從使用者角度來命名的,即其生成的函式一般都被用於元件的回撥函式,而這些回撥函式一般都有 “事件特性”,比如 onClick
、onScroll
,所以當開發者看到 useEvent
時,可以下意識提醒自己在寫一個事件回撥,還算比較直觀。(當然我覺得主要原因還是為了縮短名稱,好記)
值並不是真正意義上的實時
雖然 useEvent
可以拿到最新值,但和 useCallback
拿 ref
還是有區別的,這個差異體現在:
function App() {
const [count, setCount] = useState(0)
const sayCount = useEvent(async () => {
console.log(count)
await wait(1000)
console.log(count)
})
return <Child onClick={sayCount} />
}
await
前後輸出值一定是一樣的,在實現上,count
值僅是呼叫時的快照,所以函式內非同步等待時,即便外部又把 count
改了,當前這次函式呼叫還是拿不到最新的 count
,而 ref
方法是可以的。在理解上,為了避免夜長夢多,回撥函式儘量不要寫成非同步的。
useEvent 也救不了手殘
如果你堅持寫出 onSomething={cond ? handler1 : handler2}
這樣的程式碼,那麼 cond
變化後,傳下去的函式引用也一定會變化,這是 useEvent
無論如何也避免不了的,也許解救方案是 Lint and throw error。
其實將 cond ? handler1 : handler2
作為一個整體包裹在 useEvent
就能解決引用變化的問題,但除了 Lint,沒有人能防止你繞過它。
可以用自定義 hook 代替 useEvent 實現嗎?
不能。雖然提案裡給了一個近似解決方案,但實際上存在兩個問題:
- 在賦值 ref 時,
useLayoutEffect
時機依然不夠提前,如果值變化後理解訪問函式,拿到的會是舊值。 - 生成的函式被用在渲染並不會給出錯誤提示。
總結
useEvent
顯然又給 React 增加了一個官方概念,在結結實實增加了理解成本的同時,也補齊了 React Hooks 在實踐中缺失的重要一環,無論你喜不喜歡,問題就在那,解法也給了,挺好。
討論地址是:精讀《React useEvent RFC》· Issue #415 · dt-fe/weekly
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)