精讀《React useEvent RFC》

黃子毅 發表於 2022-05-16
React

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} />
}

這種程式碼能解決問題,但絕對不推薦,原因有二:

  1. 每個值都要加一個配套 Ref,非常冗餘。
  2. 在函式內直接同步更新 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);
  }, []);
}

其實很好理解,我們將需求一分為二看:

  1. 既然要返回一個穩定引用,那最後返回的函式一定使用 useCallback 並將依賴陣列置為 []
  2. 又要在函式執行時訪問到最新值,那麼每次都要拿最新函式來執行,所以在 Hook 裡使用 Ref 儲存每次接收到的最新函式引用,在執行函式時,實際上執行的是最新的函式引用。

注意兩段註釋,第一個是 useLayoutEffect 部分實際上要比 layoutEffect 執行時機更提前,這是為了保證函式在一個事件迴圈中被直接消費時,可能訪問到舊的 Ref 值;第二個是在渲染時被呼叫時要丟擲異常,這是為了避免 useEvent 函式被渲染時使用,因為這樣就無法資料驅動了。

精讀

其實 useEvent 概念和實現都很簡單,下面我們聊聊提案裡一些有意思的細節吧。

為什麼命名為 useEvent

提案裡提到,如果不考慮名稱長短,完全用功能來命名的話,useStableCallbackuseCommittedCallback 會更加合適,都表示拿到一個穩定的回撥函式。但 useEvent 是從使用者角度來命名的,即其生成的函式一般都被用於元件的回撥函式,而這些回撥函式一般都有 “事件特性”,比如 onClickonScroll,所以當開發者看到 useEvent 時,可以下意識提醒自己在寫一個事件回撥,還算比較直觀。(當然我覺得主要原因還是為了縮短名稱,好記)

值並不是真正意義上的實時

雖然 useEvent 可以拿到最新值,但和 useCallbackref 還是有區別的,這個差異體現在:

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 實現嗎?

不能。雖然提案裡給了一個近似解決方案,但實際上存在兩個問題:

  1. 在賦值 ref 時,useLayoutEffect 時機依然不夠提前,如果值變化後理解訪問函式,拿到的會是舊值。
  2. 生成的函式被用在渲染並不會給出錯誤提示。

總結

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 許可證