博客 / 詳情

返回

解讀 React useEvent RFC

談談 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);
  }, []);
}

其實,真正的實現比起上述兩種方式要複雜一些,作為一個使用度極廣的框架,必須要需要考慮一些邊界條件和約束。

  1. 在組件 render 時使用被 useEvent 包裹的函數需要拋出錯誤。因為它的設計是為了包裹事件函數,事件函數不應該在 render 時調用。這也是為什麼上述代碼有useLayoutEffect,它也保證了每次事件觸發時都是最新的,因為視圖/事件的更新一定在useLayoutEffect之後。同時,useEvent 內部修改 state 也是安全的,因為它不會在 render 期間被調用,不會修改組件的 output。
  2. 其實handlerRef.current的更新發生在比所有useLayoutEffect更提前的時刻,這個保證了當 layout 時,不會存在舊版本的 handler,不會出現狀態割裂的問題
  3. 第 1 處的設計還間接的優化了服務端渲染的安全和性能,因為它不能在 render 時運行,而服務端是不存在事件的,避免了報錯。同時,既然 useEvent 對服務端渲染沒有意義,那麼服務端構建的包裏可以跳過 useEvent 的打包,優化了包體積。

你什麼時候不應該使用 useEvent

  1. 普通的函數(非事件回調)依然用原來的 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

  1. 不是所有的 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

  1. 可能會導致 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且依賴selectedRoomuseEffect依賴useCallback

useEvent 的『缺點』是什麼

  1. 毫無疑問它增加了 hooks 的概念,帶來了更多的心智負擔,你需要判斷這裏該不該用 useEvent,還是用 useCallback
  2. 由於需要一個比 layoutEffect 更提前的時期,它不可避免的需要改動 fiber tree commit 階段的邏輯。但是相比於讓社區在第三方庫中自行提供各自的不完美的解決方案,這種付出還是值得的。
  3. 它的表現似乎超出了單純的 event 邊界,更應該叫useStableCallback或者useCommittedCallback,官方給它取useEvent這一名字,是為了幫助開發者們更容易建立『它應該被用於事件』這一心智模式。
  4. 它有一些特殊的邊界條件下會出現問題,不過這主要是因為代碼編寫有問題帶來的,並不是它自身的問題。但正因為人是最難控制的,所以這種問題也是最難阻止的,開發者應該更注意自己的書寫規範:

比如 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 個人並沒有太多欣喜,將來有則用,畢竟是官方給出的最佳實踐,沒有也可以有其他解決辦法。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.