0x000 导读
在前端开发中,多个任务串联、并发、多任务条件触发的场景非常常见,本篇文章是我对这方面的思考和总结。
0x001 多任务
这里的多任务指的是什么呢?我们假设以下几个场景:
-
开发一个商城应用,首页需要加载 banner 信息、商品信息、商品分类信息。假设这需要请求三个接口,那么请求这三个接口就是三个任务,而这三个接口的之间是没有相互关联的,我们可以说他们是“并发”的任务。(注:这里所谓的并发只是说不等待其他异步请求完成就开始下一个请求,非真实的并发)
-
发布一个商品的时候分为两部,第一步保存接口,第二步上架。假设保存和上架是两个接口,那么这两个接口就是两个任务,而这上架之前必须保存,因此这两个任务是“串联”的,因为发布接口必须等待保存接口完成之后调用。
-
用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面。这里的两个跳转就是两个任务,因为他是根据登陆接口的返回结果来判断行为,所以,可以称之为条件触发。
定义:
- 任务:一系列业务操作的集合,本身就可以完成一个业务,在代码中的体现可以是一个简单的函数,比如请求登陆接口,跳转某个页面。(注意:任务不局限于一个网络请求,一个弹窗、一个跳转都可以是一个任务)
- 多任务:就是多个任务
- 多任务编排:就是将多个任务按照一定的逻辑排序,这里的逻辑可以是上一个任务的返回值,比如登陆的成功和失败决定下一个任务;也可以是线性逻辑,上一个任务完成就执行下一个任务。
0x002 串联
任务的串联处理很简单,可以直接像日常一样编写代码:
await saveGoods()
await publishGoods()
但是多了就有点不太优雅了,因此我们可以使用一个reduce来处理
[saveGoods, publishGoods]
.reduce(
(prev, next) => prev.then(prevResult => next(prevResult)),
Promise.resolve()
)
优雅一点,封装一下:
function stepFlow(taskList, initialData) {
taskList.reduce(
(prev, next) => prev.then(prevResult => next(prevResult)),
Promise.resolve(initialData)
)
}
然后我们就可以这么使用了:
stepFlow([saveGoods,publishGoods])
0x003 并发
并发的处理的日常写法:
getBanner()
getGoodsList()
getGoodsGroupList()
因为这三个方法是接口请求,本身就是异步的,所以其实不做任何处理,就是并发。但是我们还有优雅一点,可以使用Promise.all():
Promise.all([getBanner, getGoodsList, getGoodsGroupList])
0x004 条件触发
真正复杂的是这种情况,串联和并发并不会造成太大的业务复杂度。在各种条件中才容易迷失方向。比如,前面的场景3(用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面),我们日常的编写方式是:
const result = await login()
if (result.hasBindEmail) {
window.location.href = '#/index'
} else {
window.location.href = '#/bind'
}
如果条件简单,则无所谓,但是如果有多层的逻辑嵌套。最后形成一个逻辑树,那么我们就会在条件逻辑中迷失,对未来的开发维护造成极大的成本损耗。
在未来,我们回顾这段逻辑,或者向新成员介绍这段业务的时候,如何最快的解释清楚这中间的逻辑关系呢?
为了解决这个问题,我尝试引入状态机来解决。
0x005 状态机
具体状态机的概念可以自行了解(阮一峰 -JavaScript与有限状态机 ),我这里进行压缩,只抽取必要的逻辑:
- 状态:每个时刻,应用都处于某个状态,比如,未登录状态,已登陆状态。状态可以随着业务进行而转变,比如未登录变成已登陆状态。
- 任务:一系列业务操作的操作,表现为一个函数。任务的执行可以为状态变化准备数据。
- 转化器:状态机根据转化器将状态从一个状态转化到另一个状态。
注意:任务和转化器其实可以合并为一个,但是这里为了分离任务和任务编排,而独立出来。之后会解释为什么要分离成两个概念。
0x006 状态机实现复杂条件多任务调度和编排
以场景3(用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面)来做例子,从场景3中我们可以分离出三个任务:
- 登陆
- 跳转首页
- 跳转绑定页面
因此我们可以定义三个任务:
function taskLogin(){}
function taskJumpToIndex(){}
function taskJumpToBind(){}
然后定义三个状态,分别是login、jumpToIndex,jumpToBind,接着我们编排状态图:
const stateMap = {
'login': taskLogin,
'jumpToIndex': taskJumpToIndex,
'jumpToBind': taskJumpToBind
}
我们将所有的状态和对应的任务都使用一个对象保存了,那么如何指定状态之间的关系呢?
我们可以让每一个状态的任务返回下一个状态,如果没有返回状态,就代表完结了:
async function taskLogin(){
const result = await login()
if (result.hasBindEmail) {
return Promise.resolve('jumpToIndex')
} else {
return Promise.resolve('jumpToBind')
}
}
这样我们就可以让状态机跑起来了:
async function run(stateMap, state){
if(!state) return;
const task = await stateMap[state]
const nextState = await task()
run(stateMap, nextState)
}
run(stateMap, login)
但是这里存在3个问题:
- 无法将上一个任务的结果传递给下一个任务。比如上面的场景中,如果
login接口的数据需要在绑定页面使用。我们必须向下传递。我们可以通过改造任务的返回值来解决:
async function taskLogin(){
const result = await login()
if (result.hasBindEmail) {
return Promise.resolve({
state: 'jumpToIndex',
payload: result
})
} else {
return Promise.resolve({
state: 'jumpToBind',
payload: result
})
}
}
我们将任务的返回值从直接返回状态改为返回一个{state, payload}对象,其中state就是下一个状态,而paylod是这次任务的结果。因此run函数需要同步修改:
async function run(stateMap, state, payload){
if(!state) return;
const task = await stateMap[state]
const nextState = await task(payload)
run(stateMap, nextState?.state, nextState?.payload)
}
run(stateMap, login)
-
这里存在
封装入侵(这里应该有专有名词,但是我忘记了),我们的任务定义必须满足要求,即(改造前)需要返回下一个状态,或者(改造后)返回结果必须是{action, payload}形式,会导致任务函数无法在其他场景复用,或者存在重构风险。 -
将对逻辑的判断封装到任务函数中,对于整个逻辑的表达没有帮助,或者相比于之前,反倒是绕了一圈,更乱了。
不使用状态机:
const result = await login()
if (result.hasBindEmail) {
window.location.href = '#/index'
} else {
window.location.href = '#/bind'
}
使用状态机:
const result = await login()
if (result.hasBindEmail) {
return Promise.resolve({
state: 'jumpToIndex',
payload: result
})
} else {
return Promise.resolve({
state: 'jumpToBind',
payload: result
})
}
对于以上 2、3 问题的解决方案都是一样的那就是将对状态的判断独立出来,任务只负责处理任务自身的逻辑,对于状态的判断交由转换器来完成:
- 任务:一系列业务操作的集合,无所谓是否返回数据。(注意:任务不局限于一个网络请求,一个弹窗、一个跳转都可以是一个任务)
- 转换器:状态的转换处理器,可以直接配置或者根据上一个任务的执行结果来决定下一个状态。和具体任务无关。
现在让我们修改一下状态图:
const stateMap = {
'login': {
task: taskLogin,
next: (result)=>{
if(result.hasBindEmail){
return {
state: 'jumpToIndex',
payload: result
}
} else {
return {
state: 'jumpToBind',
payload: result
}
}
}
},
'jumpToIndex': taskJumpToIndex,
'jumpToBind': taskJumpToBind
}
原先,状态对应的直接是任务,而现在,状态对应的是一个{task, next}的配置对象。其中,task是状态对应的任务,next就是转换器。它可以是一个函数,该函数返回一个{state, payload}对象,表示下一个状态需要的信息。
好处:
-
对
taskLogin的封装没有侵入性,他就是一个普通的函数,对于测试也非常的友好。 -
任务的流更加的清晰,代码即业务。当我们关心的是业务流程的时候,通过状态图就可以看出。如果我们关心的是具体实现,就可以看具体的任务函数。甚至,如果任务的返回值是有限的几个,我们可以直接使用一个映射表来表示:
{1: taskJumpToIndex, 2: taskJumpToBind}。
坏处:
- 有一定的学习成本
- 对于大部分场景大材小用,过度设计
这里还不算完,状态图改造之后,响应的run函数也要进行一定的改造。具体实现已经在下面的 github 仓库中完成,并发布到npm:
0x007 总结
“人们面临权衡取舍”。
使用哪种方式来编写代码千人千面,对于每一种编码方式的决策都是基于每个人的思考的。而我以上所思考的就是如何在未来--一个礼拜,一个月,一年后,快速的让我,或者一个团队的新人快速的了解这里的业务。
因此我将代码切分成任务和转化器,使用状态图将两者结合。其中:
- 任务负责执行具体的业务
- 转化器负责状态变化
- 状态图负责编排任务和转化器
其背后的思想就是状态机,而灵感来自 Docker 和 k8s。
0x008 资源
文章内提及的资源:
我的其他文章:
0x009 带货
最近发现一个好玩的库,作者是个大佬啊--基于 React 的现象级微场景编辑器。