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的新状态,而不是闭包中首次的旧状态。
那么解决方案的话:
-
用ref追踪最新值 -
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.5.15)这个时间点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>
);
}
来看这个例子,先来说下设计意图:
-
用了useEvent保证两个函数的引用不变 -
想在每次渲染的时候读取count最新值 -
想每次渲染结束的时候读取count的最新值
意图1显然可以实现没有问题。
意图2也可以实现,每次读取的都是本次渲染的最新值
但意图3则有些问题,因为这里读取到的是下一次渲染的最新值。也就是说onEnter和onLeave取得的不是相同的值,这显然是不符合预期的。
也就是说,你点击一次,onEnter输出的0,而onLeave输出的是1。
这种行为和React的自身流程有关,React的流程是:
-
App函数执行 -
执行上次的渲染的cleanup,也就是useEffect中return的函数 -
执行本次渲染的effect
而我们useEvent的hack实现中,是在App函数执行阶段替换的event内容。
也就是说是替换后才轮到cleanup执行,所以cleanup中调用event获取的下次渲染的值。
这里还是有点小饶,我明晰一下整个流程:
-
首次App函数执行 -
替换event内容,当前读取的count为0 -
执行effect,也就是onEnter,输出0 -
点击触发re-render,App函数再次执行 -
替换event内容,当前读取的count为1 -
执行上次渲染的cleanup,也就是onLeave,输出1 -
执行effect,也就是onEnter,输出1 -
后续是同样的逻辑,不再继续表述
这个流程已经非常明晰了,现在可以继续说下解决的问题了。
首先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的顺序:
-
首次App函数执行 -
layout effect -
effect -
点击触发re-render,App函数再次执行 -
对上次的layout effect进行cleanup -
本次的layout effect -
对上次的effect进行cleanup -
本次的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为空的分支,什么也不会发生。
事情到了这一步,似乎有些没法收场了,我们反思一下问题到底出在哪里?
我们回到起点,useEvent的功能其实就是两个:
-
保持引用不变 -
解决闭包
表面看起来是2无法解决,因为effect和cleanup中无法使用同一个闭包中的值。
但根源其实是1,如果不保持引用不变,直接使用原函数,那effect和cleanup永远都使用的是同一个闭包中的值,也就没有这么多事了。
所以保持引用不变其实是成本很高的,我们必须反思一下,保持引用不变到底应不应该?
保持引用不变的理由,最常见的有:
-
callback作为props时避免多余的re-render -
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>
);
}
这是一个搜索场景,我理解支持保持引用不变的人就是想写这种代码,他们觉得:
-
只有在query更新后,fetchData的引用才更新,这样Child就可以进行memo,避免query以外的state改变导致函数引用改变,进而导致不必要的re-render -
在Child中fetchData是deps,如果不用useCallback,那父组件任何无关变量导致的re-render都会导致引用改变,进而导致子组件中进行多余的effect。
所以说,useCallback既避免了多余的re-render,又避免了多余的effect,实在是太好啦,必须都给我用起来。
然而其实不然,存在更好的解决方案。
实际上,任何callback都可以拆解为纯函数+state.
我们只需要遵循以下原则:
-
我们永远不传函数props,也不把函数作为deps。 -
我们只传state props,只把state作为deps。 -
我们把callback拆解成纯函数+state。 -
至于纯函数,我们以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,只需要给它传query参数就可以了,也就避免了多余的effect。
至此,保持引用不变的两个目的都已经达成了,并且这个方案:
-
代码量毫无疑问更少,没有用到useCallback,deps也少了,用思考的点毫无疑问少了 -
fetchData这个强耦合query的callback拆解成了query+纯函数,耦合性也减少了
所以,我的结论是,保持函数引用不变绝大多数情况下就是个伪需求,完全有更好的解决方案,至于基于引用不变思路下解决闭包的useEvent API,似乎也是和useCallback一样,没多大必要的。
参考资料:
龙背上的骑兵:探讨一下 React useEvent 的实现
杨健:React Hooks(二): useCallback 之痛
本文转自 https://zhuanlan.zhihu.com/p/514584329,如有侵权,请联系删除。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/20433.html