前端框架
-useEffectEvent 教學 - 身為 React 開發者該熟悉的 Hooks
this.web

身為 React 開發者,你使用過最新的 useEffectEvent 了嗎?
今天這篇文章,要帶你好好了解這個最新的 Hooks,讓你寫出更乾淨的程式碼~!
在開始之前,我們先從 useEffect 長期存在的一個問題談起。
useEffect 的問題
React 的 useEffect 一直是一個不容易用好的 Hooks,
尤其是我們需要和外部系統連接,而且又需要讀取最新的 state 時。
比如以下程式碼,使用者可以決定再收到訊息時,要不要打開音效通知:
function ChatConnection({ channelId }) {
const [soundEnabled, setSoundEnabled] = useState(true);
useEffect(() => {
const socket = joinChannel(channelId);
socket.on("message", () => {
// 當通知音效打開時,播放音效
if (soundEnabled) {
playSound();
}
});
return () => socket.leave();
}, [channelId, soundEnabled]); // 要將 soundEnabled 加入依賴陣列
return ( ... );
}理論上,在這種場景 React 會要求你把這個 State 加進依賴陣列裡面,
但一旦這麼做,Effect 就會在 State 每次變動時重新執行,導致重新連結聊天室。
除了浪費效能以外,整個程式碼的邏輯也很不正確,因為開啟音效不應該重連聊天室。
在過去,我們可以使用 useRef 去解決這個問題。
useEffectEvent 前的解法 — useRef
我們可以使用 useRef 建立一個不會改變參考位置的變數,
這樣就不需要將其傳入依賴陣列裡面。
function ChatConnection({ channelId }) {
const [soundEnabled, setSoundEnabled] = useState(true);
// 建立 ref
const soundEnabledRef = useRef(soundEnabled);
useEffect(() => {
const socket = joinChannel(channelId);
socket.on("message", () => {
if (soundEnabledRef.current) {
playSound();
}
});
return () => socket.leave();
}, [channelId]); // 不需要將 soundEnabledRef 加入依賴陣列
return ( ... );
}但這樣還不夠,還需要同步 ref 和 state,大部分人的解法是使用 useEffect 來同步兩者:
function ChatConnection({ channelId }) {
const [soundEnabled, setSoundEnabled] = useState(true);
const soundEnabledRef = useRef(soundEnabled);
// 同步 state 和 ref
useEffect(() => {
soundEnabledRef.current = soundEnabled;
}, [soundEnabled]);
useEffect(() => {
const socket = joinChannel(channelId);
socket.on("message", () => {
if (soundEnabledRef.current) {
playSound();
}
});
return () => socket.leave();
}, [channelId]); // 不需要將 soundEnabledRef 加入依賴陣列
return ( ... );
}但如果你有了解 useEffect 的目的就會知道,Effect 主要是要和外部系統連結的,這種寫法除了不符合設計目的以外,也讓程式碼變得很難理解、很難閱讀。
因此更好的做法應該是在事件中去改變 Ref 的值:
function ChatConnection({ channelId }) {
const [soundEnabled, setSoundEnabled] = useState(true);
const soundEnabledRef = useRef(soundEnabled);
// 在事件內同步 state 和 ref
const enableSound = (value) => {
setSoundEnabled(value);
soundEnabledRef.current = value;
};
useEffect(() => {
// 省略連結程式碼 ...
}, [channelId]);
return (
...
<input
type="checkbox"
checked={soundEnabled}
onChange={(e) => enableSound(e.target.checked)}
/>
);
}不過就算這樣,仍然很難閱讀,其他工程師看到程式碼,很難第一時間理解 soundEnabled 和 soundEnabledRef 的差別。
而且我們可能會忘記同步 Ref 和 State,或是忘記使用 .curretn 拿到 Ref 的值。
因此,React 官方終於意識到這個問題,推出了 useEffectEvent Hooks 來解決這個問題!
useEffectEvent:現代解法
useEffectEvent 接收一個函式,並回傳一個參考位置穩定的事件處理函式。
這個函式在被呼叫時,永遠會使用最新 Render 中的 State 與 Props。
所以我們可以將上面的程式碼改成這樣:
function ChatConnection({ channelId }) {
const [soundEnabled, setSoundEnabled] = useState(true);
const onMessage = useEffectEvent(() => {
// 這裡永遠可以安全地讀取最新的 state
if (soundEnabled) {
playSound();
}
})
useEffect(() => {
const socket = joinChannel(channelId);
socket.on("message", onMessage);
return () => socket.leave();
}, [channelId]);
return ( ... );
}
這裡有一個非常重要的特性:
onMessage不需要放進 dependency array,因為他是一個穩定的參考值- Effect 是否重新執行,只取決於
channelId
這樣除了不需要手動同步 Ref 和 State 以外,也分離的連結系統和事件本身的邏輯。讓程式碼的可讀性大大增加。
useEffectEvent 的原理解釋
其實 useEffectEvent 不是什麼魔法,它的內部邏輯其實很單純:
function useEffectEvent(callback) {
// 這個 ref 用來保存「最新一次 render 的 callback」
const latestCallbackRef = useRef(callback);
// 每次 render 後,同步更新 ref
// 這會在所有 Effect 執行之前完成
latestCallbackRef.current = callback;
// 回傳一個參考位置固定的函式
// 無論被呼叫多少次,都會執行最新的 callback
const stableWrapper = useCallback((...args) => {
return latestCallbackRef.current(...args);
}, []);
return stableWrapper;
}現實的 useEffectEvent 會更複雜,因為他需要和 react 的 fiber 架構整合在一起
總結
React 的 useEffectEvent 終於解決了一個 useEffect 長期存在的問題:
Effect 裡面的事件處理必須要讀取最新的 State,這個 State 會導致 Effect 內部的連結系統重新執行。
過去我們只能透過 useRef 手動拆解這兩件事,但代價就是程式碼冗長且語意不清楚,導致容易出錯。
現在如果你的 Effect 需要註冊事件、訂閱外部系統,而這些事件又必須使用最新的 State 時,useEffectEvent 就會是目前最合適的選擇。
參考連結