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的新状态,而不是闭包中首次的旧状态。
那么解决方案的话:
到了这里,就引起争议了。
很多开发者开始抵制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显然可以实现没有问题。
意图2也可以实现,每次读取的都是本次渲染的最新值
但意图3则有些问题,因为这里读取到的是下一次渲染的最新值。也就是说onEnter和onLeave取得的不是相同的值,这显然是不符合预期的。
也就是说,你点击一次,onEnter输出的0,而onLeave输出的是1。
这种行为和React的自身流程有关,React的流程是:
而我们useEvent的hack实现中,是在App函数执行阶段替换的event内容。
也就是说是替换后才轮到cleanup执行,所以cleanup中调用event获取的下次渲染的值。
这里还是有点小饶,我明晰一下整个流程:
这个流程已经非常明晰了,现在可以继续说下解决的问题了。
首先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的顺序:
以上顺序是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的功能其实就是两个:
表面看起来是2无法解决,因为effect和cleanup中无法使用同一个闭包中的值。
但根源其实是1,如果不保持引用不变,直接使用原函数,那effect和cleanup永远都使用的是同一个闭包中的值,也就没有这么多事了。
所以保持引用不变其实是成本很高的,我们必须反思一下,保持引用不变到底应不应该?
保持引用不变的理由,最常见的有:
下面给一个说明以上两点的经典例子(例子的来源是《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>
- );
- }
这是一个搜索场景,我理解支持保持引用不变的人就是想写这种代码,他们觉得:
所以说,useCallback既避免了多余的re-render,又避免了多余的effect,实在是太好啦,必须都给我用起来。
然而其实不然,存在更好的解决方案。
实际上,任何callback都可以拆解为纯函数+state.
我们只需要遵循以下原则:
只需要按照这四点操作,就不再需要保证引用不变:
- 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。
至此,保持引用不变的两个目的都已经达成了,并且这个方案:
所以,我的想法是,保持函数引用不变绝大多数情况下就是个伪需求,完全有更好的解决方案,至于基于引用不变思路下解决闭包的useEvent API,似乎也是和useCallback一样,没多大必要的。