因为本文篇幅超出掘金单篇文章长度限制,所以分成了两篇文章:
《 为什么我们要删掉 100% 的 useEffect(一) 》
《 为什么我们要删掉 100% 的 useEffect(二) 》
GitHub 源码 : fool-proofing-hooks
完整原文(阅读体验更佳) :《 为什么我们要删掉 100% 的 useEffect 》
TLDR
因为本文篇幅很长(大概 3w 中文字符),所以提前对各个章节进行了介绍,大家可以按章节顺序阅读,也可以先挑感兴趣的章节先行阅读:
-
第一章:可以选择仅阅读加粗部分,主要是讲背景和目标,移除所有 useEffect 是我们实现目标的关键过程之一。
-
第二章:回顾了我从传统的 Class 组件过渡到 React Hook 时的学习感受。
-
第三章:列举了我们团队在实际使用 useEffect 时遇到的各种实际问题,这些问题大大降低了我们在复杂中后台场景中的研发效率。
-
第四章:介绍了 useInit 解决方案:用 Init Hook 和少量的 Watch Hook 来完全代替 useEffect,源码详见 fool-proofing-hooks。
-
第五章:辩证地分析了 React Hook 中 Effect 概念和"同步"的设计哲学,并讨论了这些设计到底带来了什么收益。
-
第六章:补充介绍了 useInit 解决方案的设计由来,帮助大家更好的理解这套新的 React Hook 编码范式。
一、背景
1、聊聊代码屎山
最近几年学习到了一个很重要的观点:在重构一个屎山项目时,最重要的指标就是如何保证下一次重构不会很快到来,所以重构的技术方案中必须要包含如何让代码"保鲜"的方法,否则重构结束后,换一几拨人维护几个来回,整个代码仓库又会迅速劣化成屎山。
知乎问题 ***"为什么祖传代码被称为「屎山」?"中 程墨Morgan的回答 **从**经济学的角度 *分析了写出好代码的客观条件。不过其中说到的管理层问题未必是因为短视,而是视角不一样:作为程序员我们肯定希望有充足的时间和精力去做最好的设计,写最好的代码;但是在竞争激烈的市场环境中,很多时候快速支持业务落地才能抢占市场份额和用户心智,因此就有了各种倒排项目。当然作为打工人谁也不想天天加班,也有一些情况需要有高质量代码的支撑,在这些场景中就必须投入充足的时间和精力去做好编码前的设计工作:比如已经初步占据市场的产品需要通过周期性的高效迭代进一步巩固市场地位,比如公司的战略就是通过越级的产品体验来吸引用户......
**这些思考让我觉得干净整洁的代码可能在很多时候就是不符合经济学规律的。**举个不恰当的比喻:我们无法要求一座摩天大楼的施工现场在施工期间每天都保持一尘不染、各类管线布局做到最好的模块化和最好的布局设计,能保证最基本的安全和质量这两个要求就已经非常好了。如果长远的未来业务需要转型,没有人能预知"在这座大楼上进行翻修改造"和"换地皮用新技术建造一栋新的大楼"哪个能让我们完成未来的商业目标。
不过作为技术人员,特别是当我们要继续维护和迭代屎山代码时,除了不断地提高对屎山代码的适应能力,更要从屎山代码的问题中反思如何去避坑。
在这个过程中,我想起了多年前当入行时被推荐阅读过的一本关于软件构建的巨著《 Code Complete 代码大全 》,当年只是浅读了几章之后就再也没有看过。在回看所有我们遇到的问题时,其实绝大多数的解决思路或者说最佳实践早已在这本编写于 2006 年的书中被列出来了,只是因为书中文字比例较高,且列举的各种代码示例所用的编程语言和我们目前工作中用的技术栈并不一样,所以很难一下子代入到我们日常的编码工作中,但是关于软件构建的核心思想都是一样的,只是需要我们看这本书时多花点时间,静下心来将这些理念应用到我们当下的编码工作中。
下面我基于自己的理解来介绍两个编码过程中要确保的要点,也是本文的出发点,在《 Code Complete 代码大全 》中也有类似的理念介绍:
2、确保代码意图清晰明确
React Hook 发布于 2018 年,最近一次看到讨论官方 Hook 的技术文章是关于 useMemo 和 useCallback 的《 请删掉99%的useMemo 》和《「好文翻译」为什么你可以删除 90% 的 useMemo 和 useCallback ?》。
对于 useMemo 和 useCallback,我个人的结论更简单粗暴:**没事别在业务代码中使用 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 么?
typescript
useEffect(() => {
fetchListData();
}, [pageNo, pageSize]);
因为我 Vue 的实际使用经验较少,知道 watch 这个 API 但是没真正用过,所以在写这篇文章之前我一直以为 Vue 的 watch 是以数组形式声明监听项的,写这篇文章时,我特意去查了一下,发现 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%,例如:
typescript
useEffect(() => {
fetchProjectList({
pageNo: 1,
pageSize: 10,
});
}, []);
这个代码的问题如下:
语义化问题
如果只是为了替代 componentDidMount, 使用例如 ahooks 的 useMount 可能会更语义化,且可以少写一个空数组。
判断依赖问题
如果在工程中参考 代码检查(Linting)启用 react-hooks/exhaustive-deps 这个官方 ESLint 规则后,实际上还会有一个提示:
假设开发者的意图很明确,就是只要在 componentDidMount 中只执行一次,完整的代码如下:
typescript
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 来源于组件外部:
typescript
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 可能会在开发阶段逃逸,大概率在测试阶段被测试人员发现。
但是业务还有很多类似的因为漏写依赖导致的问题,可能因为原本的依赖项较多,导致漏依赖问题很难被发现,可能要到真线上某个真实用户的进行页面操作才会发现:
typescript
// 假设以下代码遗漏了依赖 stateD
// 因为大部分情况下,stateD 会和其他状态一起改变,所以不会出什么问题
// 但是当某个用户操作导致 stateD 会单独变化时就会出问题,副作用回调没有被触发
// 而且这种问题非常容易在测试阶段逃逸,在 Code Review 环节也难以发现
useEffect(() => {
// ......
}, [stateA, stateB, stateC, /* stateD, */ stateE, stateF, stateG]);
如果想要在开发阶段非常自然地发现这类问题,有一个标准的办法就是把 react-hooks/exhaustive-deps 这个 ESLint 规则从 warn 级修改为 error 级。不再用肉眼去判断各个函数的实现中是否包含 state 和 props,而是无脑跟着 linter 提示走。
typescript
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 规则的人应该不到 50%,还有很多人会通过人工阅读代码来判断是否需要添加依赖项(一般都是在模拟 componentDidMount 时就忽略 linter 规则校验了)
我自己选了【c】完全不看,因为我在绝大多数情况下使用 useEffect 是为了模拟 componentDidMount,在阅读他人代码时我也用监听思维去理解。但是完全不参考 linter 提示,会对开发者的细心程度要求比较高,而且脑子不能犯浑,因为一些复杂场景中会有很多层级的父子函数,需要人工去排查所有的父子函数中用到的响应式值。
2、useCallback 的传染性
大量的冗余代码
当我在阅读那些按照官方推荐严格遵守 react-hooks/exhaustive-deps 规则的同事写的代码时,我觉得这些代码也开始变得越来越冗余:
typescript
/**
* 🟡 因为这些 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 时,就发现了这个功能貌似无法实现,因为监听了什么状态,并不意味着我处理监听回调的时候只能用到这些状态:
typescript
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 中是没有本质差别的。
我认为这么做最大的问题不在于首次开发阶段,而在于阅读历史代码逻辑和迭代开发时,比如有一个后台项目列表页,以及常见的底部页码翻页功能:
typescript
// 渲染过程也依赖 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 方法,其实也有类似问题,即丢失了直接的调用关系)。
作为对比,如果将项目列表接口的请求逻辑实现在事件处理函数中:
typescript
// 渲染过程也依赖 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 一样增加第二个回调函数参数:
typescript
/**
* 类组件
*/
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 时,不提前了解需求的情况下根本不能快速看懂代码逻辑。
举一个比列表翻页再略微复杂一点的示例让大家更清晰地感受到这个问题。比如在一个项目详情页中,每个项目都有若干个公告,用户可以单选某个公告,被选中的公告会自动展示相关的发布信息和问题反馈信息:
typescript
/**
* 以下所有的 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 事件响应函数中:
typescript
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 章 "高质量的子程序" 学习如何通过拆分子函数降低逻辑复杂度、对关键过程进行抽象。比如一个更复杂的提交操作,如果按书中介绍的原则来写,不论过程多么复杂,逻辑都会很清晰:
typescript
/** 点击 提交 */
const handleSubmitClick = async () => {
// 前端校验并保存
await validateAndSaveForm();
// 后端校验
await validateFormByBackend();
// 确认是否需要重新生成公告
await checkRegenerateAnnouncement();
// 确认是否需要重新生成采购文件
await checkRegeneratePurchaseFile();
// 提交表单
await submitForm();
// 唤起工作流
await launchWorkflow();
// 刷新表单变成详情态
await refreshForm();
};
不过要让事件响应函数中的代码都写成这种非常优雅的串行逻辑,其实需要一些额外的前端技巧。因为很可能其中一个中间过程是唤起一个弹框让用户在弹框中执行一些操作,那么我们在事件响应函数中的逻辑很可能就会以 setSomeModalVisible(true) 结尾,但其实整个提交过程才执行了一半,导致事件响应函数中无法包含完整的处理逻辑。这种时候往往需要我们从当前的事件响应函数末尾跳跃到 handleSomeModalOk / handleSomeModalCancel 这些弹框类组件的事件回调中继续阅读代码。不过这是另一个话题了,不属于本文范畴,准备以后另外开坑再来讨论。
混乱的编码抉择
回到上述后台列表翻页的场景,两种请求数据的实现方式(useEffect 和事件响应函数)我们可能都在业务代码中看到,但是对于另外一些数据请求的副作用,我们却大多不会选择 useEffect 来实现。比如有文章展示页面,需要点击"展开详情"按钮才会触发详情接口请求,我们大多会直接写在事件处理函数中:
typescript
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 来实现则有种脱裤子放屁的感觉,代码如下:
typescript
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 来实现:
typescript
/**
* 点击页面刷新按钮,触发 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 实现这类问题时,为了解决闭包问题,实际上设置监听类的逻辑会在依赖变化的时候反复触发,这和在类组件中设置监听完全不同:
typescript
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 输入框中当前的值。
typescript
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 的值(虽然这违背了最小状态量的原则):
typescript
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 之外的 AI,首次回答都完美踩坑,只有 DeepSeek 直接想到了要用 Input 自带的 ref 拿到 DOM 元素从而获取输入框的值,还节省了同步逻辑,牛啤!
为什么我们以前没注意到这个问题呢,因为大多数情况下状态变化不会这么频繁,误差也仅出现在状态变化的时候,产品功能上大多都没感知到这点误差,但从逻辑上这是不严谨的。并且这个功能如果用类组件来实现时,完全不会遇到这个问题:
typescript
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 建连和断连,要不断使唤网线了(大误)。
typescript
// 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 中同步额外维护一份:
typescript
// 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 环节容易被忽略。
比如下面这个真实的低级错误:
typescript
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 成一个常规的变量即可:
typescript
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 研发经验的开发者都可以避免的,我们再来看下面这两个例子:
typescript
/**
* 🟡 页面初始化时,需要基于用户信息判断是否要弹框提醒用户绑定手机号
*/
const Page = () => {
const [tipModalVisible, setTipModalVisible] = useState(false);
// 🟡 useUserInfo 是一个很基础的业务 Hook,会在组件 didMount 时自动请求当前登录用户的相关信息
const userInfo = useUserInfo();
// 查询到用户信息后,判断用户是否绑定手机号
useEffect(() => {
if (userInfo && !userInfo.phone) {
setTipModalVisible(true); // 显示提示弹框
}
}, [userInfo]);
// ...
};
typescript
/**
* 🟡 抽屉组件中会渲染一个项目列表,用户在抽屉内选中一条项目数据并后点击「确定」按钮后,会将这条项目数据传递给页面中的其他组件
*/
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 内书写一些非副作用逻辑。
四、全新的 useInit 解决方案
1、避开依赖项声明
在第二章中提到过我不会将由用户事件触发的接口请求写到 useEffect 中。那如果遇到了一个组件的某个 props 确实会变化怎么办?比如 <ProjectInfo id={id} /> 组件的 id 是会动态变化的,需要监听 id 的变化去重新请求项目详情信息。这种情况我会选择利用 key 让这个组件销毁然后重新创建一个新的组件实例:
typescript
// 每当 key 变化
// 原有的 ProjectInfo 组件实例就会被销毁
// 全新的 ProjectInfo 组件会从 componentDidMount 开始重启一个生命周期
const a = <ProjectInfo key={String(id)} id={id} />;
// 如果原本要监听多个状态,那就拼多个变量为字符串(虽然不太优雅,但是非常实用,感觉后续可以搞个 HOC 稍微封装一下模板字符串的逻辑)
const b = <ProjectInfo key={`${id}_${bizType}`} id={id} bizType={bizType} />;
官方对于这个特性的介绍:《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》,这样每当遇到需要监听的 props 时,优先考虑能否使用 key 这个技巧让原组件销毁,不需要考虑属性变化之后组件内部要如何修改保证可以适应这种变化(重置一些状态等),因为组件一次生命周期内 id 和 bizType 是永远不会变化的。因此我平常很少写 useEffect 依赖,直到有一次参与一个研发周期比较长的重点项目重构,必须要用 addEventListener 设置很多监听器时:
typescript
const handleFooEvent = (event) => {
/* ...... */
};
/** 监听 foo 事件 */
useEffect(() => {
const eventName = 'foo-event';
window.addEventListener(eventName, handleFooEvent);
return () => window.removeEventListener(eventName, handleFooEvent);
}, [stateA, stateB]); // 🟡 __todo 上线前确认一遍依赖是否正确
const handleBarEvent = (event) => {
/* ...... */
};
/** 监听 bar 事件 */
useEffect(() => {
const eventName = 'bar-event';
window.addEventListener(eventName, handleBarEvent);
return () => window.removeEventListener(eventName, handleBarEvent);
}, [stateC, stateD]); // 🟡 __todo 上线前确认一遍依赖是否正确
/** 监听 xxx 事件 */
/** 监听 yyy 事件 */
/** 监听 zzz 事件 */
为了避免闭包问题,必须要在 useEffect 中声明依赖,因为我日常开发完全不会参考 react-hooks/exhaustive-deps 这个 ESLint 校验规则(就是这么倔...),所以只能人工判断在依赖中声明必要的 state 和 props,因为开发周期比较长,我知道后续事件响应函数的实现逻辑肯定会变化,依赖也必定会再次变化,所以我保留了几个 todo 的注释提醒自己。直到项目后期要开始逐渐清理项目中的 todo 时,我发现这几个确认依赖的 todo 最保险的还是在上线当前再清理,因为你不知道上线前会不会有 bug 要改动事件响应函数里的逻辑,所以我想了一个办法来解决这个问题:
typescript
const handleFooEvent = (event) => {
/* ......*/
};
/**
* 监听 foo 事件
* 🟡 维护依赖太麻烦了,而且后续迭代增减 state 状态之后非常容易出错,所以每次组件更新时都把最新的事件处理函数同步到 ref 中
*/
const handleFooEventFnRef = useRef(null);
handleFooEventFnRef.current = handleFooEvent;
useEffect(() => {
const eventName = 'foo-event';
const handler = (event) => handleFooEventFnRef.current(event);
window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler);
}, []);
const handleBarEvent = (event) => {
/* ......*/
};
/**
* 监听 bar 事件
* 🟡 维护依赖太麻烦了,而且后续迭代增减 state 状态之后非常容易出错,所以每次组件更新时都把最新的事件处理函数同步到 ref 中
*/
const handleBarEventFnRef = useRef(null);
handleBarEventFnRef.current = handleBarEvent;
useEffect(() => {
const eventName = 'bar-event';
const handler = (event) => handleBarEventFnRef.current(event);
window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler);
}, []);
我用 ref 来规避了闭包问题,既然已经规避了闭包问题,那就再也不需要声明依赖,因为这个代码没有很好的可读性(无法自说明),所以我给每个 useEffect 都加了很多一样的注释。再往后就是基于这个模式进行的深入思考和自定义 Hook 设计讨论,最终的产物就是两个自定义 Hook(源码详见 fool-proofing-hooks):Init Hook 和 Watch Hook:
2、"重新学习 React Hook"
在开始介绍这两个 Hook 之前,请大家先忘掉副作用的概念,也忘掉 React Hook,更不知道 useEffect。让时间重新回到 2018 年,你是一个有一定类组件开发经验的前端工程师,你看到 React 正式发布了 React Hook,你开始跟着官方文档学习:
-
先看了 Hook 简介 和 Hook 概览
-
然后学习了 State Hook,也就是 useState 的使用
-
从这里开始打住,在这个"平行世界"里你并没有学习 useEffect 而是学习了 useInit
"平行世界"的官方文档:当你想在 componentDidMount 和 componentWillUnmount 这两个生命周期中编写逻辑时,你可以使用 useInit 这个等价于以上两个生命周期的 Hook:
useInit 基础示例
typescript
const Page = (props) => {
/**
* -----------------------------------------------------------
* useInit 基础示例一
* -----------------------------------------------------------
*/
// 回调函数等价于 componentDidMount
useInit(() => {
// mount callback
console.log('componentDidMount');
});
// 回调函数中返回的回调函数等价于 componentWillUnmount
useInit(() => {
// mount callback
return () => {
// unmount callback
console.log('componentWillUnmount');
};
});
// 两个回调函数配合使用,便于在组件销毁时执行一些清理动作,避免内存泄露问题
useInit(() => {
// mount callback
const intervalId = setInterval(() => {
console.log('now:', Date.now());
}, 1000);
return () => {
// unmount callback
clearInterval(intervalId);
};
});
/**
* -----------------------------------------------------------
* useInit 基础示例二
* 使用多个 useInit 实现关注点分离,即一个 Hook 只做一件事
* -----------------------------------------------------------
*/
// 初始化逻辑
useInit(() => {
initPage();
});
// 新手指引逻辑
useInit(() => {
const { destroy } = Modal.info({
title: '新手指引',
content: '......',
});
return () => {
destroy();
};
});
/**
* -----------------------------------------------------------
* useInit 基础示例三
* 当不需要用到 unmount 回调时,useInit 直接支持 async 语法
* -----------------------------------------------------------
*/
// useInit 第一个入参支持 async 语法的函数(即返回值为 Promise 实例)
useInit(async () => {
// mount callback
await sleep(1000); // 模拟异步任务
console.log('组件初始化逻辑执行成功');
// 当前逻辑不需要在 unmount 时执行任何清理动作
// return () => {
//
// };
});
// 但是要注意下,用了 async 语法就不能再返回一个 unmount 回调函数
useInit(async () => {
await sleep(1000);
// 使用 async 语法时若返回了一个函数,ts 类型会报错,并且运行时 console 里会异步抛出一个不影响后续逻辑的异常
// Uncaught Error: If callback of 「useInit」 need return a cleanup callback, don't use async syntax.
return () => {
// 这个函数也不会被执行
console.log('unmount callback');
};
});
return <div>......</div>;
};
export default Page;
useInit 进阶示例
typescript
const Page = (props) => {
/**
* -----------------------------------------------------------
* useInit 进阶示例一
* 使用 useInit 的第二个参数规避闭包问题
* -----------------------------------------------------------
*/
// 需要实现的功能:
// 定义一个 number 类型的 state 命名为 inputCount 并和页面中的一个 input 的值绑定
// 定义一个 number 类型的 state 命名为 randomCount 并展示在页面上
// 每隔 1 秒生成为一个 0 ~ 9 的随机数 X
// 若该随机数 X 不等于当前的 inputCount 或 randomCount,则把 randomCount 设置为 X
// 若该随机数 X 等于当前的 inputCount 或 randomCount,则忽略本次随机出的 X
const [inputCount, setInputCount] = useState('0');
const [randomCount, setRandomCount] = useState(0);
const handleInputChange = (e) => {
setInputCount(e.target.value);
};
/**
* 定时器处理函数
* 函数中的逻辑依赖了两个状态:inputCount 和 randomCount
* 但是我们并不需要在任何地方去额外指出我们依赖了这两个状态,因为 useInit 会自动帮我们处理闭包问题
* 因此我们实现函数的时候,包括在后续迭代的时候,都不需要考虑闭包问题,可以直接访问到 state 和 props 的最新值
*/
const handleInterval = () => {
console.log('interval callback start');
// 生成 0 ~ 9 的随机数
const num = Math.floor(Math.random() * 10);
const blackList = [Number(inputCount), randomCount];
if (blackList.includes(num)) {
console.log(`skip setRandomCount: ${num} is in ${blackList}`);
} else {
setRandomCount(num);
}
};
// 设置定时器
useInit(
/**
* mount callback
*
* 若 useInit 传递了第二个参数 funcMap,则回调函数可以通过 self 对象拿到特殊处理过的 funcMap
* 所谓的特殊处理其实就只是将原本 funcMap 上的方法实时更新到 ref 中并固化函数的引用地址,从而解决闭包问题
*
* @param {typeof funcMap} self - 可以想象成类组件中的组件实例 this,从 self 上拿到的方法就像类组件中的原型方法是没有闭包问题的
* self 上有哪些"原型方法"是通过 useInit 第二个参数 funcMap 定义的,只能在 self 中定义函数方法,不能定义字符串、数组等"原型属性"
* 但是不同 useInit 中的 self 以及其中的方法是独立的,从这个角度来说 funcMap 定义的其实是"实例方法"而非"原型方法",self 也只是"局部作用域内的实例概念"
* 如果想要在整个组件层面使用实例概念去定义一些"实例属性",并能在组件的所有地方都能实时访问和修改,请使用 useInstance(详见第六章),其实就是 useRef
* @returns {() => void} unmount callback
*/
(self) => {
// mount callback
const intervalId = setInterval(self.handleInterval, 1000);
return () => {
// unmount callback
clearInterval(intervalId);
};
},
/**
* 第二个参数 funcMap,即声明 self 上有什么方法可以调用,类似在 class 组件中定义原型方法
*/
{ handleInterval }
);
/**
* -----------------------------------------------------------
* useInit 进阶示例二
* 若 WebSocket 的回调中有依赖 state / props 的逻辑
* -----------------------------------------------------------
*/
// 需要实现的功能:
// 监听 WebSocket 消息,并在每次拿到消息时,实时对比消息中的 count 值
// 若大于上一个示例中的 randomCount 则保留该条消息,否则忽略该条消息
const [wsData, setWsData] = useState({});
// 处理 WebSocket 消息
const handleMessageEvent = (data) => {
const { count } = data || {};
if (count > randomCount) {
console.log('--- 符合条件,接收本次 WebSocket 数据 ---', data);
setWsData(data);
} else {
console.log('*** 不符合条件,舍弃本次 WebSocket 传输过来的数据 ***', data);
}
};
useInit(
(self) => {
// mount callback
const ws = new WS('/ws/message');
ws.onMessage = self.handleMessageEvent;
return () => {
// unmount callback
ws.close();
};
},
{ handleMessageEvent }
);
return (
<div>
<h4>inputCount by user input</h4>
<input type="number" value={inputCount} onChange={handleInputChange} />
<br />
<h4>randomCount by interval</h4>
<pre>{randomCount}</pre>
<br />
<h4>valid WebSocket data</h4>
<pre>{JSON.stringify(wsData, null, 4)}</pre>
</div>
);
};
export default Page;
如何理解 self 和第二个参数 funcMap
-
会被多次异步执行的业务逻辑,都应该封装成方法放到 funcMap 中,类似于在 class 组件中定义了一个单独的事件处理函数,然后 self 就能像 class 中的 this 实例一样去实时访问到没有闭包问题的事件处理函数。
-
声明了 funcMap,那么 useInit 回调中的逻辑就应该只剩下非常少量的代码:
-
设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数。
-
清理对应的监听器。
-
typescript
/** 泛指各类会被多次异步执行的处理函数 */
const handleFunc = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
// 场景一
useInit(
(self) => {
window.addEventListener('my-event', self.handleFunc);
return () => {
window.removeEventListener('my-event', self.handleFunc);
};
},
{ handleFunc }
);
// 场景二
useInit(
(self) => {
const id = setInterval(self.handleFunc, 1000);
return () => {
clearInterval(id);
};
},
{ handleFunc }
);
- funcMap 上 95% 的情况中都只需要声明一个方法,因为代码要做到关注点分离,即一个 hook 只做一件事,而一件事则只应该只有一个入口函数。另外 5% 的场景是考虑到可能部分监听器有多种回调需要处理,或者类似的外部事件需要批量监听,配置不同的 handler。
typescript
/**
* 某些监听器可能会有很多个回调需要处理
*/
const handleWebSocketConnected = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleWebSocketMessage = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleWebSocketDisconnected = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
useInit(
(self) => {
const ws = new MyWebSocket({
url: '/ws/api',
onConnected: self.handleWebSocketConnected,
onMessage: self.handleWebSocketMessage,
OnDisconnected: self.handleWebSocketDisconnected,
});
return () => {
ws.close();
};
},
{
handleWebSocketConnected,
handleWebSocketMessage,
handleWebSocketDisconnected,
}
);
typescript
/**
* 一次 hook 中一次性监听多个相似类型的事件
*/
const handleEventA = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleEventB = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleEventC = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
useInit(
(self) => {
window.addEventListener('event-a', self.handleEventA);
window.addEventListener('event-b', self.handleEventB);
window.addEventListener('event-c', self.handleEventC);
return () => {
window.removeEventListener('event-a', self.handleEventA);
window.removeEventListener('event-b', self.handleEventB);
window.removeEventListener('event-c', self.handleEventC);
};
},
{
handleEventA,
handleEventB,
handleEventC,
}
);
唯一的陷阱
如果完全按照上面的说明来定义 funcMap 和使用 self,则不会遇到闭包问题。但是给出最常见的错误示例,也是很重要的:
typescript
const WrongDemo = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
} = props;
const [foo, setFoo] = useState(false);
// ...... 组件的其他逻辑中会调用 setFoo 修改 foo 的值 ......
const handleMyEvent = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
// 命名声明了 funcMap 并使用了 self 获取事件处理函数,为什么还是遇到闭包问题了?
useInit(
(self) => {
// 基于 handleMyEvent 封装了一层
const handler = () => {
if (foo) {
// 🔴 基于 foo 的 if 逻辑不属于设置监听器,不应该出现在 useInit 的回调中
// 问题说明:在 handler 被异步执行时,foo 永远是初始值 false
self.handleMyEvent();
}
};
const eventName = `my-event-${dataId}`; // 因为这个场景中 dataId 不会变化,所以不用考虑闭包问题
window.addEventListener(eventName, handler);
return () => {
window.removeEventListener(eventName, handler);
};
},
{ handleMyEvent }
);
return <div>......</div>;
};
在 useInit 的设置监听器逻辑中,已经将如何处理自定义事件这件事抽象在了 handleMyEvent 这个子函数内,那么和处理自定义事件相关的所有逻辑(细节实现、子函数、高阶函数等等)都不应该再出现在 handleMyEvent 中,所以这个功能正确的实现方式是:
typescript
const RightDemo = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
} = props;
const [foo, setFoo] = useState(false);
// ...... 组件的其他逻辑中会调用 setFoo 修改 foo 的值 ......
/**
* 再次强调:只有将 state 和 props 写在异步事件处理函数内,且放到 funcMap 中时才能避免闭包问题
*/
const handleMyEvent = (...args) => {
// 🟢 基于 foo 的 if 逻辑应该放到 handleMyEvent 中
if (foo) {
// ......
}
};
/**
* 当使用了 useInit 的第二个参数 funcMap 时,回调中的逻辑就应该只剩下非常少量的两类代码:
* 1、设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数
* 2、清理对应的监听器
*/
useInit(
(self) => {
// 设置监听器
const eventName = `my-event-${dataId}`; // 因为这个场景中 dataId 不会变化,所以不用考虑闭包问题
window.addEventListener(eventName, self.handleMyEvent);
return () => {
// 清理监听器
window.removeEventListener(eventName, self.handleMyEvent);
};
},
{ handleMyEvent }
);
return <div>......</div>;
};
用 useWatch 填补最后的空白
让我们继续给上面的例子增加复杂度,假设组件 props 接受一个 mode 参数,根据 mode 来决定具体监听什么类型的事件:
typescript
enum ControlModeEnum {
Pending = 'pending', // 模式待定
EventA = 'event-a', // 监听 event-a 事件控制数据变化
EventB = 'event-b', // 监听 event-a 事件控制数据变化
}
const DataComponent = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
mode, // 组件首次渲染时,mode 为 Pending,由父组件异步决定最终的控制模式
} = props;
const [data, setData] = useState<any>(null);
const handleEventA = (...args) => {
// 根据 event-a 事件修改 data
};
const handleEventB = (...args) => {
// 根据 event-b 事件修改 data
};
useInit(
(self) => {
const eventAName = `event-a-${dataId}`;
const eventBName = `event-b-${dataId}`;
// 🔴 在 didMount 时,mode 的值肯定是 pending
if (mode === ControlModeEnum.EventA) {
window.addEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
return () => {
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
};
},
{ handleEventA, handleEventB }
);
useInit(async () => {
const initData = await fetchInitialData(dataId);
setData(initData);
});
return <pre>{JSON.stringify(data, null, 4)}</pre>;
};
DataComponent 组件要根据 mode 决定监听事件 A 还是事件 B,而 mode 又是一个会变化的 props,且在这个场景中还有另一个 useInit 在 mode 初始化之前会先执行 fetchInitialData 这个动作,若在父组件中等到 mode 异步确定后再渲染 <DataComponent /> 组件,则会导致 fetchInitialData 的执行时机被延后(当然如果业务能接受这点性能差异,那这么做是最简单的)。
基于 mode 的 if 逻辑明显属于设置监听器逻辑的一部分,所以这个 if 逻辑就应该实现在 useInit 中,但是当执行 useInit 回调的时候,mode 的值肯定是 pending,出问题了。
其实我们可以先想象一下如果用类组件这个功能该如何实现。既然 mode 是会被父组件异步初始化的,那必然不能将设置事件监听器的逻辑写在 componentDidMount 中,必须要用到 componentDidUpdate 这个生命周期:
typescript
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
};
// props.dataId 在当前组件整个生命周期中不会变化
this.eventAName = `event-a-${props.dataId}`;
this.eventBName = `event-b-${props.dataId}`;
}
componentDidMount() {
this.fetchInitialData(this.props.dataId).then((initData) => this.setState({ data: initData }));
}
handleEventA = (event) => {
// 根据 event-a 事件修改 data
};
handleEventB = (event) => {
// 根据 event-b 事件修改 data
};
componentDidUpdate(prevProps) {
const { mode } = this.props;
const { mode: prevMode } = prevProps;
// 我们假设 mode 只会从 Pending 变为 EventA 或 EventB,并不会在 EventA 和 EventB 之间切换
// 否则用类组件来实现这个功能会略微有点麻烦,这也不是我们要讨论的重点
if (mode !== prevMode) {
if (mode === ControlModeEnum.EventA) {
window.addEventListener(this.eventAName, this.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(this.eventBName, this.handleEventB);
} else {
// do nothing
}
}
}
componentWillUnmount() {
const { mode } = this.props;
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(this.eventAName, this.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(this.eventBName, this.handleEventB);
} else {
// do nothing
}
}
render() {
return <pre>{JSON.stringify(this.state.data, null, 4)}</pre>;
}
}
当一个功能我们在类组件中只能通过在 componentDidUpdate 中判断属性或状态的变化来实现的时候,在函数组件中,我们则可以使用 Watch Hook 提供的 props / state 监听能力来实现:
typescript
const DataComponent = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
mode, // 组件首次渲染时,mode 为 Pending,由父组件异步决定最终的控制模式
} = props;
const [data, setData] = useState<any>(null);
const handleEventA = (...args) => {
// 根据 event-a 事件修改 data
};
const handleEventB = (...args) => {
// 根据 event-b 事件修改 data
};
// Watch Hook
useWatch(
/**
* deps
*
* 既然要监听 props / state 的变化,那监听什么就是最关键的,所以我们把监听数组作为第一个参数
*/
[mode], // 🟡 监听什么那些响应式值(如 state 和 props)需要人工判断
/**
* watch callback
*
* 每当监听数组中的任意一个依赖项发生了变化,就会执行该回调函数(在 componentDidMount 时也会自动执行一次,此时 prevDeps 中的所有项都是 undefined)
*
* @param {typeof funcMap} self - 和 useInit 一样,self 是用于解决闭包问题的
* @param {typeof deps} prevDeps - 数组中记录了被监听的依赖项在上一次渲染时的旧值
* 当监听了多个依赖项时,可以通过依次对比新旧的值来确定监听回调是因为哪个依赖项变化而触发的
* @returns {() => void} cleanup callback - 用于执行清理动作的回调
* 当前 watch callback 返回的 cleanup callback 会下一次被监听依赖项发生变动后,下次 watch callback 执行前被执行
* 最后一次 watch callback 返回的 cleanup callback 会在 componentWillUnmount 时被执行
*/
(self, prevDeps) => {
// watch callback
const eventAName = `event-a-${dataId}`;
const eventBName = `event-b-${dataId}`;
if (mode === ControlModeEnum.EventA) {
window.addEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
return () => {
// cleanup callback
// 在这个例子中,因为 mode 从 Pending 变为 EventA 或 EventB 后不会再发生改变
// 所以事件监听器 A / B 将会在 componentWillUnmount 时解绑
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
};
},
/**
* 第三个参数 funcMap,即声明 self 上有什么,类似在 class 组件中定义原型方法
*/
{ handleEventA, handleEventB }
);
useInit(async () => {
const initData = await fetchInitialData(dataId);
setData(initData);
});
return <pre>{JSON.stringify(data, null, 4)}</pre>;
};
谨慎使用 useWatch
但是要注意 useWatch 是一个 danger 的用于实现监听类逻辑的 Hook,真正适用的场景其实比较少(绝大部分场景有更适合的方案)。当你想用 useWatch 实现某个功能,或者在代码中看到 useWatch 时,请优先确认能否用以下方案实现这个功能:
-
如果要监听的依赖项是自身 state:
- 优先让当前组件在 setState 的时候通过函数调用直接触发对应逻辑。
-
如果要监听的依赖项是 props:
-
如果这个 props 在整个组件生命周期中都是不会变化的,那么请使用 useInit 来实现这个逻辑。
-
如果这个 props 只是需要进行异步初始化,则可以在父组件中等异步初始化完成后再渲染当前组件。
-
如果这个 props 确实是会不断变化,则:
-
优先考虑在父组件中给当前组件设置 key 实现组件在依赖项变化时自动销毁并创建新组件,具体参考《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》。在一些需要异步初始化的业务组件中,可能会出现的 UI 界面抖动问题,可以使用骨架图、CSS Transitions 等方式解决。
-
可以考虑将依赖项变化时要执行的逻辑抽离成独立的函数方法,通过 useImperativeHandle 将函数方法挂载到 ref 上,从而让父组件直接调用该方法,例如一些弹框/抽屉类组件可以通过 ref 向外暴露一些 showModal/showDrawer 方法避免在这些组件内部监听 visible 属性执行部分 state 的重置逻辑。但若 props 透传层级过深,或抽离的函数方法不适合被父组件调用(比如父组件不应该关心这个子组件内部过程),则不适用这个方式。
-
-
如果以上方案都不能很好地实现你的功能,那么你才可能需要使用 useWatch。在使用过程中,请注意以下几个点:
typescript
/**
* 🟡 useWatch 和 unInit 一样,可以不声明 funcMap 参数,可以不返回 cleanup callback
*/
useWatch([foo, bar], () => {
console.log('watch:', foo, bar);
});
/**
* 🟡 使用 useWatch 和在 componentDidUpdate 判断依赖有一个小差异
* 即 useWatch 在首次渲染之后也会执行,此时的 prevDeps 数组中的每一项都是 undefined
*/
useWatch([foo, bar], (_, prevDeps) => {
/**
* 实际打印:
* prevDeps: [undefined, undefined]
* prevDeps: [..., ...]
* prevDeps: [..., ...]
* prevDeps: [..., ...]
*/
console.log('prevDeps:', prevDeps);
});
/**
* 🟡 useWatch 监听哪些依赖和回调函数中会用到哪些 props / state 是没有任何关联的,请自行确监听目标声明正确
*/
useWatch([lastName, firstName], () => {
// 仅在姓名变化的时候进行打印,监听了 2 个状态,用到了 3 个状态
console.log(`The name was changed to ${firstName} ${lastName} at ${timestamp}.`);
});
最后在用到 useWatch 的场景中,也建议主动找靠谱的同事进行 Code Review 相互确认一下,以及确保有合适的注释能让代码有较高的可读性。
至此,现在这个"平行世界"的 React Hook 方案已经介绍完了,在编写平时的业务代码时,相比于原本的 useEffect 方案:
-
你几乎不用写依赖(除了极少数场景需要必须要用到 useWatch 时,要明确要监听的数据是什么)
-
很少会感知到闭包问题
再配合第一章中对于 useCallback 和 useMemo 的观点:PureComponent 式的优化方案在迭代中是极其脆弱的,应该优先用其他方式解决性能问题:
-
你完全不用写 useCallback,是的我们顺便也消除了 100% 的 useCallback
-
你几乎不会用到 useMemo(仅在基于 state / props 进行昂贵的衍生计算时需要使用)
PS: 不过在写一些需要封装到 npm 包中的非常基础的业务 Hook 时,因为考虑到 npm 包引用方的环境不可控(比如除了修复问题之外不再进行功能迭代的历史项目工程),在确保代码质量的前提下,在向外暴露变量时还是要尽量用一下 useMemo 和 useCallback 正确包裹。
回顾第三章中提到的所有 useEffect 的问题,我们的新方案基本都很好地解决或者规避了。
Perfect ! 立马在团队内找几个前端工程试行这套新的 React Hook 编码范式,以后新的需求对应的实现代码不允许再使用 useCallback和 useEffect**。**
3、自定义 Hook 设计问题
依赖异步数据的初始化逻辑
在试行这套新的方案时,很快我们遇到了一个问题,useInit 和很多原有的自定义 Hook 不太兼容,例如我们原本封装了一个公共的自定义 Hook 叫做 useProjectInfo:
typescript
/** 某个前端工程中公共的"查询项目信息 Hook" */
const useProjectInfo = () => {
const [info, setInfo] = useState(null);
const fetchInfo = async () => {
const { projectId } = qs.parse(location.search.slice(1));
const query = qs.stringify({ projectId });
const response = await fetch(`/project/detail?${query}`);
const data = await response.json();
setInfo(data);
};
useEffect(() => {
fetchInfo();
}, []);
return {
projectInfo: info,
};
};
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
if (!projectInfo) {
return null;
}
return <pre>{JSON.stringify(projectInfo, null, 4)}</pre>;
};
当某次迭代中,服务端在接口返回中增加了一些配置信息或者需要将 projectInfo 中的部分数据单独作为 state 时,原本用 useEffect 我们可能会这么写:
typescript
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
const [itemList, setItemList] = useState([]);
const handleAddItemBtnClick = () => {
setItemList([
...itemList,
{
name: '商品名称',
createTime: Date.now(),
},
]);
};
const handleDeleteItemBtnClick = () => {
// ......
};
const handleSaveBtnClick = async () => {
await saveProjectInfo();
await message.success('保存成功,页面将自动刷新。');
window.location.reload();
};
useEffect(() => {
if (!projectInfo) {
return;
}
// 获取到项目级信息后,执行以下额外逻辑:
// 读取某些配置,给予用户提示
if (projectInfo.configuration.needTip) {
Modal.info({
title: '提醒',
content: '请注意......',
});
}
// 将部分数据单独设为 state,因为页面中新增了商品新增、删除类功能(保存之前不生效)
// 而 projectInfo 中的 itemList 则视为服务端目前实际存储的商品数据
setItemList(_.clone(projectInfo.itemList || []));
}, [projectInfo]);
if (!projectInfo) {
return null;
}
return (
<div>
<div>......</div>
<pre>{JSON.stringify(projectInfo, null, 4)}</pre>
</div>
);
};
这种代码 useEffect 用法在我们团队中是比较常见的,我们用 useWatch 也能实现:
typescript
const { projectInfo } = useProjectInfo();
// ......
useWatch([projectInfo], () => {
if (!projectInfo) {
return;
}
// ...... 剩余代码和 useEffect 完全相同
});
但是如果这么玩,useWatch 的使用也会在工程中泛滥,这种用法根本不在我们 useWatch 的设计预期内,因为这些逻辑本质上还是属于组件初始化阶段的一次性逻辑,所以我们尝试过新增了一个 Hook 支持这种依赖异步数据的组件初始化逻辑(目前该 Hook 已被废弃):
typescript
const { projectInfo } = useProjectInfo();
// ......
// 🟡 useInitWhenReady 内部会自动监听第一个参数,等到第一个参数为真值时,再执行初始化逻辑
useInitWhenReady(Boolean(projectInfo), () => {
// 获取到项目级信息后,执行以下额外逻辑:
// 读取某些配置,给予用户提示
if (projectInfo.configuration.needTip) {
Modal.info({
title: '提醒',
content: '请注意......',
});
}
// 将部分数据单独设为 state,因为页面中新增了商品新增、删除类功能(保存之前不生效)
// 而 projectInfo 中的 itemList 则视为服务端目前实际存储的商品数据
setItemList(_.clone(projectInfo.itemList || []));
});
相比于 useEffect 和 useWatch 语义化确实更强了,也能复用各种已有的业务 Hook,而且还可以同时处理多个异步依赖:
typescript
const { projectInfo } = useProjectInfo();
const { articleInfo } = useArticleInfo();
const { announcementInfo } = useAnnouncementInfo();
// ......
useInitWhenReady(
Boolean(projectInfo) && Boolean(articleInfo) && Boolean(announcementInfo),
() => {
// 异步获取到各种信息后,再执行初始化逻辑
},
);
看起起来很不错,除了 isReady 的判断有点丑,但如果某个页面在迭代后 articleInfo 和 announcementInfo 的请求入参中需要额外传入 projectInfo 中的部分数据(这里不讨论服务端接口合并,这是两个话题),我们不得不调整自定义业务 Hook 中的逻辑,让他们支持类似 ahooks 中的 useRequest 手动触发模式:
typescript
// useProjectInfo 逻辑不用修改
const useProjectInfo = () => {
/* ...... */
};
// 修改 useArticleInfo 逻辑使其支持手动触发接口请求
const useArticleInfo = (options) => {
const {
manual = false, // 🟡 因为 useArticleInfo 作为通用业务 Hook 被引用的地方很多,所以通过增加配置项的方式来实现该功能以兼容其他场景
} = options | {};
const [info, setInfo] = useState(null);
const fetchInfo = async (extraParams = {}) => {
const { projectId } = qs.parse(location.search.slice(1));
const params = {
projectId,
...(manual ? extraParams : {}),
};
const query = qs.stringify(params);
const response = await fetch(`/article/detail?${query}`);
const data = await response.json();
setInfo(data);
return data;
};
useEffect(() => {
// 🟡 手动模式时,不自动触发请求
if (!manual) {
fetchInfo();
}
}, []);
return {
articleInfo: info,
fetchArticleInfo: fetchInfo,
};
};
// 修改 useAnnouncementInfo 逻辑使其支持手动触发接口请求
const useAnnouncementInfo = (params, options) => {
/* ...... 代码修改方式同 useArticleInfo ...... */
};
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
const { articleInfo, fetchArticleInfo } = useArticleInfo({ manual: true });
const { announcementInfo, fetchAnnouncementInfo } = useAnnouncementInfo({ manual: true });
useInitWhenReady(Boolean(projectInfo), () => {
fetchArticleInfo({
bizId: projectInfo.bizId, //
});
fetchAnnouncementInfo({
type: projectInfo.announcementType,
});
});
if (!projectInfo) {
return null;
}
return (
<div>
<div>......</div>
<pre>{JSON.stringify(projectInfo, null, 4)}</pre>
<pre>{JSON.stringify(articleInfo, null, 4)}</pre>
<pre>{JSON.stringify(announcementInfo, null, 4)}</pre>
</div>
);
};
或者我们干脆也让 useProjectInfo 支持 manual 模式,这样我们也就可以用回 useInit 了:
typescript
useInit(async () => {
const projectInfo = await fetchProjectInfo();
fetchArticleInfo({
bizId: projectInfo.bizId,
});
fetchAnnouncementInfo({
type: projectInfo.announcementType,
});
});
关于自定义 Hook 的额外规范
例子说完了,分析以上场景,感觉 useInitWhenReady 在一些场景中确实有必要,那我们为什么还是把这个 Hook 废弃了呢?其实主要是回到了我们第一章中的观点:"代码意图的正确传达 "和"保持代码结构一致性"。
-
在最后示例中,使用 useInit 的代码语义化明显好于 useInitWhenReady,因为只看这十行左右的代码,你无法知道 projectInfo 是从哪来的。其实很多时候如何判断语义化是否优秀,可以把代码当作自然语言(中文 / 英语)来阅读 ,越通顺越好,这样才能更好地确保代码意图的正确传达。
-
在上面的几次迭代中,因为多了一个 Hook 之后实现方式的选择变多,导致我们的代码结构在不断地变化:
-
引入 useInitWhenReady
-
修改部分自定义业务 Hook 额外支持 manual 手动模式
-
修改全部自定义业务 Hook 额外支持 manual 手动模式
-
删除 useInitWhenReady 改回 useInit
-
......
-
而且我们完全有理由去猜测,以后 useInitWhenReady 会被用在一些不在设计意图内的错误场景中,比如下面这种骚操作:
-
所以最后的结论是弃用 useInitWhenReady,并在我们全新的 React Hook 编码范式中,对所有的业务自定义 Hook 设计有一层额外的约束:
-
封装了业务接口请求的自定义 Hook 只能包含状态和函数的定义,不能在自定义 Hook 内包含会自动执行的逻辑,即不能包含 useInit 和 useWatch 这两个 Hook。
-
不过可以将 useInit 和 useWatch 其封装到绑定监听器类的自定义 Hook 中(例如 useOnlineStatus),因为绑定监听器的逻辑一般不会有执行顺序要求,而且监听回调本质上也不是由组件内部触发的。
那么所有常见的数据请求类的自定义 Hook 都应该是类似这样的,有点类似只支持 manual 手动模式的 useRequest,但和 useRequset 不同的是,无论是手动触发请求的函数还是 Hook 本身,都会返回 state,一个用于 UI 渲染,一个用于 JS 逻辑:
typescript
/**
* 自定义 Hook 中只能包含 useState、useRef 以及常规函数、变量的定义
*/
const useXxxInfo = () => {
const [info, setInfo] = useState(null);
const fetchInfo = async (params = {}) => {
const query = qs.stringify(params);
const response = await fetch(`/project/detail?${query}`);
const data = await response.json();
// 🟡 注意下面两行代码
setInfo(data); // 请求成功后自动更新 state 用于 UI 渲染
return data; // 同时异步向外暴露最新的 state 值用于 JS 逻辑
};
// 向外暴露 state 和对应的方法,需要在外部手动调用这些方法来触发 Hook 中封装的逻辑
return {
projectInfo: info,
fetchXxxInfo: fetchInfo,
};
};
这种封装方式既保证了良好的可读性,只看所有的 useInit 就可以清晰地了解初始化逻辑。也确保了在不断的需求迭代中,代码整体结构尽量不发生大的变化,因为所有的初始化逻辑调整都将收敛在 useInit 中:
typescript
const { projectInfo, fetchProjectInfo } = useProjectInfo();
const { articleInfo, fetchArticleInfo } = useArticleInfo();
const { announcementInfo, fetchAnnouncementInfo } = useAnnouncementInfo();
// 场景一:请求没有先后顺序
useInit(async () => {
fetchProjectInfo();
fetchArticleInfo();
fetchAnnouncementInfo();
});
// 场景二:其中一个请求要前置(被另外所有接口依赖)
useInit(async () => {
const pInfo = await fetchProjectInfo();
fetchArticleInfo({
bizId: pInfo.bizId,
});
fetchAnnouncementInfo({
type: pInfo.announcementType,
});
});
// 场景三:其中一个请求要前置(被另外一个接口依赖)
useInit(async () => {
fetchArticleInfo();
const pInfo = await fetchProjectInfo();
fetchAnnouncementInfo({
type: pInfo.announcementType,
});
});
// 场景四:其中多个请求要前置(被另一个接口依赖)
useInit(async () => {
const [pInfo, aInfo] = await Promise.all([fetchProjectInfo(), fetchArticleInfo()]);
fetchAnnouncementInfo({
type: pInfo.announcementType,
articleId: aInfo.id,
});
});
不过这样自定义 Hook 设计规范后期是无法实现目前社区中 SWR 类的缓存优化的(例如 useSWR)。不过在这个 useSWR 的入门示例 中,我倒认为 <Navbar user={user} /> 用受控组件形式去接收 user 对象也没啥大问题,如果在使用了 useSWR 后当这个 Navbar 组件需要跨业务场景复用时,你反而会发现这个组件和具体数据接口耦合了。还有一些确实要在短时间内减少网络接口请求的场景中(比如移动端弱网场景),其实可以先让服务端先用传统的 HTTP 缓存策略进行优化,这样的缓存策略也不会入侵到前端代码,且哪些接口能缓存、可以缓存多久本来就是服务端要评估和维护的。
至此,对于我们新的 React Hook 编码范式的介绍已结束!