关于useEvent的思考

useEvent这个Hook最近很火,我也查阅了一下这个Hook的相关资料。

useEvent这个API实际上通常来说就是useCallback这个钩子的改良版。

useCallback这个钩子开发者会怎么使用呢?

其实主要就是为了保证函数引用不变使用。

但是这种使用下会存在比较烦人的闭包问题:

import {useCallback, useState} from 'react';

export default function App() {
  const [counter, setCounter] = useState(0);

  const handleClick = useCallback(() => {
    console.log(counter);
    setCounter(counter => counter + 1);
  }, []);

  return (
    <div onClick={handleClick}>
      click to add counter
      counter: {counter}
    </div>
  )
}

这个例子中,你会发现DOM上的counter更新了,但事件处理函数中的获取的counter始终为0。

这是因为你在事件处理函数中访问到的是闭包中的变量。

因为在App函数首次执行完毕后,JS引擎发现函数作用域内的counter和setCounter两个变量会被事件处理函数持续引用,但是执行上下文切换后这俩变量就会销毁,所以引擎会创建一个闭包保存这两个变量,而事件处理函数中的变量则会链接上闭包,因此访问的永远是首次渲染时创建的闭包中的变量。

但是我们通常是希望访问最近的那次re-render的新状态,而不是闭包中首次的旧状态。

那么解决方案的话:

  1. 用ref追踪最新值
  2. deps数组中增加counter

到了这里,就引起争议了。

很多开发者开始抵制useCallback这个API,因为保持引用不变这种模式并不是必要的,还引起了闭包问题要解决。

但另一派则坚持保持引用不变的模式。

后者的声量并不比前者少,或许正是因此,React预备推出useEvent这个API,来保证引用不变的同时可以访问到最新的状态:

import {useState, useEvent} from 'react';

export default function App() {
  const [counter, setCounter] = useState(0);

  const handleClick = useEvent(() => {
    console.log(counter);
    setCounter(counter => counter + 1);
  });

  return (
    <div onClick={handleClick}>
      click to add counter
      counter: {counter}
    </div>
  )
}

当然在(2022.6.23)这个时间点React还没有推出这个API,因此上面只是看看。

我们不妨来hack一下这个API:

import {useCallback, useRef, useState} from 'react';

function useEvent(callback) {
  const callbackRef = useRef(null);

  callbackRef.current = callback;

  const event = useCallback((...args) => {
      if (callbackRef.current) {
          callbackRef.current.apply(null, args);
      }
  }, []);

  return event;
}

export default function App() {
  const [counter, setCounter] = useState(0);

  const handleClick = useEvent(() => {
    console.log(counter);
    setCounter(counter => counter + 1);
  });

  return (
    <div onClick={handleClick}>
      click to add counter
      counter: {counter}
    </div>
  )
}

不难理解这个useEvent的实现,保持引用不变这个功能当然还是用useCallback实现。

而使用最新值这个功能,稍微需要思考下,实际上是把新的函数传进来了。

可以看到使用useEvent和直接用useCallback不一样的地方在于,useEvent每次渲染都会创建一个新的事件处理函数,这个事件处理函数中访问的是最新的状态,所以每次都用ref把引用不变的event壳子内部的实际任务更新成最新的事件处理函数就可以了:callbackRef.current = callback;

最终event这个引用不变的壳子内部调用的是每次渲染都会改变的处理函数:

callbackRef.current.apply(null, args);

听起来很完美,实际效果看起来也符合预期,但是其实并没有那么好:

import { useEffect, useCallback, useRef, useState } from "react";

function useEvent(callback) {
  const callbackRef = useRef(null);

  callbackRef.current = callback;

  const event = useCallback((...args) => {
    if (callbackRef.current) {
      callbackRef.current.apply(null, args);
    }
  }, []);

  return event;
}

