前端框架

-

useEffectEvent 教學 - 身為 React 開發者該熟悉的 Hooks

this.web

useEffectEvent 封面

身為 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)}
    />
);
}

不過就算這樣,仍然很難閱讀,其他工程師看到程式碼,很難第一時間理解 soundEnabledsoundEnabledRef 的差別。

而且我們可能會忘記同步 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 就會是目前最合適的選擇。

你可能會感興趣的文章 👇