R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

编写状态驱动的业务代码(2)

记得两年前的差不多这个时候,我曾经写了一篇文章 编写状态驱动的业务代码,讲了一下我使用状态来描述业务需求的一个经历。自那之后,我很少再接触步骤如此多的业务需求,直到前一段时间……

一个复杂的页面

运营平台有一个页面,是专门用来配置运费规则的,里面有最多 12 个步骤(12 个对话框),一个可能的步骤如下:

  1. 基础信息配置,其中可以选择是否需要配置一些“可选费用”(例如燃油费等)
  2. 地区配置
  3. 如果地区配置为 XXX,则需要配置 YYY 费
  4. 如果第一步中选择了燃油费,则配置燃油费
  5. 如果第一步中选择了 XXX 费,则配置 XXX 费
  6. ……

这个页面最开始不是我写的,之前的同事正是使用了一个状态转移函数来负责步骤间的切换。不过对于如此多的状态,这段代码还是对接手的人太不友好了:

这对接手的人太不友好了!

在我还不了解之前的需求的时候,突然的一大堆 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 协议的情况下转载。
上一篇: 由于硬件问题引发的条形码扫描 Bug
下一篇: 当表格排版遇到了合并单元格

这是我们共同度过的

第 1834 天