export default function App() {
  const [count, setCount] = useState(0);

  const onLeave = useEvent(() => {
    console.log("onleave:", count);
  });

  const onEnter = useEvent(() => {
    console.log("onenter:", count);
  });

  const onClick = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    onEnter();

    return () => {
      onLeave();
    };
  });

  return (
    <div onClick={onClick}>click to add counter counter: {count}</div>
  );
}

来看这个例子,先来说下设计意图:

  1. 用了useEvent保证两个函数的引用不变
  2. 想在每次渲染的时候读取count最新值
  3. 想每次渲染结束的时候读取count的最新值

意图1显然可以实现没有问题。

意图2也可以实现,每次读取的都是本次渲染的最新值

但意图3则有些问题,因为这里读取到的是下一次渲染的最新值。也就是说onEnter和onLeave取得的不是相同的值,这显然是不符合预期的。

也就是说,你点击一次,onEnter输出的0,而onLeave输出的是1。

这种行为和React的自身流程有关,React的流程是:

  1. App函数执行
  2. 执行上次的渲染的cleanup,也就是useEffect中return的函数
  3. 执行本次渲染的effect

而我们useEvent的hack实现中,是在App函数执行阶段替换的event内容。

也就是说是替换后才轮到cleanup执行,所以cleanup中调用event获取的下次渲染的值。

这里还是有点小饶,我明晰一下整个流程:

  1. 首次App函数执行
  2. 替换event内容,当前读取的count为0
  3. 执行effect,也就是onEnter,输出0
  4. 点击触发re-render,App函数再次执行
  5. 替换event内容,当前读取的count为1
  6. 执行上次渲染的cleanup,也就是onLeave,输出1
  7. 执行effect,也就是onEnter,输出1
  8. 后续是同样的逻辑,不再继续表述

这个流程已经非常明晰了,现在可以继续说下解决的问题了。

首先cleanup的时机就是在App执行后,这点肯定是难以改变的。

那么我们能做的也只有更改替换event内容的时机,目前是在位置5,如果移动到位置7就可以了。

因为在位置7更新,位置6cleanup还是使用的和上次effect中一样的旧值,就符合预期了:

function useEvent(callback) {
  const callbackRef = useRef(null);

  useEffect(() => {
    callbackRef.current = callback;
  });

  const event = useCallback((...args) => {
    if (callbackRef.current) {
      callbackRef.current.apply(null, args);
    }
  }, []);

  return event;
}

然而事情完美解决了吗?

并没有,因为还有个我们之前没有考虑的useLayoutEffect,这个API用得少一些,我们先列下layout effect、effect以及它们的cleanup的顺序:

  1. 首次App函数执行
  2. layout effect
  3. effect
  4. 点击触发re-render,App函数再次执行
  5. 对上次的layout effect进行cleanup
  6. 本次的layout effect
  7. 对上次的effect进行cleanup
  8. 本次的effect

以上顺序是React的设计结果,要牢记这个表,很重要。

不管React为什么这么设计,总之我们要在既定事实下从表中选择event内容的更新时机,从头开始:

在1和2之间的话,前面已经得到了不行的结论,因为会导致onLeave和onEnter不一致。

在2和3之间的话,也就是在layout effect中更新的话,效果和上面一样。注意看6和7,React内部会先layout effect再执行上次effect的cleanup方法,所以仍然会导致更新在cleanup之前,还是不行。

至于3之后的任何时机,也都不行,因为太靠后了,如果在这个时机更新,那么就无法在layout effect中调用事件了。结合例子来说,就是首次渲染的layout effect中调用onEnter会命中current为空的分支,相当于头一枪在layout effect中会稳定打空,这显然不符合预期。

事情到了这一步,似乎有些没法收场了,我们反思一下问题到底出在哪里?

我们回到起点,useEvent的功能其实就是两个:

  1. 保持引用不变
  2. 解决闭包

表面看起来是2无法解决,因为effect和cleanup中无法使用同一个闭包中的值。

但根源其实是1,如果不保持引用不变,直接使用原函数,那effect和cleanup永远都使用的是同一个闭包中的值,也就没有这么多事了。

