记得两年前的差不多这个时候,我曾经写了一篇文章 编写状态驱动的业务代码,讲了一下我使用状态来描述业务需求的一个经历。自那之后,我很少再接触步骤如此多的业务需求,直到前一段时间……
一个复杂的页面
运营平台有一个页面,是专门用来配置运费规则的,里面有最多 12 个步骤(12 个对话框),一个可能的步骤如下:
- 基础信息配置,其中可以选择是否需要配置一些“可选费用”(例如燃油费等)
- 地区配置
- 如果地区配置为 XXX,则需要配置 YYY 费
- 如果第一步中选择了燃油费,则配置燃油费
- 如果第一步中选择了 XXX 费,则配置 XXX 费
- ……
这个页面最开始不是我写的,之前的同事正是使用了一个状态转移函数来负责步骤间的切换。不过对于如此多的状态,这段代码还是对接手的人太不友好了:
在我还不了解之前的需求的时候,突然的一大堆 switch-case,看的我眼花缭乱。更麻烦的是,产品写的 PRD 上面,多了好几个形如“如果 XXX 则跳过 YYY 步骤”的特判。我觉得这段代码已经无法满足后续的维护需求了,于是便让之前的同事帮忙整理了一下业务逻辑,画了一张状态转移图出来,大概是这样的:
看了之后感觉还是挺震撼的,另外一个同事不禁发出了如下感慨:
玩笑归玩笑,代码还是要写的。整理了一下,产品的新需求其实就是在图中加了几条边而已,于是我稍微更新了一下这张图。
理清了思路,接下来就是如何利用当前状态、这个转移图、当前上下文这三个条件,来计算下一个状态了。其实这并难不倒我,既然这是个图结构,那么我就用计算机中的“图”来存储它!
图结构的用法
本文不是一篇数据结构文章,因此不会详细的讲解图结构的代码(网上的资料已经足够多了)。图结构在计算机中有三种最常用的存储方式:邻接矩阵、邻接表、边数组,我决定使用邻接表来做。为了简便,代码里面会有一些 JS 的特性(例如 C 就无法很方便的像 Object 一样快速查找)。
在上面的图片中,状态转移图的每个点可以认为是一个字符串,每条边有两个属性,那么整个图结构的 TypeScript 定义如下:
interface GraphType {
[key: string]: Array<{
next: string;
cond?: (state: Record<string, any>) => boolean;
}>;
}
根据上面的状态转移图,整张图的样子大概是这样的(由于原代码涉及到了公司业务,因此用简单的字符串来代替一下):
const buildGraph = (baseState: Record<string, any>) => {
// 静态的边
const g: GraphType = {
'aFee': [
// 必须保证所有的边都是互斥的,
// 否则如果有两条边的条件此时都满足,
// 会跳到哪个点,就要依赖定义的顺序了
{ next: 'bFee', cond: state => state.aaa === AAA },
{ next: 'cFee', cond: state => state.aaa !== AAA },
],
'bFee': [
{ next: 'cFee', cond: state => state.ccc === CCC },
{ next: 'dFee', cond: state => state.ccc !== CCC },
],
'cFee': [
// 一条边可以不设置条件,即“无条件跳到下一个点”
{ next: 'dFee' },
],
// ....
};
// 可以针对一些条件动态加边
if (baseState.XXX === XXX) {
g.xFee.push({ next: 'yFee' });
}
return g;
};
至于点击“下一步”之后该跳到哪个步骤,判断的思路就是:先找到当前的点,然后依次枚举从这个点出发的每条边,看是否满足边的 cond
,如果满足则直接返回这条边的终点。代码如下:
const fun = (g: GraphType, curPage: string, type: string) => {
// 先实现“下一步”
if (type === 'next') {
// 不知道什么原因没有找到当前页面对应的点,
// 或者当前点没有边,那就停留在当前页面吧!
if (!g[curPage] || !g[curPage].length) {
return curPage;
}
// 枚举从当前点出发的每条边
for (const edge of g[curPage]) {
// 如果这条边没有设置条件,或当前状态可以满足条件
if (!edge.cond || edge.cond(state)) {
// 直接返回这条边的终点
return edge.next;
}
}
} else {
// “上一步”过会儿实现
}
// 正常来说只要图建对了,代码是不会走到这儿的,
// 但一些特殊情况有可能,例如图中每条边的条件都不满足。
// 作为 fallback 我返回了当前点,也可以直接报错。
return curPage;
};
那么“上一步”呢?我一开始考虑的是生成一张反图(点不变,边的方向相反),这样就可以先根据 type
确定用正图还是反图,然后剩下的逻辑就一样了。不过事实证明我想的太简单了,例如有两个点 A 和 B,经过了相同的条件,走到了同一个点 C,这是在业务中允许的。然而如果是这样,那么在反图中,从 C 出发就会有两条 cond
相同的边,但分别指向的 A 和 B,这样是会出问题的!
使用栈来记录路径
我又稍微想了一下,用户从第一个步骤开始,经过一通操作,到达了某个步骤,这个过程在图中就是一条路径。我们只需要简单的用一个栈来记录当前用户走过的路径,这样用户点击“上一步”的时候,就可以直接弹栈了。
// 存储路径用的栈
const stack = [];
const fun = (g: GraphType, curPage: string, type: string) => {
if (type === 'next') {
if (!g[curPage] || !g[curPage].length) {
return curPage;
}
// 这是第一个元素,先入栈再说
if (!stack.length) {
stack.push(curPage);
}
for (const edge of g[curPage]) {
if (!edge.cond || edge.cond(state)) {
// 用户往前走了一步,需要入栈
stack.push(edge.next);
return edge.next;
}
}
} else {
// 点击“上一步”时,直接弹栈
stack.pop();
return stack[stack.length - 1] || curPage;
}
return curPage;
};
图的更新
因为在一些界面可能会编辑一些配置信息,这会影响到图,因此点击下一步之后,需要跳到另外一个界面。最终我决定将 fun
函数、图的更新函数,以及那个栈一起,放到一个大的闭包中:
export const createPageSwitcher = (baseState: Record<string, any>): PageSwitchFunc => {
let state = baseState;
let g = buildGraph(state);
let stack: string[] = [];
const fun = (type: string) => {
// ...刚才的 fun 函数,curPage 其实就是 stack 的顶端元素
};
fun.updateGraph = (nextState: Record<string, any>, clearStack = false) => {
state = nextState;
g = generateGraph(state);
if (clearStack) {
stack = [];
}
};
return fun;
};
这样调用方式如下:
// 初始化
const switcher = createPageSwitcher(this.state);
// 下一页
console.log(switcher('next'));
// 上一页
console.log(switcher('prev'));
// 修改了 state 之后调用的,用来更新图,但保留当前已走过的路径
switcher.updateGraph(nextState, false);
大功告成!之后产品如果想再修改这些步骤,对于开发人员来说就简单多了,只需要修改 buildGraph
部分的代码就可以了。
为了保险起见,我针对不同的图、当前点、操作类型,为这些函数仔细编写了单元测试。
版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。