React 新出的 useEvent,到底是什麼東西?

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

如果你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

版權聲明:自由轉載 - 非商用 - 非衍生 - 保持署名(創意共享 3.0 許可證)

前端從進階到入院 我是 ssh,只想用最簡單的方式把原理講明白。wx:sshsunlight,分享前端的前沿趨勢和一些有趣的事情。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/npKuhfHft9dTLBlG0hjAGA