likes
comments
collection
share

解读 React useEvent RFC

作者站长头像
站长
· 阅读数 15

谈谈 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 个人并没有太多欣喜,将来有则用,毕竟是官方给出的最佳实践,没有也可以有其他解决办法。