![f69351a2444a379cb2cc0dfb1344a7e0.png](https://i-blog.csdnimg.cn/blog_migrate/afc7e889887fef7cbd07f724e6a1d1f9.jpeg)
(知乎上的动图压得太厉害了 ,可以在语雀上查看高清版)
异步逻辑问题一直是前端开发中的难点之一。我们知道 JavaScript 是基于事件循环机制执行的,代码并非以视觉顺序从上到下运行,而是取决于相关事件发生的时间与次序。随着前端页面的复杂度不断上升,代码中需要处理的事件类型越来越多:用户交互(键盘/鼠标)、网络请求与响应、服务器消息推送、组件生命周期回调……。当不同的事件交织在一起时,维护复杂异步逻辑的成本是非常高的。此时我们应当借助一些合适的工具以降低书写异步代码的复杂度,例如使用 Promise 优化回调函数的写法,利用 async/await 简化 Promise 的创建与使用,抑或是引入 redux-saga/RxJS 这样的类库来针对性地解决问题。
以下面的表格为例,考虑表格需要展示大量数据,前端采用了异步加载数据的方式。用户每点击一次展开按钮,表格会从后端去加载对应节点下方的内容。用户点击某个子公司之后,表格会发起请求查询该子公司下有哪些门店。
![b66dbb6fa96c2b3f0031f6a3e613a736.gif](https://i-blog.csdnimg.cn/blog_migrate/cd37b6e485325ac3ef43b4d1cf237abf.gif)
在实际使用中,用户可能会连续展开多个节点,表格将连续发送多个请求。在理想情况下,后端处理速度足够快,后端响应总是快于下一次用户交互,我们在开发时只需要考虑“展开1——响应1——展开2——响应2”的事件顺序,代码写起来也较为简单。但在请求处理较慢时,例外情况就会发生:上一次响应尚未回来时,用户进行了点击操作(展开1——展开2——响应1——响应2);或是响应顺序与请求顺序不一致(展开1——展开2——响应2——响应1);也可能是因为响应太慢,在响应回来之前,用户切换了页面(前端路由),表格组件被卸载了。如果我们在开发时不注意这些细节,那么难免会导致以下问题:
![bed263d422df56f8f7e013f36517155b.png](https://i-blog.csdnimg.cn/blog_migrate/62ec31ca6497d85112a2c46446fd2ae7.jpeg)
![74f3e8dc36e8172d4c56bb41c9c406bc.gif](https://i-blog.csdnimg.cn/blog_migrate/3962891848b74f271bf105fbb7865794.gif)
一般来说,对于「上一个响应尚未回来,下一个请求将被发起」的情况,我们可以有以下几种处理方案(以下简称异步处理方案):
- mergeAll:不进行额外的判断,直接发起下一个请求
- switchAll:下一个请求发起时,抛弃/忽略上一次请求的响应;即我们只关心最后一次请求(喜新厌旧 )
- exhaust:上一个请求尚未完成时,忽略所有后续请求
- concatAll:将所有的请求排队,上一个请求处理完成之后按序处理下一个请求
- debounce / buffer:缓存/忽略一开始过快的请求,等到时机成熟时(例如1秒内没有新的请求时)再进行处理。
上述每一种异步处理方案都有各自适用的场景,在编程中我们需要分析实际情况来为每一种交互挑选最为合适的方案。
- 当后端接口非常稳定且响应速度很快时,我们可以选择最简单的 mergeAll 方案;一般来说 mergeAll 是回调函数写法下的默认行为,实现成本最低;
- 当下一个请求的结果会影响/覆盖上一个请求的结果时,switchAll 较为合适;
- 如果请求处理成本较高,当一个请求正在处理时,我们应该使用 exhaust 来忽略后续请求(同时在页面上也相应地展示 loading 动画)
- 而对于短时间内操作频繁——但又不希望被忽略的情况,concatAll 不失为一个好的方案。
- ……
上面的各个方案只是在模型/逻辑层面对请求进行了处理,保证了页面在极端情况下页面的鲁棒性;为了使得页面更加易用,在视图层面我们也需要透出相应的载入/出错提示,让用户直观感受到正在进行的操作与操作的结果。
在 RxJS 的世界中,有许多操作符能够将高阶的 Observable 转换为一阶的 Observable,例如 mergeAll, switchAll, exhaust… 这些操作符会以不同的方式来「打平」高阶 Observable,这些不同方式恰好对应了前面的各个异步处理方案(这也是方案名称的由来)。从这里我们也可以看出 RxJS 强大的表达能力:当我们用 Observable 去表达表格状态、表格事件,并用操作符的组合去实现表格逻辑之后,通过切换操作符就可以切换表格采取的异步处理方案。
在其他实现方式下,实现单个方案就有不小的成本(例如通过回调函数去实现 concatAll 方案,需要手动维护一个队列),更不用提在不同方案之间进行切换的成本了。而在 RxJS 下,异步方案与具体表格数据加载逻辑是完全解耦的,切换方案只需要简单地切换操作符即可。而当需要新增一个自定义方案时,我们只需要实现一个新的操作符即可。
下面附上相关代码和不同方案下的表格行为:
const loadLeftOrLoadTop$ = action$.pipe(
filter(action => action.type === 'expand-left' || action.type === 'expand-top'),
map(action =>
of(action).pipe(
withLatestFrom(dataAdapter$, baseQueryConfig$),
// of(action) 只会同步地发送一个值然后立刻结束,所以这里可以用任意 join 操作符
switchMap(([action, dataAdapter, baseQueryConfig]) => {
const promise = dataAdapter.queryDrillTree(baseQueryConfig, action.node)
return from(promise).pipe(
// 得到响应的时候重新获取一遍最新的 state$;因为在响应回来之前 state$ 可能已经发生变化
withLatestFrom(state$),
map(([subTree, state]) => {
if (action.type === 'expand-left') {
return {
type: 'load-left',
leftTree: replaceSubTree(state.leftTree, subTree),
}
} // else 表格上方逻辑与左侧逻辑相同,这里省略
}),
)
}),
),
),
// 这里用 concatAll 来将所有的 expandLeft/expandTop 操作放入到一个队列,保证每个操作依次执行
concatAll(),
// 我们可以替换成其他操作符(switchAll/exhaust...)来切换异步处理方案
// map(...), concatAll(...) 也可以优化为 concatMap(...)
)
![bc5ccb2e556ff35cf4bee6cc1ee6ee26.gif](https://i-blog.csdnimg.cn/blog_migrate/cd0e80d44078de6c763b776e258424eb.gif)
![1e6492acc612040a1b07b3f448c22198.gif](https://i-blog.csdnimg.cn/blog_migrate/f9d12b96eaf44f2840b417e22943a4c9.gif)
![5294005f3ab15d36ffa4132b7c3c6402.gif](https://i-blog.csdnimg.cn/blog_migrate/33ee26785c60d97c5d2f106f7f9599cf.gif)
concatAll 可以参见本文第一张动图。debounce/buffer 方案与高阶操作符方案类似,不过该方案还需要另一个 Observable 作为抖动结束的信号或是 flush buffer 的信号,实现起来与前述方案差别不大,这里就不再展开了。
值得一提的是,在 redux-saga 中,我们也可以通过切换 takeLatest/takeLeading/takeEvery 等 saga helpers 声明式地切换不同异步处理方案。不过因为 redux-saga 要以 redux middleware 的形式运行,对环境依赖较高;而 RxJS 则相对地可以跑在更多地方,本文中的例子就是将 RxJS 跑在了 React Hooks 中(我拿 LeetCode-OpenSource/rxjs-hooks 稍微改了一下)。
从上文中可以看出在前端开发中异步问题并不简单,即便对于「上一个响应尚未回来,下一个请求将被发起」这样一个问题,我们也要分析实际情况,并从多个方案中挑选一个最合理的方案。同时这些方案与 RxJS 中的一些高阶操作符是不谋而合的,当我们将逻辑、状态、事件等用 Observable/operators 抽象出来时,我们便能充分享受 RxJS 带来的好处,RxJS 能够让我们站在更高的角度去抽象和复用代码。最后祝大家 happy coding