Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React useEvent:砖家说的没问题 #50

Open
brickspert opened this issue May 15, 2022 · 0 comments
Open

React useEvent:砖家说的没问题 #50

brickspert opened this issue May 15, 2022 · 0 comments

Comments

@brickspert
Copy link
Owner

brickspert commented May 15, 2022

之前写了一篇文章《React Hooks 使用误区,驳官方文档》,文中抛出了两个观点:

  1. 不是所有的依赖都必须放到依赖数组中
  2. deps 参数不能缓解闭包问题

这两个观点引起了剧烈的讨论,当然大多数人还是持反对意见的,甚至质疑我不会用 Hooks,(⊙o⊙)… 我想说我写的 Hooks 比你吃的盐都多(开玩笑 😋 ~)

然后呢,知乎上来了个提问《如何看待《React Hooks 使用误区,驳官方文档》?》,大家依旧是讨论激烈,甚至 #黄玄 大佬也亲自来回答了。

很多同学极力反对我的观点,刚开始我还想一争高下,后来实在没精力一个一个对线。

这不,React 官方来帮我助阵了?React 官方为啥出 useEvent?就是发现以前要求的依赖写法,实在有太大问题,不加一个新的 API,官方示例都没法写了 🙂。

image.png

以前一直觉得 React Hooks 教程,包括 Dan 写的 useEffect 教程,都只是写了基础场景,对于稍微复杂点的场景,都避而不谈。因为这些复杂场景,在之前的规则下,确实是没法玩。

什么是 useEvent

关于 useEvent 是什么,官方 RFC 文档有非常详细的解释,并且目前社区上也有非常多的文章介绍(其实很多介绍都是有问题的)。接下来用一个官方文档上的一个例子,来认识一下 useEvent。
需求很简单,我们希望 url 变化的时候,上报下当前 urlusername

function Page({ route, currentUser }) {
  useEffect(() => {
    logAnalytics('visit_page', route.url, currentUser.name);
  }, [route.url]);
  // ...
}

如上代码,会有 warning,告诉我们 currentUser.name要放到 deps 中。修正后代码是这样

function Page({ route, currentUser }) {
  useEffect(() => {
    logAnalytics('visit_page', route.url, currentUser.name);
  }, [route.url, currentUser.name]);
  // ...
}

但这样明显满足不了我们的业务需求,因为 currentUser.name变化后,也触发了上报请求。

很多杠精就问,为啥你的需求要这样设计?为啥 currentUser.name变化后不要上报?你的需求不合理吧?
这个你去问 dan 吧~

以前的解决方案可能有两个:

  1. 忽略警告,把 eslint-plugin-react-hooks卸载掉
  2. 通过 ref 来标记 currentUser.name
function Page({ route, currentUser }) {
  const ref = useRef(currentUser.name);
  ref.current = currentUser.name;

  useEffect(() => {
    logAnalytics('visit_page', route.url, ref.current);
  }, [route.url]);
  // ...
}

两个方案都有缺点:

  1. 打破了所谓的 React 对 deps 的限制规则
  2. 写法太麻烦,项目复杂后要定义无数个 ref

基于 useEvent 改造起来就很简单了

function Page({ route, currentUser }) {
  // ✅ Stable identity
  const onVisit = useEvent(visitedUrl => {
    logAnalytics('visit_page', visitedUrl, currentUser.name);
  });

  useEffect(() => {
    onVisit(route.url);
  }, [route.url]); // ✅ Re-runs only on route change
  // ...
}

useEvent 会将一个函数「持久化」,同时可以保证函数内部的变量引用永远是最新的。如果你用过 ahooks 的 useMemoizedFn,实现的效果是几乎一致的。
再强调下 useEvent 的两个特性:

  1. 函数地址永远是不变的
  2. 函数内引用的变量永远是最新的

useEvent 可以用来代替 useCallback,以前这样写,在 text 变化的时候,函数地址会变化。

function Chat() {
  const [text, setText] = useState('');

  // 🟡 A different function whenever `text` changes
  const onClick = useCallback(() => {
    sendMessage(text);
  }, [text]);

  return <SendButton onClick={onClick} />;
}

通过 useEvent 代替 useCallback 后,不用写 deps 函数了,并且函数地址永远是固定的,text也永远是最新的。

function Chat() {
  const [text, setText] = useState('');

  // ✅ Always the same function (even if `text` changes)
  const onClick = useEvent(() => {
    sendMessage(text);
  });

  return <SendButton onClick={onClick} />;
}

useEvent 是怎么实现的

useEvent 的实现原理比较简单,但现在看到的社区上的介绍文章几乎都有问题。

// (!) 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);
  }, []);
}

上面的代码是官方提供的一个示例代码,需要重点注意这句注释 In a real implementation, this would run before layout effects,翻译过来就是 “在真实的实现中,这里用的 Hooks 执行时机在 useLayoutEffect之前”。

这里一定是不能用 useLayoutEffect来更新 ref的,因为子组件的 useLayoutEffect比父组件的执行更早,如果这样用的话,子组件的 useLayoutEffect中访问到的 ref一定是旧的。

所以官方为了实现 useEvent,一定是要加一个在 useLayoutEffect 之前执行的 Hooks 的,并且这个 Hooks 应该不会开放给普通用户使用的。

另外 React 要求不要在 render 中直接调用 useEvent返回的函数,原理也是一样的,在 render 中访问的函数一定是旧的,因为 useLayoutEffect还没执行呢。

useMemoizedFn 和 useEvent 的差异

在 React 18 之前,社区上有很多类似 useEvent 的实现,比如 ahooks 的 useMemoizedFn,类似下面这样

function useMemoizedFn(fn) {

  const fnRef = useRef(fn);
  fnRef.current = useMemo(() => fn, [fn]);

  return useCallback((...args) => {
    return fnRef.current.apply(args);
  }, []);
}

之前很多同学问,为啥不用 useLayoutEffect,是不是有问题?现在应该明白了吧?我们需要一个比useLayoutEffect执行更早的 Hooks,很遗憾的是之前更没有,所以只能放到 render 中。

为什么之前官方没有提供类似的 Hooks?useMemoizedFn 有问题吗?
之前 React Issue #16956 上对类似的封装做了很多讨论,官方的态度一直是 “在 concurrent 下可能会存在问题” ,也就是官方也吃不准未来会不会出问题。随着 React 18 发布,concurrent 模式稳定之后,官方发现,这种写法不会有问题,索性就自己提供了一个。

在 React 18 之前,因为没有 concurrent,所以 useMemoizedFn 不会有任何问题。在 React 18 之后,我目前也没看到有什么问题。不过为了稳妥起见,后面 ahooks 的 useMemoizedFn 会做一次升级,向官方的 useEvent 看齐。

最后用知乎上一个同学的评论结尾“面多了加水,水多了加面”。

images?url=https%3A%2F%2Fintranetproxy alipay com%2Fskylark%2Flark%2F0%2F2021%2Fpng%2F112013%2F1640597326837-3ff62a59-0406-4505-9f30-69ca7e4ce587 png sign=9879b951034975fc72f598b112e81678b7ac62298fb0e5c2223271a978c34555

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant