React Hooks与异步任务队列结合,纯干货

3,419 阅读7分钟

问题的背景

之所以想聊聊这个话题,主要是业务上碰到了一个场景:当用户进入某页面后,会视用户情况,连续出现A、B、C、D、E 5个异步任务不等(其中ACDE 4个任务具有弹窗交互,如果某个弹窗曾经已完成过,则不出现,直接启动下一个任务);此外,当用户下单时,也要前置触发这一系列任务(任务A除外)检查可用性。

一个任务不论是完成或取消,它都能根据自己的结果决定后续任务是否继续执行,依此类推。

任务可能会有前置条件:比如A只针对英国用户生效;比如BCD都需要用户登录信息,未登录的用户不启动任务。但用户信息的获取需要一定时间,所以在未获取到用户登录信息之前这段时间不能简单将其视为未登录用户直接不启动,而是需要等待。

有的任务可能会依赖前置任务的结果,比如CDE 3个任务会依赖B任务的检测结果(检测结果存储于localStorage中)。

当我描述完以上这些要求时,我觉得听众可能会分为两部分:一部分是了解过异步有序任务队列(比如浏览器的事件循环机制),他们的脑海中会瞬间浮现出一套通用解决方案;而另一部分则是没有了解过的,很有可能被这些需求绕晕。

异步任务队列

通常,异步任务队列都是有序串行执行的。

我们可以举个现实中常见的例子类比一下,大家就清楚了:有很多顾客光顾奶茶店,通常是在前台下了单之后取个号拿个闹钟,然后可以坐在那里边等边刷手机,也可以去逛逛街,奶茶做好之后手上的闹钟一响就告诉你奶茶好了,可以去拿了。

这就是典型的异步有序队列场景了,异步是因为你在下完单之后可以去做任何你想做的事情,等奶茶好了就会通知你,有序是你的奶茶是按序号来制作的,不用担心被插队。

实际上规模稍大一点的奶茶店,会有多个店员同时制作奶茶,很有可能12号与13号是同时制作完成的,甚至有可能12号会晚于13号。这便是异步并发队列场景,比如有3个店员,便能以3倍的效率同时执行奶茶任务。此外,异步并发队列场景也分为两种:一种是基于Promise.all实现的,拿到现实中就是3个店员同时取号制作奶茶,不论谁先完成,都必须等其它两人都完成后才能继续后续3杯奶茶的制作;另一种则是3名店员独立执行,不用等其它人,做完自己的这杯马上继续下一杯的制作,这种更贴切实际,效率也更高。

当然,这种并发场景并不适合我们的需求,我们需要的只是串行执行异步任务队列。

一个简单的异步任务队列流程图(图一):

image.png 图一

当我们将图中的逻辑转换为代码,大约如图二或图三所示:

image.png 图二

image.png 图三

很简单,每个任务都是一个返回Promise的可执行函数,当任务内部 resolve 时则代表继续执行下一个,当任务内部 reject 时则代表结束这一切。我们发现这样做能对多个任务进行解耦,便于后期扩展与维护

那它能解决本文开头提出的问题吗?这得分场景来看:

  • 如果是在纯 js 代码中运行它,那么它能。
  • 如果是在React class组件中运行它,也是可以的,不过需要对UX交互做一定改造,比如弹窗需要做成方法调用;另外,针对用户登录信息的获取也需要封装成Promise形式,否则做不到等待。
  • 如果是在React hooks中运行它,那就不行了,因为 task 会有闭包+变量过时的问题发生(将task返回的函数修改为useRef能解决这个问题,但是会带来调试麻烦的问题,另外,await task.current()真的不好看);公司提供的获取用户登录信息的API也是 Hooks 的形式,所以无法 await,只能利用 useEffect 的第二个参数去做响应,但是useEffect中的for of一旦开始执行任务队列,我们是无法取消并重新开始的。那怎么解决这个问题呢?这便是下一节所要探讨的问题了。

与Hooks结合

我们知道,Hooks有一个特点,就是修改状态(setState)后或所使用的hooks产生变化后(useXXX)就会重新执行,那我是不是能将任务队列进行拆分,将 task队列分散到每一次 Hooks 的执行中去跑呢?这样将解决一系列问题:

  • 当某个task的响应变量更新 -> task更新 -> task队列更新 -> Hooks重新执行,每次Hooks执行时,所有的task都一定是最新的

  • 当异步前置条件(useXXX的hooks形式)尚未获取到时,我们可以暂停任务,直到前置条件更新 -> task响应变量更新 -> task更新 -> task队列更新 -> Hooks重新执行,此时再根据前置条件决定是否继续任务 这两个优点足够诱人了,解决了我们最棘手的两个问题。

我写了个demo ,大家可以点击进去把玩一下,我针对task与queue特别设计了两个hooks:useTask 和 useQueue。

其中useTask能对function进行包装,内部使用Promise进行异步处理(图四):

image.png 图四

其中name参数为任务名称,便于调试时观察任务执行情况;callback为任务执行函数,queue调度器会传入3个参数

  • resolve(当前任务完成,queue继续执行)
  • reject(当前任务失败,queue失败,结束并抛出异常)
  • abort(当前任务完成,queue结束执行)

isPause参数是一个返回Promise的函数,queue调度器执行task任务前会调用它进行判断,是否有前置条件导致任务需要暂停,如果返回true则暂停当前queue的执行,false则调用callback。

而useQueue则是任务调度的核心(图五)

image.png 图五

deferred对象是我们用于内部处理异步状态的工具,此外我们在启动任务后返回了它,对外部的使用提供了try…catch能力,一旦抛出异常,外部任务也可以中止。

nextExecuteIndex使用了useState存储,代表进入下一个任务的索引序号,正是它让我们拥有了利用Hooks更新的特性去分片执行任务的能力。

executeIndex存储了当前正在执行的索引序号,用于与nextExecuteIndex判断是否一致, 不一致则进入下一次任务的执行,一致则不做什么。这是因为我们并不知道tasks的变化频率,它一旦变化,useEffect必定会重新执行,不比较的话任务肯定会混乱。

此外,我们对外提供的启动方法里提供了index参数,让外部可以选择从哪个任务开始执行,比如文章开始提到了“当用户下单时,也要前置触发这一系列任务(任务A除外)”,利用这个参数我们可以有效的控制任务队列的起始位置。

结语

这个任务前后花费了我2天多的时间,还花了1天左右的时间去完善细节,demo中去掉了敏感的业务细节,但重新抽象了可随时复用的hooks又花了一天。

希望我趟过的这些坑能帮助大家减少一些工作量,省下时间多陪陪家人和孩子。

虽然Hooks的闭包特性给我们带来了很多不便,尤其是很多设计模式感觉格格不入。不过本文探讨的这种异步队列控制方式,我个人还是觉得结合得比较优雅的,扩展与维护性非常强,在很多类似的场景上(比如下单前的一系列验证、问卷调查等只要涉及到连续异步任务都可以派上用场。

image.png