談談 React 的新提案:useEvent
2022 年 5 月 5 日,Dan Abramov 在 React RFC 上提交了一個新 hook 的提案:useEvent。其目的是返回一個永遠引用不變(always-stable)的事件處理函數。
沒有 useEvent 時我們如何寫事件函數
首先我們來看一下這段代碼
function Chat() {
const [text, setText] = useState("");
const onClick = () => {
sendMessage(text);
};
return <SendButton onClick={onClick} />;
}
為了訪問最新的 state,onClick在每次Chat組件發生更新時,都會聲明一個新的函數(引用變化),這會導致SendButton組件每次都接受一個新的 prop,React 的比較兩個組件節點是否要 diff 前,會對 props 做淺比較(Object.is),所以每次 props 無意義的變化顯然是對 diff 性能不利的。
同時它還會破壞你的 memo 優化,比如你的SendButton做了如下設計:
const SendButton = React.memo(() => {});
這時你可能會想到使用useMemo或者useCallback來優化父組件的onClick函數
function Chat() {
const [text, setText] = useState("");
const onClick = useCallback(() => {
sendMessage(text);
}, [text]);
return <SendButton onClick={onClick} />;
}
但是這樣當text變化時,引用還是會變化,依然會帶來子組件的不必要更新,設計不當甚至會觸發子組件 useEffect 的 re-fired。SendButton根本不關心text的變化。而且當函數非常複雜時,可能會漏寫依賴(當然你可以通過 eslint 來保證),導致每次使用的都是初始 state,從而造成難以追蹤的 bug。
而新的 hook 提案 useEvent,你可以做到這樣:
function Chat() {
const [text, setText] = useState("");
const onClick = useEvent(() => {
sendMessage(text);
});
return <SendButton onClick={onClick} />;
}
onClick已經一直是引用不變的了,而且可以訪問到最新的 text。
useEvent 是如何實現的
它看上去好像很神奇,你也可以自己簡單實現一個類似的 hook,最核心的地方就是使用 useRef 維持最新引用以及緩存住外層的 function:
const useEvent = (eventHandler) => {
const eventHandlerRef = useRef(eventHandler);
// 每次useEvent被調用都返回不變的值,但內部實際執行的是最新的函數
return useMemo((...args) => {
return eventHandlerRef.current(...args);
}, []);
};
官方給的一個類似實現是這樣的:
// (!) 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);
}, []);
}
其實,真正的實現比起上述兩種方式要複雜一些,作為一個使用度極廣的框架,必須要需要考慮一些邊界條件和約束。
- 在組件 render 時使用被 useEvent 包裹的函數需要拋出錯誤。因為它的設計是為了包裹事件函數,事件函數不應該在 render 時調用。這也是為什麼上述代碼有
useLayoutEffect,它也保證了每次事件觸發時都是最新的,因為視圖/事件的更新一定在useLayoutEffect之後。同時,useEvent 內部修改 state 也是安全的,因為它不會在 render 期間被調用,不會修改組件的 output。 - 其實
handlerRef.current的更新發生在比所有useLayoutEffect更提前的時刻,這個保證了當 layout 時,不會存在舊版本的 handler,不會出現狀態割裂的問題 - 第 1 處的設計還間接的優化了服務端渲染的安全和性能,因為它不能在 render 時運行,而服務端是不存在事件的,避免了報錯。同時,既然 useEvent 對服務端渲染沒有意義,那麼服務端構建的包裏可以跳過 useEvent 的打包,優化了包體積。
你什麼時候不應該使用 useEvent
- 普通的函數(非事件回調)依然用原來的 useCallback
function ThemedGrid() {
const theme = useContext(ThemeContext);
const renderItem = useCallback(
(item) => {
// Called during rendering, so it's not an event.
return <Row {...item} theme={theme} />;
},
[theme]
);
return <Grid renderItem={renderItem} />;
}
因為有 render 時期的報錯機制,開發者也不太可能在這種場景下用 useEvent
- 不是所有的 useEffect 依賴函數都應該是事件
function Chat({ selectedRoom }) {
const { createKeys } = useContext(EncryptionSettings);
// ...
useEffect(() => {
const socket = createSocket("/chat/" + selectedRoom, createKeys());
// ...
socket.connect();
return () => socket.disconnect();
}, [selectedRoom, createKeys]); // ✅ Re-runs when room or createKeys changes
}
這裏的createKeys不應該使用 useEvent,因為 effect 中的函數不是事件,也不需要保持引用不變,因為它需要在createKeys變化時重新建立 socket
- 可能會導致 useEffect 不再響應式
下面是一個錯誤的寫法
function Chat({ selectedRoom, theme }) {
// ...
// 🔴 This should not be an event!
const createSocket = useEvent(() => {
const socket = createSocket("/chat/" + selectedRoom);
socket.on("connected", async () => {
await checkConnection(selectedRoom);
onConnected(selectedRoom);
});
socket.on("message", onMessage);
socket.connect();
return () => socket.disconnect();
});
useEffect(() => {
return createSocket();
}, []);
}
要知道一點的是,useEvent 是非響應式的。因為它是事件,最終會被動調用,並不需要隨着狀態變化而立即響應。所以當selectedRoom變化時,effect 不再重新建立 socket 了,儘管createSocket始終可以拿到最新的selectedRoom,但它需要的是主動觸發。
正確的寫法應該是使用useCallback且依賴selectedRoom,useEffect依賴useCallback
useEvent 的『缺點』是什麼
- 毫無疑問它增加了 hooks 的概念,帶來了更多的心智負擔,你需要判斷這裏該不該用 useEvent,還是用 useCallback
- 由於需要一個比 layoutEffect 更提前的時期,它不可避免的需要改動 fiber tree commit 階段的邏輯。但是相比於讓社區在第三方庫中自行提供各自的不完美的解決方案,這種付出還是值得的。
- 它的表現似乎超出了單純的 event 邊界,更應該叫
useStableCallback或者useCommittedCallback,官方給它取useEvent這一名字,是為了幫助開發者們更容易建立『它應該被用於事件』這一心智模式。 - 它有一些特殊的邊界條件下會出現問題,不過這主要是因為代碼編寫有問題帶來的,並不是它自身的問題。但正因為人是最難控制的,所以這種問題也是最難阻止的,開發者應該更注意自己的書寫規範:
比如 useEvent 裏面有異步邏輯
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 前後輸出值是一樣的,因為 await 後面的回調保存了 count 閉包。count 僅僅是本次 render 的狀態快照,所以函數內異步等待時,即便外部又把 count 改了,當前這次函數調用還是拿不到最新的 count,而 ref 方法是可以的。所以事件中儘量不要有異步。
另外還有『條件判斷式的 event』,比如你寫出了這樣的代碼onSomething={cond ? handler1 : handler2},自然是沒辦法幫你保持引用不變的。
此外在 react 更新中也會有『割裂』問題,unmounting layout effects 時使用的是上一次 render 時的 event,但是 非 layout effect 卸載時使用的是新版本的 event(下一次更新時的 event可能發生變化了)。這就類似於在 unmounting layout 和 non-layout effects 期間讀 ref 結果不一致的情況。
個人對 useEvent 的看法
useEvent 主要作用是維持引用不變的事件,可以用十分簡潔的代碼減少引用變化帶來的問題。但是它本身也帶來了更多的概念。正如上面的缺點裏寫的,你需要時刻注意那些問題。而且目前官方也依然有一些待解決的問題https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#unresolved-questions。總之對於這個 RFC 個人並沒有太多欣喜,將來有則用,畢竟是官方給出的最佳實踐,沒有也可以有其他解決辦法。