所以保持引用不变其实是成本很高的,我们必须反思一下,保持引用不变到底应不应该?

保持引用不变的理由,最常见的有:

  1. callback作为props时避免多余的re-render
  2. callback作为deps时避免多余的effect

下面给一个说明以上两点的经典例子(例子的来源是《React Hooks(二): useCallback 之痛》):

function Child(props) {
  console.log("rerender:");
  const [result, setResult] = useState("");
  const { fetchData } = props;
  useEffect(() => {
    fetchData().then((result) => {
      setResult(result);
    });
  }, [fetchData]);
  return (
    <>
      <div>query:{props.query}</div>
      <div>result:{result}</div>
    </>
  );
}
export function Parent() {
  const [query, setQuery] = useState("react");
  const fetchData = useCallback(() => {
    const url = "<https://hn.algolia.com/api/v1/search?query=>" + query;
    return fetch(url).then((x) => x.text());
  }, [query]);
  return (
    <div>
      <input onChange={(e) => setQuery(e.target.value)} value={query} />
      <Child fetchData={fetchData} query={query} />
    </div>
  );
}

这是一个搜索场景,我理解支持保持引用不变的人就是想写这种代码,他们觉得:

  1. 只有在query更新后,fetchData的引用才更新,这样Child就可以进行memo,避免query以外的state改变导致函数引用改变,进而导致不必要的re-render
  2. 在Child中fetchData是deps,如果不用useCallback,那父组件任何无关变量导致的re-render都会导致引用改变,进而导致子组件中进行多余的effect。

所以说,useCallback既避免了多余的re-render,又避免了多余的effect,实在是太好啦,必须都给我用起来。

然而其实不然,存在更好的解决方案。

实际上,任何callback都可以拆解为纯函数+state.

我们只需要遵循以下原则:

  1. 我们永远不传函数props,也不把函数作为deps。
  2. 我们只传state props,只把state作为deps。
  3. 我们把callback拆解成纯函数+state。
  4. 至于纯函数,我们以export和import的方式复用。

只需要按照这四点操作,就不再需要保证引用不变:

import {useState, useEffect} from 'react';

function Child(props) {
  console.log("rerender:");
  const [result, setResult] = useState("");
  const {query} = props;
  useEffect(() => {
    fetchData(query).then((result) => {
      setResult(result);
    });
  }, [query]);
  return (
    <>
      <div>query:{query}</div>
      <div>result:{result}</div>
    </>
  );
}

const fetchData = query => {
  const url = "<https://hn.algolia.com/api/v1/search?query=>" + query;
  return fetch(url).then((x) => x.text());
}

export default function Parent() {
  const [query, setQuery] = useState("react");
  
  return (
    <div>
      <input onChange={(e) => setQuery(e.target.value)} value={query} />
      <Child query={query} />
    </div>
  );
}

其实就是按照上述的原则,把fetchData变成了纯函数,纯函数可以干净地export和import,不需要props传递,用的时候传入参数就可以用。

这就解决了保持引用不变的目的1,避免多余的re-render。

至于目的2,也不攻自破,因为fetchData就是一个纯的工具函数,根本不需要把它作为deps,因为它被改造成了永远不需要更新的、忠实地根据输出返回结果、状态无关的工具(而不是你需要仔细研究代码、弄清楚到底什么时候会更新的、代码一复杂就难以debug的状态强耦合的讨厌事物)。只需要给它传query参数就可以了,也就避免了多余的effect。

至此,保持引用不变的两个目的都已经达成了,并且这个方案:

  1. 代码量毫无疑问更少,没有用到useCallback,deps也少了,用思考的点毫无疑问少了
  2. fetchData这个强耦合query的callback拆解成了query+纯函数,耦合性也减少了

所以,我的想法是,保持函数引用不变绝大多数情况下就是个伪需求,完全有更好的解决方案,至于基于引用不变思路下解决闭包的useEvent API,似乎也是和useCallback一样,没多大必要的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值