TLDR
阅读指南
因为本文篇幅较长,大家可以按章节顺序阅读,也可以先挑感兴趣的章节先行阅读:
-
如果你是一个很注重代码质量的前端团队 leader,可以先阅读第三章“现实中的 useEffect 地狱”,看看你们团队有没有同样的问题。
-
如果你是一个已经能够熟练使用
useEffect的前端开发者,并且很自信能避开所有的useEffect“陷阱”,建议直接阅读第五章中的“Just let it throw !”这一小节,并基于你的开发经验,想一想副作用和同步的模型到底比生命周期模型(不是类组件)好在哪? -
如果你也感觉
useEffect很难用,每次使用都要小心翼翼怕出问题,编码时的心智负担很大,建议直接阅读第四章“全新的 useInit 解决方案”。 -
如果你并没有觉得
useEffect有多难用,但是也不知道自己的用法对不对,建议还是先去阅读 React 官方文档 脱围机制(Escape Hatches)中关于 Effect 的 5 篇教程(内容也非常多,重点看前三篇),然后再来阅读本文第五章“Effect 和“同步””,因为这一章对官方文档自身的问题进行了深入的分析。
本文各章节简介
-
第一章:可以选择仅阅读加粗部分,主要是讲背景和目标,移除所有 useEffect 是我们实现目标的关键过程之一。
-
第二章:回顾了笔者从传统的 Class 组件过渡到 React Hook 时的学习感受。
-
第三章:列举了我们团队在实际使用 useEffect 时遇到的各种问题,这些问题大大降低了我们在复杂中后台场景中多人协作时的研发效率。
-
第四章:一套全新的 React Hook 编码范式,介绍如何用 Init Hook 和少量的 Watch Hook 来完全代替 useEffect。
-
第五章:辩证地分析了 React Hook 中 Effect 概念和“同步”的设计哲学,并讨论了这些设计到底带来了什么收益。
-
第六章:补充介绍了 useInit 解决方案的设计由来,帮助大家更好的理解这套新的 React Hook 编码范式。
重点内容标记
如果你想快速阅读本文,可以重点关注以下加粗内容:
- 一般的重点内容会使用黑色加粗字体
- 文章论述过程中重要的转折点会使用红色加粗字体
- 重要结论类的内容会使用橘橙色加粗字体
- 不同方案对比后的优势点会使用绿色加粗字体
降级阅读方案
junhao.ao 分享的 Grok 总结版 《 这篇万字长文实在让我没有耐心看完,帮我讲讲这篇文章讲了什么内容 》 ,看过之后不得不感慨 Grok 确实很厉害,只有少部分内容总结得不太准确(可能是一些图片等内容的识别没做好),满分 10 分的话,我能给 8.8 分。不过个人还是建议感兴趣的同学直接看原文,或者看完 Grok 总结版后再针对感兴趣的内容来阅读本文。
Grok 总结内容部分截图:
欢迎讨论
欢迎各种形式的友好讨论:
在此先行感谢每一位参与讨论的开发者。
正文分界线
一、背景
1、聊聊代码屎山
最近几年学习到了一个很重要的观点:在重构一个屎山项目时,最重要的指标就是如何保证下一次重构不会很快到来,所以重构的技术方案中必须要包含如何让代码“保鲜”的方法,否则重构结束后,换一几拨人维护几个来回,整个代码仓库又会迅速劣化成屎山。
知乎问题 **“为什么祖传代码被称为「屎山」?” **中 程墨Morgan的回答 从经济学的角度分析了写出好代码的客观条件。不过其中说到的管理层问题我认为未必是因为短视,而是视角不一样:作为程序员我们肯定希望有充足的时间和精力去做最好的设计,写最好的代码;但是在竞争激烈的市场环境中,很多时候快速支持业务落地才能抢占市场份额和用户心智,因此就有了各种倒排项目。当然作为打工人谁也不想天天加班,也有一些情况需要有高质量代码的支撑,在这些场景中就必须投入充足的时间和精力去做好编码前的设计工作:比如已经初步占据市场的产品需要通过周期性的高效迭代进一步巩固市场地位,比如公司的战略就是通过越级的产品体验来吸引用户……
这些思考让我觉得干净整洁的代码可能在很多时候就是不符合经济学规律的。 举个不恰当的比喻:我们无法要求一座摩天大楼的施工现场在施工期间每天都保持一尘不染、各类管线布局做到最好的模块化和最好的布局设计,能保证最基本的安全和质量这两个要求就已经非常好了。如果长远的未来业务需要转型,没有人能预知“在这座大楼上进行翻修改造”和“换地皮用新技术建造一栋新的大楼”哪个能让我们完成未来的商业目标。
不过作为技术人员,特别是当我们要继续维护和迭代屎山代码时,除了不断地提高对屎山代码的适应能力,更要从屎山代码的问题中反思如何去避坑。
在这个过程中,我想起了多年前当入行时被推荐阅读过的一本关于软件构建的巨著 《 Code Complete 代码大全 》 ,当年只是浅读了几章之后就再也没有看过。在回看所有我们遇到的问题时,其实绝大多数的解决思路或者说最佳实践早已在这本编写于 2006 年的书中被列出来了,只是因为书中文字比例较高,且列举的各种代码示例所用的编程语言和我们目前工作中用的技术栈并不一样,所以很难一下子代入到日常的编码工作中,但是关于软件构建的核心思想都是一样的,只是需要我们看这本书时多花点时间,静下心来将这些理念应用到当下的编码工作中。
下面我基于自己的理解来介绍两个编码过程中要确保的要点,也是本文的出发点,在 《 Code Complete 代码大全 》 中也有类似的理念介绍:
2、确保代码意图清晰明确
React Hook 发布于 2018 年,最近一次看到讨论官方 Hook 的技术文章是关于 useMemo 和 useCallback 的《 请删掉99%的useMemo 》和《「好文翻译」为什么你可以删除 90% 的 useMemo 和 useCallback ?》。对于这两个 Hook,我个人的结论更简单粗暴:
尽量别在业务代码中使用 useMemo 和 useCallback,纯纯增加代码噪音,不用这两个 Hook 不需要理由,用到的地方则需要明确说明理由。
因为很多页面的性能/体验短板根本不在 js 的执行阶段,我们完全可以等到页面真的遇到此类性能问题时再来考虑这些优化方案,并且在函数组件的时代,PureComponent 式的优化方案在迭代中是极其脆弱的,应该优先用其他方式解决性能问题,比如更合理的组件设计。
提到以上这两篇文章的原因是,文章中提到的一个问题点和本文的其中一个出发点是十分类似的:
即我们如何保证自己代码的意图在后续的迭代中被正确且精准地传达给后来人而不被破坏?否则我们就只能不断地去品味屎山代码,艰难地从中抿出前人的智慧,因为在每个项目的开发中,我们都既是前人,也是后来人。
3、确保代码结构的一致性
本文的另一个出发点起源于我个人:在看自己过往写的业务代码时,发现如果让我再写一次,在一些逻辑细节的设计上,可能会有点不一样。并不是因为这段时间间隔中自己有了什么成长或者新的见解,而是当时写代码的时候有 Way A 和 Way B 两种思路确实差不多,随便选了 A(确实差不多所以当时想再多也没用,一些不影响最终效果的实现细节我们也没时间去纠结)。过段时间后再来看这个代码我会再次纠结一会:“是不是用 Way B 很好呢,所以这次的新增功能我试试用 Way B 迭代上去吧” or “之前既然用的是 Way A 那么继续用 Way A 吧,保持一致性”。导致实际上后续每次迭代功能时我都要纠结一遍,代码一致性也没那么好。
作为一个老牌强迫症,我决定给自己定一个清晰的规则,在实现同一类功能时,尽量让代码能够保持跨越时间的结构一致性,但是很快就发现,我要花更多的精力和一起协作的同事去沟通和同步这些想法,而且这个进展很慢且不可复制。 因此我发现这个问题对团队的意义比对个人要大得多,在开发协作中我们每天都在遇到代码结构一致性、以及规范执行一致性等问题的挑战。
4、更高的目标
上述这些问题最终会影响整个项目长期的可读性和可维护性,直接决定团队平时的开发效率,间接影响系统的稳定性。而且只有解决了以上这两个问题,我们在平时开发的时候才有精力真正去关注和讨论一些更高级、更重要的东西,例如怎么将复杂的需求转变为拥有最佳设计的业务组件、如何将复杂的业务逻辑进行合理的分层拆分等等。
要解决上述的两个问题,其实要求很简单,落地却很难:
-
代码意图的正确传达:代码可以看上去平平无奇但一定要确保最高的可读性,必要时写一些注释,千万别在业务代码中炫技,越接近自然语言的代码可读性越强。要是写得连产品都能看得懂,你是最牛的!
-
保持代码结构一致性:对能用的开发工具进行精挑细选,缩减开发中实现方式的可选项。在最新版本的海拉鲁大陆中,你只能装备大师之剑,余料建造也不行,赶紧去救公主,越快越好!
在这个过程中,我们发现 useEffect 是一个绕不过去的话题。当然也有其他话题,我们内部暂且把这些话题都归到了前端的研发范式中。在正文具体展开之前容许我先叠个甲:本文的部分观点较为主观 ,因为没有条件进行详尽的沟通调研和数据统计,但是欢迎大家参与讨论。
二、初见 React Hook
1、第一印象
我大概在 19 年开始学习和使用 React Hook,作为一个有 Class Component 类组件开发经验的新手,简单回忆和总结了当时通过官方文档学习函数组件过程中的关键疑问/感慨:
- 每次函数组件 update 就是简单地把最外层函数重新执行一遍,拿到 UI 视图渲染结果,那么:
-
- 既然每次执行都是独立的,那组件内多次调用 useState 时为什么不需要传入特定的 key 来区分这些 state,不然应该会乱套?(答案是基于顺序而不是基于 key,类似 Map 和 Array 的区别)
- 每次执行的时候所有方法都要重新定义一遍,感觉相比类组件,好浪费啊。用了 useCallback 也避免不了定义环节,只是每次定义完了,再来个判断逻辑来决定要不要用,感觉更浪费了!(专门有个 FAQ 也讲到这个了)
- 当我开始试图在函数组件中寻找对标类组件生命周期的解决方案时,我对 useEffect 的理解如下:
-
-
当 useEffect 第二个参数不传时,就等同于 componentDidUpdate 这个类组件中的生命周期。
-
当 useEffect 依赖传入空数组 [] 时可以实现 componentDidMount 和 componentWillUnmount 的能力,但是这个场景能不能给个语义更好的 API 命名,比如 useDidMount,每次都要传入空数组也很多余。
-
当 useEffect 传入状态依赖时,每当依赖数组中的状态发生了变化,useEffect 的回调函数中的副作用逻辑就会执行。
-
当 useEffect 传入依赖并实现一些 addEventListener 之类的事情时,为了解决闭包问题,竟然会在每次接收到消息后去解绑、再绑定、解绑、再绑定...... 仿佛闭包问题本身才是函数组件 + useEffect 带来的 “副作用”。
-
2、副作用的概念
最后就是对于所谓的 effect 本身,什么是副作用,在当时的 React 官方文档是这么描述的:
当时也没有多想,就大概知道了 UI 渲染(状态 -> JSX)之外的逻辑都属于副作用的范畴。
3、监听思维 vs 副作用思维
当我开始在业务中使用 useEffect 并尝试把所有的 http 接口请求都放到里面时,我突然感觉到:
如果这么写代码,useEffect 不就是 Vue 2 里的 watch 么?
useEffect(() => {
fetchListData();
}, [pageNo, pageSize]);
因为我 Vue 的实际使用经验较少,写这篇文章时,我特意去查了一下,发现一个小差别就是 Vue 2 中一次只能监听一个状态(当然也能通过监听计算属性实现多个状态的监听):
在我的研发习惯中,一般都会避免使用 Vue watch 和 React componentDidUpdate 等 API 去实现业务逻辑,而是优先用其他方式,因为监听类的逻辑是很难维护的。然而当 React Hook 把数据请求都视为副作用时,结合 useEffect 那不就是在引导所有开发者尝试用监听的逻辑去实现业务逻辑么,想象一下就觉得很可怕。当然我也可以选择继续保持原本的研发习惯,把由用户事件(鼠标、键盘、触屏等操作)触发的请求写在事件处理函数中。
所以当时对于如何使用 useEffect 我纠结了很久,为了回忆当时的学习过程,我甚至还找到了一份 3 年前的笔记,对于什么时候要用监听的思维进行了总结(现在看来是完全错误的):
其实在这几年经历了更多的业务实战“磨炼”后,我早已忘记了当时写在我笔记中的这个结论,如果让我给一个如何在业务代码中使用 useEffect 的新结论:我只会在为了模拟 componentDidMount 和 componentWillUnmount 这两个生命周期时主动使用空数组 [] 依赖的 useEffect;对于其他大部分场景中的副作用逻辑,我一定会优先将这些逻辑直接实现在用户事件响应函数中;实在万不得已时,我才会用 useEffect 去模拟 componentDidUpdate 生命周期中的监听类逻辑。
但是当年的这个认知一直保留了下来,包括在看其他同事的代码时我也是这么去解读,并且持续到现在,即:
“useEffect + deps(非空数组)” = “监听状态/属性后执行特定逻辑”
并且在准备写这篇文章期间,我特意观察过同事之间的技术讨论,包括各种社区里的技术文章,当我们提及 useEffect 时,“监听”这个词出现的频率是很高的:
然而认真阅读过官方文档的人都知道,useEffect 的设计意图并不是让我们用于实现监听类逻辑,而是用于实现副作用。不过两种理解方式或者说思维模式写出来的代码功能是一样的,殊途同归。
个人认为有一个问题可以判断你日常使用 useEffect 时用的是监听思维,还是用副作用思维:你会先声明 useEffect 依赖数组的所有依赖项?还是先实现 useEffect 回调函数中的所有逻辑?
三、现实中的 useEffect 地狱
在第一章介绍的背景下,我分类总结了我认为在实际开发过程中 useEffect 存在的问题,以下大部分代码示例并没有引起实际功能的 bug。但是就像文本第一章中所说的,这些问题最终会影响整个项目长期的可读性和可维护性,直接决定团队的开发效率,间接影响系统的稳定性。
1、大量不够健壮的空依赖
在我们的前端工程中,使用 useEffect 时依赖数组为空的情况大概占比 50%,例如:
useEffect(() => {
fetchProjectList({
pageNo: 1,
pageSize: 10,
});
}, []);
接下来聊聊这类代码有什么问题。
语义化问题
如果只是为了替代 componentDidMount, 使用例如 ahooks 的 useMount 可能会更语义化,且可以少写一个空数组。
判断依赖问题
如果在工程中参考 代码检查(Linting)启用 react-hooks/exhaustive-deps 这个官方 ESLint 规则后,实际上还会有一个提示:
假设开发者的意图很明确,就是只要在 componentDidMount 中只执行一次,完整的代码如下:
const ProjectListComponent = (props) => {
const [dataSource, setDataSource] = useState({
total: 0,
list: [],
});
const fetchProjectList = async (params = {}) => {
const result = await fetchProjectData({ // fetchProjectData 是在组件外部定义的请求函数
pageNo: 1,
pageSize: 10,
...params,
});
setDataSource(result);
};
// ...... 中间隔了 100 行其他代码 ......
useEffect(() => {
fetchProjectList({
pageNo: 1,
pageSize: 10,
});
}, []); // 🟢 useEffect 中没有用到任何如 props 和 state 这样的响应式值,所以不需要添加依赖项
return (
<div>
......
</div>
);
};
目前看来因为 fetchProjectList 的实现上没有用到任何如 props 和 state 这样的响应式值(参考依赖应该和代码保持一致)。当然如果 fetchProjectData 是组件内定义的函数,也要再看一下 fetchProjectData 的实现,这里我们假设它是外部函数。
半年后另一个开发人员需要在这个功能基础上迭代,在 fetchProjectData 对应的接口中增加入参 bizId,而 bizId 来源于组件外部:
const ProjectListComponent = (props) => {
const { bizId } = props;
const [dataSource, setDataSource] = useState({
total: 0,
list: [],
});
const fetchProjectList = async (params = {}) => {
const result = await fetchProjectData({
bizId, // 迭代过程中在请求方法中添加了 bizId 这个 props 入参
pageNo: 1,
pageSize: 10,
...params,
});
setDataSource(result);
};
// ...... 中间隔了 100 行其他代码 ......
useEffect(() => {
fetchProjectList({
pageNo: 1,
pageSize: 10,
});
}, []); // 🔴 忘了添加 bizId 这个依赖项,但如果 bizId 是不会变化的,其实也不会有功能性问题
return (
<div>
......
</div>
);
};
很简单,第二个开发人员直接透传 bizId 到了请求的入参中,但是忘记将 bizId 放到 useEffect 的依赖中。但如果 bizId 是不会变化的,其实也不会有功能性问题,万一在整个页面的生命周期中的 bizId 是会变化的,那么就可能出现问题,我猜测这种 bug 可能会在开发阶段逃逸,大概率在测试阶段被测试人员发现。
但是业务还有很多类似的因为漏写依赖导致的问题,可能因为原本的依赖项较多,导致漏依赖问题很难被发现,可能要到真线上某个真实用户的进行页面操作才会发现:
// 假设以下代码遗漏了依赖 stateD
// 因为大部分情况下,stateD 会和其他状态一起改变,所以不会出什么问题
// 但是当某个用户操作导致 stateD 会单独变化时就会出问题,副作用回调没有被触发
// 而且这种问题非常容易在测试阶段逃逸,在 Code Review 环节也难以发现
useEffect(() => {
// ......
}, [stateA, stateB, stateC, /* stateD, */ stateE, stateF, stateG]);
如果想要在开发阶段非常自然地发现这类问题,有一个标准的办法就是把 react-hooks/exhaustive-deps 这个 ESLint 规则从 warn 级修改为 error 级。不再用肉眼去判断各个函数的实现中是否包含 state 和 props,而是无脑跟着 linter 提示走。
const ProjectListComponent = (props) => {
const { bizId } = props;
const [dataSource, setDataSource] = useState({
total: 0,
list: [],
});
// 🟡 如果 fetchProjectList 只在 useEffect 中用到,你还可以直接将其定义在 useEffect 内部,避免使用 useCallback
const fetchProjectList = useCallback(
async (params = {}) => {
const result = await fetchProjectData({
bizId,
pageNo: 1,
pageSize: 10,
...params,
});
setDataSource(result);
},
[bizId],
);
// ...... 中间隔了 100 行其他代码 ......
useEffect(() => {
fetchProjectList({
pageNo: 1,
pageSize: 10,
});
}, [fetchProjectList]); // 🟢 完全根据 linter 提示添加依赖项
return (
<div>
......
</div>
);
};
如果一个前端团队决定把 ESLint 规则调整为 error 级,那么就能彻底预防可能会到来的 bug,并且大多数的空依赖 useEffect 都不将再是空依赖。不知道有多少团队是这么做的,至少目前我们没有这么做,我个人认为这么做最大的问题就是后续阅读这些代码时,没法区分哪些逻辑实际上是只会在 componentDidMount 时执行的(即想知道在上例中的 bizId 是否会变化,只看当前组件的代码是无法知晓的),即前序开发者的部分代码意图信息完全丢失了。
这违背了软件工程中的一条重要原则“优先去书写能够自说明的代码”,即优先用代码本身而非注释等其他形式来说明代码意图(在《 Code Complete 代码大全 》中就有一个章节就讨论了这个话题)。
当我要在此基础上继续开发时,最保险的方式就是假定所有 useEffect 中依赖的状态都是会不断变化的,我会认为在这种假设的前提下写代码对心智的消耗很大,因为你可能还要考虑当前组件中除了 useEffect 之外的逻辑是否也支持这些依赖不断变化,比如所有的子组件(特别是那些二、三方包内的组件,因为历史原因或者公司内团队规范差异,这些组件被编写时可能没有被限制必须严格遵守 react-hooks/exhaustive-deps 规则)。
如果我们依旧将这个 ESLint 规则保持为 warn 级,除了容易出现的 bug 之外,还有一个小问题:在阅读他人的代码时,react-hooks/exhaustive-deps 这个 ESLint 规则完全无法帮我们判断历史代码中的空依赖是否是正确的,因为规则无法判断人的意图和对需求的理解,还是要我们用肉眼去判断,这会引发一个恶性循环:不断降低开发者对于 react-hooks/exhaustive-deps 这个规则的使用频率和信任度(包括开发人员在自己开发的时候)。
所以对于“判断依赖”这个问题,我认为这两种方式各有千秋,也各有问题,我们也没有想到更好的解决办法。
顺便,对于目前 react-hooks/exhaustive-deps 的使用情况,我简单在团队内做过一个调查:
从这个简单的调查可以得出一个团队内的初步结论,目前能够较好遵守这个 ESLint 规则的人不到 40%,还有大部分人会通过人工阅读代码来判断是否需要添加依赖项(我猜应该是在用空数组 [] 依赖模拟 componentDidMount 时就忽略 linter 规则校验了)。
我自己选了【c】完全不看,因为我在绝大多数情况下使用 useEffect 是为了模拟 componentDidMount,在阅读他人代码时我也用监听思维去理解。但是完全不参考 linter 提示,对阅读代码时的细心程度要求比较高,而且脑子不能犯浑,因为一些复杂场景中会有很多层级的父子函数,需要人工去排查所有的父子函数中用到的响应式值。
2、useCallback 的传染性
大量的冗余代码
当我在阅读那些按照官方推荐严格遵守 react-hooks/exhaustive-deps 规则的同事写的代码时,我觉得这些代码也开始变得越来越冗余:
/**
* 🟡 因为这些 doSomething 方法要被复用,所以无法直接定义在某个 useEffect 内部
*/
const doSomethingX = useCallback(() => {
// ......
}, [/* ...... */]);
// ......
const doSomethingG = useCallback(() => {
// ......
}, [doSomethingX]);
const doSomethingF = useCallback(() => {
// ......
}, [propsA, propsC]);
const doSomethingE = useCallback(() => {
// ......
}, [stateB, propsF]);
const doSomethingD = useCallback(() => {
// ......
}, [stateA, propsD]);
const doSomethingC = useCallback(() => {
// ......
}, [stateC, doSomethingG]);
const doSomethingB = useCallback(() => {
// ......
}, [doSomethingE, doSomethingF]);
const doSomethingA = useCallback(() => {
// ......
}, [stateA, stateB, doSomethingD]);
useEffect(() => {
doSomethingA();
doSomethingB();
}, [doSomethingA, doSomethingB]);
useEffect(() => {
if (stateA) {
doSomethingC();
}
}, [stateA, doSomethingC]);
只要父级函数被 useCallback 包裹了,所有的子子孙孙的函数都必须要被 useCallback 包裹,这些 useCallback 和 deps 不代表任何业务意义,全部都是代码噪音,开发者为什么要关心这些东西?让我感觉不是 React 在给我们提效,而是我们在给 React 当牛马。
难以自动化
那有没有什么方法能避免这个问题呢,React 当初在官方文档可是给我们画了饼的:
如果这个饼能吃到,那既不用开发者吭哧吭哧干苦力,在源码中也不会出现这些 useCallback 和 deps 造成代码噪音。但是当我如第二章中说的用“监听”去理解 useEffect 时,就发现了这个功能貌似无法实现,因为监听了什么状态,并不意味着我处理监听回调的时候只能用到这些状态:
const [timestamp, setTimestamp] = useState(0);
const [firstName, setFirstName] = useState('Hua');
const [lastName, setLastName] = useState('Li');
useEffect(() => {
// 仅在姓名变化的时候进行打印
console.log(`Name was changed to ${firstName} ${lastName} at ${timestamp}.`)
}, [lastName, firstName]); // 🟡 如果监听了 timestamp,那么每当 timestamp 变化时就会打印 `Name was changed ...`,但实际 name 没变
如上的例子中,若要实现这个打印诉求,依赖数组中的状态和回调函数中真实使用到的状态就是不一样的,构建过程是无法区分这种场景和常规的副作用场景的。除非对这个状态量就是不一致的场景通过注释等的形式进行标记,然而从 React Hook 推出到现在已经这么多年过去了,做这个事情的前提就要对整个 React Hook 生态以及所有公司自己的业务代码做一次大排查标记出这种场景,这显然是不太可能的。
3、过度的响应式编程
useEffect vs 事件响应函数
React 这个前端框架的名字之所以叫做 “React”,我猜应该是因为相比于 jQuery 等上一代开发模式,在 React 中当数据发生变化时,框架会根据状态的变化来自动更新 UI 视图,而不需要开发者手动调用DOM API 修改 UI 视图,即实现了一种从状态到 UI 视图的响应式编程(Reactive Programming)。
当我们将所有的副作用逻辑(数据请求、设置订阅等)都写在了 useEffect 中时,其实就是实现了从状态到副作用逻辑的响应式编程,更直白地说就是 react 自动地监听我们声明的依赖,自动地执行回调函数。这和我们在使用 Class Component 类组件开发时,将组件的所有的副作用逻辑都写在 componentDidUpdate 中是没有本质差别的。
我认为这么做最大的问题不在于首次开发阶段,而在于阅读历史代码逻辑和迭代开发时,比如有一个后台项目列表页,以及常见的底部页码翻页功能:
// 渲染过程也依赖 pageNo 和 projectList 这两个状态
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
const handlePageNoBtnClick = (targetPageNo) => {
setPageNo(targetPageNo);
};
const handleNextPageBtnClick = () => {
const targetPageNo = pageNo + 1;
setPageNo(targetPageNo);
};
const handlePrevPageBtnClick = () => {
const targetPageNo = pageNo - 1;
setPageNo(targetPageNo);
};
// 🟡 当 fetchProjectList 内的逻辑被修改后,难以评估这个改动的影响面
const fetchProjectList = useCallback(
async () => {
const query = qs.stringify({
projectType: props.projectType, // projectType 是通过 props 获得的
pageSize: 20,
pageNo,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
},
[pageNo, props.projectType],
);
useEffect(() => {
fetchProjectList();
}, [fetchProjectList]);
比如当我们修改了 fetchProjectList 内的逻辑,比如增加了一个状态依赖 pageSize,我们需要梳理哪些场景会触发接口请求并将此作为测试重点,我们需要经历以下步骤:
- 明确 fetchProjectList 会在哪些场景中被调用:
-
- 若只在这个 useEffect 中被调用,只需要看 useCallback 依赖什么时候会变化即可
- 若还有 useEffect 之外的地方调用,则需要单独列出来
- 明确 pageNo 会在哪些场景中变化:
-
- 先找到 pageNo 对应的 setPageNo
- 再找到所有引用并执行 setPageNo 的地方
- 明确 pageSize 会在哪些场景中变化:
-
- 先找到 pageSize 对应的 setPageSize
- 再找到所有引用并执行 setPageSize 的地方
- 明确 props.projectType 会在哪些场景中变化:
-
- 先找到父组件中的 projectType={stateX}
- 再找到 stateX 对应的 setStateX
- 最后找到所有引用并执行 setStateX 的地方
- 分析以上三个状态变化的场景
-
-
合并因为状态初始化或者同时改变而合并请求的情况
-
最后得到我们需要的重点测试的场景
-
以上过程非常繁琐,在更复杂的业务场景中会大大降低代码的可读性,就是我在第二章提到过这很可怕的原因:响应式的设计会将原本连贯的函数调用链被切分成很多个副作用逻辑片段+事件响应函数片段,每个逻辑片段内的函数调用链是完整的,片段和片段之间的关系则是极其分散的(像一些状态管理方案中的 dispatch 方法,其实也有类似问题,即丢失了直接的调用关系)。
作为对比,如果将项目列表接口的请求逻辑实现在事件处理函数中:
// 渲染过程也依赖 pageNo 和 projectList 这两个状态
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
// 🟢 可以很方便地找到 fetchProjectList 被调用的地方
const fetchProjectList = async (params = {}) => {
const query = qs.stringify({
projectType: params.projectType || props.projectType, // 优先从 params 中读取 projectType
pageSize: 20,
pageNo: params?.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
};
const handlePageNoBtnClick = (targetPageNo) => {
setPageNo(targetPageNo);
fetchProjectList({ pageNo: targetPageNo });
};
const handleNextPageBtnClick = () => {
const targetPageNo = pageNo + 1;
setPageNo(targetPageNo);
fetchProjectList({ pageNo: targetPageNo });
};
const handlePrevPageBtnClick = () => {
const targetPageNo = pageNo - 1;
setPageNo(targetPageNo);
fetchProjectList({ pageNo: targetPageNo });
};
// 🟡 如果 props.projectType 是会变化的
// 如果 projectType 变化时,本组件内的所有 state 状态都应该重置
// 可以在父组件中引用本组件时增加 key 属性,即 key={projectType}
// 每当 projectType 变化时,当前组件实例会被销毁,并自动创建一个新的组件实例
// 如果 projectType 变化时,本组件内的部分 state 状态还是要保留(假设 pageNo、projectList 之外还有别的 state 状态)
// 则可以定义一个 refreshProjectListByType 方法通过 ref 暴露给父组件
// 让父组件在改变 projectType 之后手动调用 refreshProjectListByType 方法
useImperativeHandle(ref, () => ({
refreshProjectListByType: (targetProjectType) => {
setPageNo(1);
setProjectList([]);
fetchProjectList({ projectType: targetProjectType });
},
}));
useEffect(() => {
fetchProjectList();
}, []);
当修改了 fetchProjectList 内的逻辑时,因为我们已经在用户事件响应函数中主动控制了请求的触发时机,而不是让请求在 useEffect 中“自动触发”,所以我们只需要找到所有引用 fetchProjectList 的地方就可以了,一步搞定:
不过这样实现还有一个细节差异:对比 useEffect 的实现方式,这里的 fetchProjectList 需要增加 params 入参。我曾设想过为什么不能像类组件的 this.setState 一样增加第二个回调函数参数:
/**
* 类组件
*/
this.setState(
{
pageNo: targetPageNo,
},
() => {
// 等状态已经完成变更后再触发请求,这样就无需再设置函数入参
this.fetchProjectList();
}
);
/**
* 函数组件
* 假设 useState 的 set 函数也支持第二个回调函数参数
*/
setPageNo(targetPageNo, () => {
// 这个回调执行的时候,状态已经变更好了
// fetchProjectList(); // 🔴 但是因为闭包问题 fetchProjectList 中拿到的 pageNo 还是旧值
fetchProjectList({ pageNo: targetPageNo }); // 🟢 所以在函数组件中,我们必须将 state 类的参数也作为函数入参重新传递一遍
});
为什么副作用逻辑响应式会有上述难以排查实际调用方的问题,但是对于 UI 视图响应式设计我们貌似没有感知到类似的问题,我认为主要是因为副作用逻辑大多时候会有多个状态依赖,但是阅读和梳理 UI 视图逻辑时,我们一次大多只看一个状态即可,比如:“list 数据从哪来?”、“visible 什么时候会变成 true ?”等等。而且 UI 响应式(或者说状态机的设计)确实比上一代直接操作 DOM 的开发模式要便捷。
如果你平常也是这么使用 useEffect 的,却没有明显地感受到这个问题,可能是因为你对当前在开发的前端工程非常熟悉(代码都是你写的),或者对这一类功能的逻辑非常熟悉(比如常见的列表查询),你自然地能想到测试重点而不用仔细梳理代码逻辑,这个其实就是严重依赖开发者的素质和业务知识传递的效率。
当我们逐渐习惯在一些相对简单的功能场景中把这类副作用逻辑都写在 useEffect 中后,某一天我们要去实现一些逻辑更加复杂的交互功能时,会自然而然地去保持这种研发习惯,在正向开发的时候你可能感受不到大的阻力,但是当开发完成后,换另一名同事来进行 Code Review 时,不提前了解需求的情况下根本不能快速看懂代码逻辑。
举一个比列表翻页再略微复杂一点的示例让大家更清晰地感受到这个问题。比如在一个项目详情页中,每个项目都有若干个公告,用户可以单选某个公告,被选中的公告会自动展示相关的发布信息和问题反馈信息:
/**
* 以下所有的 useXxxInfo/List 类业务自定义 Hook 都封装了在 useEffect 中进行接口请求的逻辑,多个 Hook 之间相互联动
*/
const ProjectDetailPage = (props) => {
const { projectId } = props;
// selectedAnnouncementId 为 UI 渲染中会用到的一个 state
const [selectedAnnouncementId, setSelectedAnnouncementId] = useState(null);
// 每当 projectId 变化时:
// useProjectInfo 内部会自动请求项目详情接口
const projectInfo: ProjectInfo | null = useProjectInfo(projectId);
// 每当 projectId 和 bizId 变化时:
// 若两者都有值时,useAnnouncementList 内部会自动请求公告列表接口
// 否则 announcementList 会变成空数组
const announcementList: Announcement[] = useAnnouncementList({
projectId: projectInfo?.id,
bizId: projectInfo?.bizId,
});
// 每当 selectedAnnouncementId 变化时:
// 若 selectedAnnouncementId 有值,usePublishInfo 内部会自动请求该公告对应的发布相关信息
// 否则 publishInfo 变成 null
const publishInfo: PublishInfo | null = usePublishInfo({
announcementId: selectedAnnouncementId,
});
// 每当 selectedAnnouncementId 变化时:
// 若 selectedAnnouncementId 有值且 isAllowFeedback 为真,则 useFeedbackList 内部会自动请求该公告发布后的用户反馈信息
// 否则 feedbackList 变成 []
const feedbackList: FeedbackInfo[] = useFeedbackList({
announcementId: selectedAnnouncementId,
isAllowFeedback: projectInfo?.isAllowFeedback,
});
// 用户单选选中某条公告信息
const handleAnnouncementRowSelect = (row) => {
// 🔴 从这个事件响应函数中,根本看不出这个用户操作到底触发了什么逻辑
setSelectedAnnouncementId(row.id);
};
// ...
};
这个示例和官方文档提到过的 链式计算 很相似,只是这里的 useEffect 中真的有请求数据类的副作用,问题在于我们很难直观地从这种代码中看出来某个用户事件被触发之后,JavaScirpt 到底会执行些什么逻辑,并且真实项目中不可能有这么多注释,并且在一些逻辑更加复杂的用户事件处理函数中,可能需要同时触发提交类接口请求和数据查询类接口请求。
所以我们去掉注释,根据实际业务需求来编码,而不是一股脑将所有请求都放到 useEffect 中,由用户事件触发的副作用逻辑都移到 select 事件响应函数中:
const ProjectDetailPage = (props) => {
const { projectId } = props;
const [projectInfo, setProjectInfo] = useState<ProjectInfo | null>(null);
const [announcementList, setAnnouncementList] = useState<Announcement[]>([]);
const [publishInfo, setPublishInfo] = useState<PublishInfo | null>(null);
const [feedbackList, setFeedbackList] = useState<FeedbackInfo[]>([]);
const [selectedAnnouncementId, setSelectedAnnouncementId] = useState(null);
const fetchPublishInfoById = async (id) => {
const info = await fetchPublishInfo({ announcementId: id });
setPublishInfo(info);
};
const fetchFeedbackListIfNeed = async (id) => {
if (projectInfo?.isAllowFeedback) {
const list = await fetchFeedbackList({ announcementId: id });
setFeedbackList(list);
} else {
setFeedbackList([]);
}
};
// 用户单选选中某条公告信息
const handleAnnouncementRowSelect = async (row) => {
// 🟢 通过用户事件响应函数,可以很清晰地看出这个用户事件触发了什么逻辑
const id = row.id;
setSelectedAnnouncementId(id);
fetchPublishInfoById(id);
fetchFeedbackListIfNeed(id);
};
// 页面初始化逻辑
const init = async () => {
const info = await fetchProjectInfo(projectId);
setProjectInfo(info);
const list = await fetchAnnouncementList({
projectId: info.id,
bizId: info.bizId,
});
setAnnouncementList(list);
};
useEffect(() => {
init();
}, []); // 🟡 业务上可以确定 projectId 不会变化,所以声明了空依赖
// ...
};
改写之后逻辑变得清晰多了,自定义 Hook 之间的联动逻辑变成了简单的串行或者并行逻辑。 但你可能发现代码行数突然变多了,因为我们把很多原本自定义 Hook 中的函数和 state 都临时挪了出来,本文后续会在介绍我们的 useEffect 的替代方案时再讨论如何封装一个自定义 Hook。
这里再提一个很重要的点,改写后代码的 handleAnnouncementRowSelect 事件处理函数,其实还做了子函数的拆分。并不是只有代码需要复用时我们才应该创建子函数,强烈推荐所有人都阅读一下《 Code Complete 代码大全 》中的第 7 章 “高质量的子程序” 学习如何通过拆分子函数降低逻辑复杂度、对关键过程进行抽象。比如一个更复杂的提交操作,如果按书中介绍的原则来写,不论过程多么复杂,逻辑都会很清晰:
/** 点击 提交 */
const handleSubmitClick = async () => {
// 前端校验并保存
await validateAndSaveForm();
// 后端校验
await validateFormByBackend();
// 确认是否需要重新生成公告
await checkRegenerateAnnouncement();
// 确认是否需要重新生成采购文件
await checkRegeneratePurchaseFile();
// 提交表单
await submitForm();
// 唤起工作流
await launchWorkflow();
// 刷新表单变成详情态
await refreshForm();
};
不过要让事件响应函数中的代码都写成这种非常优雅的串行逻辑,其实需要一些额外的前端技巧。因为很可能其中一个中间过程是唤起一个弹框让用户在弹框中执行一些操作,那么我们在事件响应函数中的逻辑很可能就会以 setSomeModalVisible(true) 结尾,但其实整个提交过程才执行了一半,导致事件响应函数中无法包含完整的处理逻辑。这种时候往往需要我们从当前的事件响应函数末尾跳跃到 handleSomeModalOk / handleSomeModalCancel 这些弹框类组件的事件回调中继续阅读代码。不过这是另一个话题了,不属于本文范畴,准备以后另外开坑再来讨论。
混乱的编码抉择
回到上述后台列表翻页的场景,两种请求数据的实现方式(useEffect 和事件响应函数)我们可能都在业务代码中看到,但是对于另外一些数据请求的副作用,我们却大多不会选择 useEffect 来实现。比如有文章展示页面,需要点击“展开详情”按钮才会触发详情接口请求,我们大多会直接写在事件处理函数中:
const [detailVisible, setDetailVisible] = useState(false);
const [detailText, setDetailText] = useState('');
const fetchDetailInfo = async () => {
if (Boolean(detailText)) {
console.log('Article detail has been fetched.');
} else {
const query = qs.stringify({
id: props.id, // 假设 props.id 是不会变化的
});
const response = await fetch(`/article/detail?${query}`);
const json = await response.json();
setDetailText(json);
}
};
const handleShowDetailBtnClick = () => {
setDetailVisible(true);
fetchDetailInfo();
};
const handleHideDetailBtnClick = () => {
setDetailVisible(false);
};
如果使用 useEffect 来实现则有种脱裤子放屁的感觉,代码如下:
const [detailVisible, setDetailVisible] = useState(false);
const [detailText, setDetailText] = useState('');
const handleShowDetailBtnClick = () => {
setDetailVisible(true);
};
const handleHideDetailBtnClick = () => {
setDetailVisible(false);
};
const fetchDetailInfo = useCallback(
async () => {
if (Boolean(detailText)) {
console.log('Article detail has been fetched.');
} else {
const query = qs.stringify({
id: props.id, // 假设 props.id 是不会变化的
});
const response = await fetch(`/article/detail?${query}`);
const json = await response.json();
setDetailText(json);
}
},
[detailText, props.id],
);
// 🟡 监听 detailVisible 实现数据请求逻辑
useEffect(() => {
if (detailVisible) {
fetchDetailInfo();
}
}, [detailVisible, fetchDetailInfo]);
// ...
还有一些接口请求类的副作用逻辑,根本想不到会去用 useEffect 来实现:
/**
* 点击页面刷新按钮,触发 GET 接口
*/
const handleRefreshBtnClick = async () => {
const response = await fetch(`/page/detail?id=${props.id}`);
const json = await response.json();
setPageInfo(json);
};
/**
* 点击页面提交按钮,触发 POST 接口
*/
const handleSubmitBtnClick = async () => {
const response = await fetch('/form/submit' /* ...... */);
const result = await response.json();
if (result.success) {
message.success('提交成功');
} else {
message.error(result.message);
}
};
那上述的几个例子中的接口请求类副作用差异点在哪呢?仔细分析之后会发现有以下几个关键点:
-
这个副作用逻辑会不会在 didMount 时触发?会不会在用户事件中触发?
-
若会在用户事件中触发,是否也会触发 state 状态变化?并且对应的 state 状态是否会作为副作用逻辑的入参(比如请求的入参)?
基于这几个关键点,对副作用进行分类梳理:
可以从梳理结果中看到,其中有三个场景都可以有两种实现方式,并且选择实现方式时的标准非常模糊。 所以为了保持代码结构一致性,以及确保函数调用关系的完整性,对于这个问题我们的结论很明确:由用户事件触发的副作用逻辑,禁止使用 useEffect 来实现,直接实现在对应的用户事件中即可。
4、useEffect 解决不了的问题
继续上述的副作用分类话题,对于“只需要 componentDidMount 中触发的副作用”,其实这个描述并不准确,当我们用 useEffect 实现这类问题时,为了解决闭包问题,实际上设置监听类的逻辑会在依赖变化的时候反复触发,这和在类组件中设置监听完全不同:
useEffect(() => {
const type = 'my-event';
const listener = () => {
console.log('log state:', stateA, stateB);
};
window.addEventListener(type, listener);
return () => {
window.removeEventListener(type, listener);
};
}, [stateA, stateB]);
当 stateA 或 stateB 不断变化时,在不停地执行 removeEventListener -> addEventListener -> removeEventListener -> addEventListener -> removeEventListener -> addEventListener ......
OK,至少功能没问题就行,CPU 也不会有情绪,并且开发者日常可以忽略这个事情,正常根据 ESLint 规则去给副作用添加标准的依赖即可。但接下来介绍的几个特殊的场景中,就有问题了。
定时器功能异常
功能描述:页面上有个 Input 输入框,用户随时可以修改,然后希望每秒打印一下 Input 输入框中当前的值。
const [inputValue, setInputValue] = useState('');
const handleInputChange = (value) => {
setInputValue(value);
};
useEffect(() => {
const handler = () => {
// 🔴 用户连续且快速打字输入时,log 不会触发
console.log('log input value per second:', inputValue);
};
const intervalId = setInterval(handler, 1000);
return () => {
clearInterval(intervalId);
};
}, [inputValue]);
非常标准的 useEffect 用法,但是在用户频繁输入导致 inputValue 变化间隔小于 1 秒时,会导致在这期间内所有的定时器都还没触发过就被清除了,即这个 log 逻辑在用户频繁输入期间是失效的。有一个常规的解决方案就是另起一个 ref 去实时同步 inputValue 的值(虽然这违背了最小状态量的原则):
const [inputValue, setInputValue] = useState('');
const valueRef = useRef('');
const handleInputChange = (value) => {
setInputValue(value);
valueRef.current = value; // 将 inputValue 额外同步到 valueRef 中
};
useEffect(() => {
const handler = () => {
console.log('log input value per second:', valueRef.current);
};
const intervalId = setInterval(handler, 1000);
return () => {
clearInterval(intervalId);
};
}, []); // 🟢 valueRef 不属于响应式值,所以不需要声明依赖
你可以说这种是低级问题,这种问题本来就应该用 ref 来解决,有意思的是我去问了好几个 AI(其中 ChatGPT 用的是免费版),问题描述如下:帮 我用 react 函数组件实现一个功能:页面上有个 Input 输入框,用户随时可以修改,然后希望每秒打印一下 Input 输入框中当前的值。
除了 DeepSeek 之外的大模型,首次回答都完美踩坑,只有 DeepSeek 直接想到了要用 Input 自带的 ref 拿到 DOM 元素从而获取输入框的值,还节省了同步逻辑,牛啤!
为什么我们以前没注意到这个问题呢,因为大多数情况下状态变化不会这么频繁,误差也仅出现在状态变化的时候,产品功能上大多都没感知到这点误差,但从逻辑上这是不严谨的。并且这个功能如果用类组件来实现时,完全不会遇到这个问题:
class Demo extends React.Component {
componentDidMount = () => {
const handler = () => {
console.log('log input value per second:', this.state.inputValue);
};
this.intervalId = setInterval(handler, 1000);
};
componentWillUnmount = () => {
clearInterval(this.intervalId);
};
}
不应该的 WebSocket 性能问题
和设置定时器类似,设置 WebSocket 通信这个场景并不会因为依赖状态的变化导致功能问题,但是会导致性能问题,因为这时候不像 addEventListener 那样只是不断使唤 CPU,而是重复调用网络 IO 接口进行 WebSocket 建连和断连,要不断使唤网线了(大误)。
// maxLength 表示展示服务端推送过来的消息时允许的最大长度,用户可以随时调整
const [maxLength, setMaxLength] = useState(50);
const handleRangeInputChange = (value) => {
setMaxLength(value);
};
useEffect(() => {
// 🔴 每当 maxLength 变化时,WebSocket 都会断开后重新连接
const ws = new MyWebSocket({
url: '/some/websocket/api', // 假设建连不需要入参,服务端判断用户 cookie 中的登录态即可
onMessage: (data = {}) => {
if (data.userId !== props.userId) {
console.error('用户信息不匹配');
return;
}
if (data.length > maxLength) {
console.error('超出长度限制');
return;
}
console.log('verified data:', data);
},
});
return () => {
ws.close();
};
}, [props.userId, maxLength]);
解决这个问题的常规思路也是额外创建 ref,把 state 和 props 在 ref 中同步额外维护一份:
// maxLength 表示展示服务端推送过来的消息时允许的最大长度,用户可以随时调整
const [maxLength, setMaxLength] = useState(50);
const userIdRef = useRef('');
const maxLengthRef = useRef(50);
const handleRangeInputChange = (value) => {
setMaxLength(value);
maxLengthRef.current = value;
};
useEffect(() => {
userIdRef.current = props.userId; // 🟡 不太确定同步 props 到 ref 中并不属于官方定义的 Effect 副作用
}, [props.userId]);
useEffect(() => {
const ws = new MyWebSocket({
url: '/some/websocket/api', // 假设建连不需要入参,服务端判断用户 cookie 中的登录态即可
onMessage: (data = {}) => {
if (data.userId !== userIdRef.current) {
console.error('用户信息不匹配');
return;
}
if (data.length > maxLengthRef.current) {
console.error('超出长度限制');
return;
}
console.log('verified data:', data);
},
});
return () => {
ws.close();
};
}, []); // 🟢 userIdRef 和 maxLengthRef 不属于响应式值,所以不需要声明依赖
一样的还有如果用类组件来实现时,也不会遇到这个问题,这里就不写代码了。
5、错误使用 useEffect
缺乏警示性
关于这个话题,官方文档有单独的文章来说明《 你可能不需要 Effect 》,我要补充的观点是,因为 useEffect 聚合了太多能力,在一些场景不够有警示性,导致真的有人写错了的时候,在 Code Review 环节容易被忽略。
比如下面这个真实的低级错误:
const [ids, changeIds] = useState([]);
/* 中间隔了 200 行代码 */
useEffect(() => {
const list = props.projectList.map((p) => p.id);
changeIds(list); // 🟡 changeIds 命名不规范,应该叫 setIds
}, [props.projectList]);
如果这个代码中没有命名规范的问题,其实就是官方文档中提到过的错误示例根据 props 或 state 来更新 state,在这个场景中根本不需要 useState 和 useEffect,直接在组件内转换 props.projectList 成一个常规的变量即可:
const { projectList = [] } = props;
// 🟢 直接计算出属性
const ids = projectList.map((p) => p.id);
// PS: 如果加上 useMemo 就非常像 Vue 的 computed 计算属性
const ids = useMemo(() => {
return projectList.map((p) => p.id);
}, [projectList]); // 仅在依赖项变化时重新触发计算逻辑
站在犯这个错的新手开发者角度:工程中到处都在用 useEffect,学习路径中可能并没有很明显感知到这个问题,并且目前为止功能没出问题。
站在 Code Review 执行者角度:没想到 changeIds 竟然是一个 setState 方法,因为显示器一屏没法同时看到 useState 和 useEffect,想当然地以为 changeIds 里面包含了副作用逻辑。
但是在类组件中,这个功能需要在 componentDidUpdate 这个独立的生命周期中去实现,当我们看到 componentDidUpdate 的时候,应该就有一种 danger 危险的感觉,习惯性要去多看一眼,因为用到这个 API 时很多人都会犯错误。官方文档在生命周期的介绍文章中也明确指出过:
找不出问题的错误用法
如果上面的例子是稍微有一点 React Hook 研发经验的开发者都可以避免的,我们再来看下面这两个例子:
/**
* 🟡 页面初始化时,需要基于用户信息判断是否要弹框提醒用户绑定手机号
*/
const Page = () => {
const [tipModalVisible, setTipModalVisible] = useState(false);
// 🟡 useUserInfo 是一个很基础的业务 Hook,会在组件 didMount 时自动请求当前登录用户的相关信息
const userInfo = useUserInfo();
// 查询到用户信息后,判断用户是否绑定手机号
useEffect(() => {
if (userInfo && !userInfo.phone) {
setTipModalVisible(true); // 显示提示弹框
}
}, [userInfo]);
// ...
};
/**
* 🟡 抽屉组件中会渲染一个项目列表,用户在抽屉内选中一条项目数据并后点击「确定」按钮后,会将这条项目数据传递给页面中的其他组件
*/
const ProjectSelectDrawer = (props) => {
const { visible } = props;
const [selectedProjectId, setSelectedProjectId] = useState(null);
// 🟡 无论用户在抽屉中点击「确定」还是点击「取消」,只要抽屉关闭了,都要清空当前选中项
useEffect(() => {
if (!visible) {
setSelectedProjectId(null);
}
}, [visible]);
// ...
};
乍一眼看这些 useEffect 会觉得没什么问题,因为完整的官方 useEffect 错误示例教学《 你可能不需要 Effect 》中也没有提到这种很常见的情况。我甚至去问了很多目前流行的 AI 大模型“这个代码有什么问题吗”,所有的 AI 都没有指出:
这两个示例中的 setTipModalVisible / setSelectedProjectId 并不是副作用逻辑,理论上不应该放在 useEffect 中,因为这些代码都是用监听思维而不是副作用思维写出来的,并且像 useUserInfo 这类封装了 useEffect 的基础业务 Hook 会不断地引导我们去写一些监听类的逻辑,形成恶性循环。
6、问题小结
最后再总结一下我们在 useEffect 实际使用中遇到的各类问题:
-
空数组的 useEffect 存在明显的语义化问题;需要书写依赖项时,准确声明依赖项和保留清晰的代码意图之间又存在无法调和的矛盾。
-
当 useEffect 的依赖中出现用 useCallback 包裹的函数,代码中会出现大量的关于依赖项声明的冗余代码,且目前看来这些繁琐而没有业务意义的工作难以自动化,一直需要开发者人工维护。
-
useEffect 会引导开发者去编写难以维护的“监听”类的逻辑,这些逻辑在复杂场景下的可读性非常差,只能靠团队规范来避免这种情况。
-
有些场景直接按 useEffect 最标准的方式去实现时会出现功能性问题,无疑又增加了使用难度。
-
useEffect 集合了太多使用场景,一些特殊的场景应该配合更具有警示性的 API,降低开发者犯错的概率;甚至还有一些结合自定义 Hook 的场景中,看上去最合适的方式就是在 useEffect 内书写一些非副作用逻辑。
Over,感谢阅读!