Vue转React学习笔记(1): 关于useEffect的困惑和思考
零、写在前面
- 之前实习和项目中都是学的Vue,由于之后工作大概率会进入React的技术生态,最近才开始学React并且缺少企业级的项目开发经验,也没有对源码做系统研究,以下的内容只是个人学习过程中的记录和思考,因此大概率会显得稚嫩而且主观,需要大佬们的指点和修正。
- 软件开发毕竟属于工程实践领域,对于相同的目标可以有不同的实现方案,而一样的技术在不同的场景下也需要做取舍权衡,它并不像数学物理这样的学科有一套不容置疑的公理。而且对于团队开发者而言,一套技术的开发体验、学习成本、认知对齐等因素也同样重要,它们应该是带有主观色彩的,至少是可讨论的;
- 为了描述简便,以下会根据官方文档的写法和称呼,对于
useEffect(setup,dependencies),将渲染中产生的副作用称为Effect,将第一个函数参数称为setup,将依赖数组简称为deps,将setup返回的清理函数称为cleanup。 - 文中提到的观点有些是React官方的提倡,有些是笔者自己的思考,请注意辨别,官方怎么说不代表我们真的要那么做/想。关于这个API,其实在各种React社区已经做出了大量讨论了,本文可以说是一个读后感。
- 以下提到的Vue指的是3.0+版本,而React指的是React18+的函数式组件+hook模式。
一、Vue视角的对比:一些粗浅的理解
由于之前有Vue框架的学习经验,因此在学习新的框架的时候,主播总会习惯上联想到Vue中相对应的Api,并且官方文档和大多数技术教程都会提到三种不同deps对应的情景和用法。
(一) 三种依赖情景
1. 不传任何dep——类比onMounted和onUpdated
不传递任何依赖的时候,函数式组件首次渲染及之后更新,都会setup函数都会执行。对Vue来讲也是类似的,组件首次挂载执行onMounted和onUpdated钩子,区别在于Vue的onUpdated钩子的使用频率是很低的。
2. 传递空数组——类比onMounted
传递空数组意味着dep永远不变,setup函数只会在组件挂载的时候运行一次
3. 传递依赖值——类比watch和watchEffect
依赖值发生变化的时候,注册的回调函数会重新执行
useEffect(() => {
// 会在每次渲染后运行
});
useEffect(() => {
// 只会在组件挂载(首次出现)时运行
}, []);
useEffect(() => {
// 会在组件挂载时运行,而且当 a 或 b 的值自上次渲染后发生变化后也会运行
}, [a, b]);
(二) 清理函数
cleanup函数会在调用和组件卸载的时候执行,Vue中也提供了相关的功能,比如在onUnmounted的时候执行卸载逻辑,watch和watchEffect都能返回函数以用于清理副作用。
watchEffect(() => {
// 副作用逻辑:创建定时器
const timer = setInterval(() => {
count.value++
}, 1000)
// 清理函数:清除上一轮的定时器
return () => {
clearInterval(timer)
console.log('定时器已清除')
}
})
(三) 思维差异
对于Vue开发者或者React类组件的开发者来说,很容易用生命周期的组件思维来理解hook,然而官方觉得这对Effect来讲是不适宜的。如果用Vue角度来类比,大概率会觉得useEffect起到了一个“监听器”的作用,这引发了后面关于是否使用effect和如何写deps数组的差异。
二、如何理解副作用:区分Event和Effect
(一) 副作用的定义
React hook倡导的是UI=f(state)的函数式编程理念,理论上组件函数应该是纯函数,也就是相同的输入返回相同的输出,并且不依赖也不影响外部系统,也就是不和外部产生任何交互。
// 非纯函数
const fn=(arr)=>{
arr.push(1) // 修改了外部的参数
}
在日常开发中产生副作用的频率是很高的,常见的比如:
- 发送http请求:和服务器交互
- 操作dom元素:和浏览器API交互
- 定时器:和浏览器API交互
- 甚至console.log也是
很多文章提到,useEffect是用来管理副作用的,但如果抱有一遇到“网络请求”“定时器”之类的操作,就全部放进useEffect处理,这肯定是不对的,至少不符合官方推荐的理念。
事实上,官方文档多次强调要区分由用户交互引起的副作用event和由渲染过程引起的Effect
(二) 区分event和Effect
Effect 允许你指定由渲染自身,而不是特定事件引起的副作用。
当你决定将某些逻辑放入事件处理函数还是 Effect 中时,你需要回答的主要问题是:从用户的角度来看它是 怎样的逻辑。如果这个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数(event handler)中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。
1. 官方文档的例子
假设你正在实现一个聊天室组件,需求如下:
- 组件应该自动连接选中的聊天室。
- 每当你点击“Send”按钮,组件应该在当前聊天界面发送一条消息。
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
2. 日常项目中常见的例子
比如我们点击按钮切换不同的数据类型,需要发送网络请求拉取最新数据,用effect需要这样写
useEffect(()=>{
fetchData(type)
},[type])
这样就会带来一个问题,比如页面中有一些情况会改变type,但是那个场景下不需要发送请求,却仍旧触发了Effect,因此我们需要在setup函数里添加判断:
useEffect(()=>{
if (type==='1'){
fetchData(type)
}
},[type])
未来,假设业务需求变更,你的请求又依赖了另一个参数userId,你需要在dep中添加另一个数据,甚至新数据也会再页面其他地方被修改,你需要对新数据进行条件判断。这样的话,代码的维护成本将会增高不少。
useEffect(()=>{
if (type==='1' && userId){
fetchData(type,userId)
}
},[type,userId])
但实际上,假如请求这是点击事件触发的,官方更推荐把它直接写在事件处理函数里。这样写的好处是:只有当用户点击按钮这个事件触发的时候才会发送请求,而不是type变化的时候发送请求,从而做到切断type和useEffect的逻辑关系,让发送请求专属于点击事件。
function handleClick(e){
const type=e.target.type
fetchData(type)
}
在常见的管理页中,搜索框输入,搜索框点击,分页页码,下拉菜单选项都有可能触发重新请求,那么应该怎么办呢?
我们可能会这么写:
useEffect(()=>{
fetchData({
pageNum,
pageSize,
searchKey,
selectType
})
},
[pageNum,pageSize,searchKey,selectType]
)
大多数人我感觉也会这么写,把useEffect当成“监听”来使用,当数据变化时,拿着最新的数据发送请求,好像也没什么问题。
但是按照React官方的说法,每个事件函数里都要发送一次请求吗?我的答案是:也许是的。。。但其实也没那么复杂,多写几次而已,前提是把fetchData提前封装好。
const handleSearch = () => {
setPageNum(1);
fetchData(); // 调用统一请求函数
};
const handlePageChange = (newPageNum, newPageSize) => {
setPageNum(newPageNum);
setPageSize(newPageSize);
fetchData(); // 调用统一请求函数
};
const handleSelectChange = (value) => {
setSelectType(value);
setPageNum(1);
fetchData(); // 调用统一请求函数
};
(三) 响应式 VS 命令式——我们真的需要“监听”吗?
扯远一点,或许再考虑一个问题,我们真的在React中需要实现“监听”吗?
在JavaScript层面,我们永远做不到对一个变量是否被改变进行监听,Vue和React作为JS框架当然都无法改变这一点。那为什么Vue可以做到监听呢?笔者觉得这涉及到两个框架对于响应式设计在理念上的巨大差异,也就是响应式编程和命令式编程。
简单讲,Vue主打一个“自动化”,基于发布订阅模式,当你声明一个数据需要为“响应式”的时候,框架内部会利用getter和setter帮你自动地进行依赖的收集和触发更新。
function reactive(target) {
return new Proxy(target, {
get(obj, key) {
track(obj, key); // 启动依赖跟踪
return Reflect.get(obj, key);
},
set(obj, key, value) {
Reflect.set(obj, key, value);
trigger(obj, key); // 触发副作用
return true;
}
});
}
而React则把控制权交给开发者,类似于“手动挡”,根据UI=f(state)的理念,React中只有状态这个概念,因此开发者必须手动管理好每一个状态,通过调用setState进行状态修改,由React来帮你自动重渲染并更新。这样一来,每一处变动都是开发者手动触发的,React自然没有进行自动监听的义务。
所以笔者觉得,或许当我们真的需要用React来进行自动监听的时候,应该持有的实现思路可能是像Vue一样基于JS的发布订阅这条路去实现getter和setter,而不是useEffect。
幸运的是,我们已经拥有了第三方库Mobx,它便是基于Vue响应式中的getter和setter模式,实现了自动监听。假如你使用React + Mobx则完全不需要手动setState了,可以看到两个框架的设计思想正在发生一定程度的交融。
import {observable, computed} from "mobx";
class OrderLine {
@observable price = 0;
@observable amount = 1;
constructor(price) {
this.price = price;
}
// 很像Vue的computed
@computed get total() {
return this.price * this.amount;
}
}
const todos = observable([
{
title: "Make coffee",
done: true,
},
]);
// reaction 很像watch
const reaction = reaction(
() => todos.map(todo => todo.title),
titles => console.log("reaction 2:", titles.join(", "))
);
// autorun 很像watchEffect
const autorun1 = autorun(
() => console.log("autorun 1:", todos.map(todo => todo.title).join(", "))
);
三、巨大的心智负担:deps到底怎么写
从以上例子可以看出,Effect的创作者担心开发人员理解不了这个hook,因此用了大量的案例来提示使用者在某些场景下别用Effect,但用不用Effect可能还不是最大的争议,useEffect钩子最大的争议点应该是在于依赖数组,也就是deps该怎么写,是否应该写完整。
(一) 官方的推荐:写全deps数组
文档中提到:任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中。这里的响应式值可能含有:
-
- state
-
- props
-
- 所有在组件函数中用到的普通函数和普通变量
也就是所有在setup中用到,并且函数组件作用域内的可能变化的值,都需要添加到dep中。
正是这一点,给开发者带来巨大的心智负担,即使官方推荐安装eslint的插件辅助我们补全deps,但实践中还是将它设置为warning而并非error,这仅仅相当于起到了一个心理安慰的作用。
特别是当dep用到了函数的时候,可能会导致useCallback嵌套的问题
//组件函数体内部
function init(){
// 每次重渲染都会产生新引用
}
useEffect(()=>{
init(data) // 会频繁触发
},[data,init])
添加useCallback
const initFn=useCallback(init,[dep])
// 如果dep里面还有函数,可能需要再次包裹useCallback,代码可读性会降低很多
useEffect(()=>{
initFn(data) // 会频繁触发
},[data,initFn])
(二) 先实现setup,再确定deps,而不是反过来
官方文档提到,dep数组是完全由setup函数确定的,而不是由开发者选择的
个人是这么理解的:
-
- 你应该先确定这里的副作用是什么
- dep是副作用的过滤器,通过指定dep来跳过副作用的执行,保证性能
- 同样地,如果要修改dep,应该先修改setup,而不是反过来
- 如果抱着“监听”的思维去理解,会先确定dep(你要监听的东西)再实现setup,那样的话,你可能会漏写dep
四、如何建立自己的最佳实践
既然useEffect的心智负担那么重,我们应该这么做,以下是一点个人的思考
1.尽量多使用成熟的第三方库,减少自己裸写useEffect的情景
- 状态管理上,已经有很多第三方库帮你解决数据的收集和更新的问题了,比如Mobx和Zustand,本质上都是发布订阅的模式
- 发送请求上,优先使用Tanstack-Query、SWR、ahooks中的useRequest等第三方库,来发送请求,帮你管理请求中的各种问题(包括网络竞态、数据缓存、loading error管理等),比自己写useEffect要稳得多。
2.可以使用useEffect的情况
- 需要操作原生dom元素的情况:比如手动设置元素的
scrollTop的值 - 确保副作用仅一次性执行,可以使用空数组的场景:比如初始化第三方SDK
- 监听浏览器原生事件的情况:比如窗口大小变化
window.resize - 需要和外部环境进行同步的情况:比如websocket链接,SSE链接等
- 你觉得自己完全搞懂了useEffect的话可以用。。。(反正我是没搞懂。。)
3.协作开发的过程需要和团队对齐观念
如果是团队开发的话,要能看懂并理解别人的代码,而不是强迫别人理解自己的观点。团队的领导者最好能制定一份团队的开发规范,拉齐所有人的技术认知。当然这不是我这种新手该考虑的东西,只能说领导让怎么做就怎么做QvQ。
五、写在最后
作为一点Vue转React的新手,有几点个人的主观感受想提一下
- React只是一个JS库,它没有义务覆盖前端所有的业务场景,也没义务提供所谓的最佳实践,而是让开发者自己选择,这种理念应该是可以理解的。
- 但不管怎么说,
useEffect都是一个认知曲线极其陡峭、给开发者造成巨大心智负担的API,从开发文档的篇幅以及React提出的useEffectEvent就可以看出来,但其实都是在缝缝补补。 - 官方对这个API的设计理念,和开发者的日常用法存在不少的割裂,这也是官方文档用大部分篇幅来讲这个hook的原因,就是为了让开发者少用,少滥用。
- 从体验上看,Vue即使对底层原理不那么熟悉也能进行开发,但React的开发过程中,还是需要不断按照React的机制去模拟整个流程,所以理解原理还是比较重要。至于这是不是框架在甩锅给开发者,就见仁见智了。
六、参考
- 《为什么我们要删掉100%的useEffect》:www.yuque.com/jiango/code…
- 《React hook使用误区,驳官方文档》:zhuanlan.zhihu.com/p/450513902
- 《A Complete Guide to useEffect》:overreacted.io/a-complete-…
- 《精读useEffect完全指南》:zhuanlan.zhihu.com/p/60277120
- React官方文档《脱围机制》:zh-hans.react.dev/learn/escap…
- React官方文档 useEffect: zh-hans.react.dev/reference/r…