yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

前端开发中的状态机 #57

Open yinguangyao opened 3 years ago

yinguangyao commented 3 years ago

1. 前言

在真实世界中,状态无处不在。红绿灯的颜色、人的喜怒哀乐、公司 OA 系统的审批流程等等都属于状态。 在 Web 开发中,状态也无处不在,选项卡的切换、loading 状态、开关按钮等等都有状态之间的切换。实际上我们前端开发中也是在和不同的状态打交道,知名前端框架 React 中就有 state 的概念。

2. 状态模式

2.1 红绿灯的例子

先来看个红绿灯的例子,红绿灯一般会有三种状态,红灯表示禁止通行,绿灯表示准许通行,黄灯表示警示,这三种状态对应了不同的行为。 如果是用传统的方式来编写代码,那就是像下面这样,需要用一堆 if...else 或者 switch...case 来判断状态。

class TrafficLight {
    constructor(state) {
        this.state = state;
    }
    switch() {
        if (this.state === 'green') {
            this.state = 'yellow';
            console.log("警示");
        } else if (this.state === 'yellow') {
            this.state = 'red';
            console.log("禁止通行");
        } else if (this.state === 'red') {
            this.state = 'green';
            console.log("准许通行");
        }
    }
}
const light = new TrafficLight("red");
setInterval(light.switch, 60 * 1000); // 假设每 60s 切换一次状态

这段代码主要就有下面这三个问题:

  1. 不符合开闭原则:如果以后哪天规定了,交通灯三种颜色不够用,需要再加个蓝色,那么就只能去修改 switch 方法。
  2. 可维护性差:如果以后一直增加新的颜色,我们也无法预料这个代码会膨胀到什么程度。
  3. 状态切换关系不明显:从上面的判断语句里面,我们无法很清晰地看出状态之间的转换关系。

2.2 状态模式重写红绿灯

状态模式是一种行为型模式,对象可以根据状态的改变来改变自己的行为。 一般来说,我们都是封装对象的行为,而非对象的状态。而在状态模式里面,就是把事物的每种状态都封装成独立的类,和状态有关的行为都在类的内部。在切换状态的时候,只需要在上下文中把请求委托给当前状态对象就行了。

image_1e1rvqh5tsq21cvs193a6bu15av9.png-29.4kB

以上面的代码为例,我们不需要在 switch 方法中进行判断、转换,只要把对应的状态转换关系封装在不同的状态类中就行了。

class RedLight {
    constructor(light) {
        this.light = light;
    }
    switch() {
        this.light.setState(this.light.greenLight);
        console.log("准许通行");
    }
}
class YellowLight {
    constructor(light) {
        this.light = light;
    }
    switch() {
        this.light.setState(this.light.redLight);
        console.log("禁止通行");
    }
}
class GreenLight {
    constructor(light) {
        this.light = light;
    }
    switch() {
        this.light.setState(this.light.yellowLight);
        console.log("警示");
    }
}

这里的 light 即上下文,在每次切换状态的时候它不会进行实质性的操作,而是委托给状态类来执行。

class Light {
    constructor() {
        this.greenLight = new GreenLight(this);
        this.redLight = new RedLight(this);
        this.yellowLight = new YellowLight(this);
    }
    setState(newState) {
        this.currentState = newState;
    }
    init() {
        this.currentState = this.redLight;
    }
}
const light = new Light();
light.init();
setInterval(light.currentState.switch, 60 * 1000)

从上述代码中就可以看出状态模式的优势,if...else 已经被消除了,我们不需要通过 if...else 来控制状态之间的切换。如果以后增加新的状态,只需要增加新的状态类就行了。 状态模式的缺点就是会在系统中创建大量的类,增加了系统的负担。同时逻辑分散在不同的状态类中,我们无法清晰地看出整个状态机的逻辑。

3. 状态机

3.1 什么是有限状态机?

有限状态机(Finite-state machine)是编译原理中的一个概念,表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。

状态机这个词可能对于大家来说比较陌生,但在 Promise、generator 中都有状态机的影子。

有限状态机一般具有以下特点:

1)可以用状态来描述事物,在任何时刻,事物总是处于一种状态;

2)状态总数是有限的;

3)通过触发某种条件,事物可以从一种状态转变到另一种状态;

4)状态变化按照规则来,可以从 A 到 B,从 B 到 C,但不一定能从 A 到 C。

5)同一个条件,可以将事物从多个状态变成同一个状态,却不能从一种状态变成多种状态。

image_1e1f2sdi51mmdkid19q719sk19ss9.png-74.6kB

如果用公式来描述一下这个转换,那就是:

nextState = prevState + action

如果你有写过 React/Redux,会发现和他们的原理是如此相似,甚至 React 中也是用 state/setState 来命名状态/修改状态。

3.2 javascript-state-machine

在 web 开发中,我们也经常会遇到状态切换的场景,比如有个灯泡,点击打开按钮就会发亮,点击关闭按钮就会关闭;常用的菜单栏,鼠标移上去会展开,鼠标移开会隐藏等等。 如果结合状态模式来实现,那就是下面这样:

class Menu {
    constructor() {
        this.on = new On(this);
        this.pff = new Off(this);
    }
    setState(newState) {
        this.currentState = newState;
    }
    init() {
        this.currentState = this.on;
        const button = document.querySelector("#btn");
        button.onclick = function() {
            this.currentState.click();
        }
    }
}
class On {
    constructor(menu) {
        this.menu = menu;
    }
    click() {
        this.menu.setState(this.menu.off);
    }
}
class Off {
    constructor(menu) {
        this.menu = menu;
    }
    click() {
        this.menu.setState(this.menu.on);
    }
}

上面这段代码虽然实现了功能,但还存在这么几个问题:

  1. 代码复杂化:每次都需要对新状态增加一个类。
  2. 代码耦合:状态转换和业务逻辑耦合在一起。
  3. 转换关系不清晰:状态之间的转换关系不够清晰。

前面我们讲过表驱动,如果使用表来配置转换之间的转换关系呢?将状态的转换封装起来,我们只需要实现业务逻辑就行了。 现在已经有一个库帮你实现了这个功能。javascript-state-machine 是一个经典的有限状态机库,它的用法也比较接近表驱动。 我们可以定义初始状态、转换规则,以及其转换后的回调函数。

 const fsm = new StateMachine({
    init: 'off',
    transitions: [
      { name: 'show', from: 'off',  to: 'on' },
      { name: 'hide', from: 'on', to: 'off'  }
    ],
    methods: {
      onShow: function() { console.log('打开') },
      onHide: function() { console.log('关闭') }
    },
    error: function(eventName, from, to, args, errorCode, errorMessage) {
      return 'event ' + eventName + ': ' + errorMessage;
    },
  });

javascript-state-machine 在使用的时候,常常需要生成一个实例,将初始状态、转换规则等属性当做配置传进去。

除了这些常规的 API 之外,javascript-state-machine 还提供了一系列的生命周期的钩子函数。

看到这里,你有没有觉得和 Vue/Mobx 很像?Vue 和 Mobx 都实现了例如 watch/reaction 这种功能,允许你监听某个属性的变化,自动去执行某些操作。 如果将 methods 改成 actions,也许你会发现这个理念和 redux 非常相似。 在我看来,不管是 react 还是 redux 都有状态机的思想在里面。在 react 中,local state 代表组件内部的状态,粒度比较细,常和一些 UI 上面的交互有关。 而在 redux 里面,global state 是一个更复杂的状态机,它常常用来管理一些全局的状态。

3.3 业务中的有限状态机

如果你有做过下拉分页列表的需求,那么一定会经常遇到这些问题。

  1. 当拉到当前页面的底部的时候,需要去加载下一页的数据,这个时候我们常常设置一个 isLoading 的状态,当数据加载成功之后会将 isLoading 设置为 false。
  2. 但是有时候因为网络差、接口超时这些问题,会导致请求失败,这个时候就需要做一些错误提示,还要加个 isError 的状态。
  3. 当点击重新加载的时候,就又会进入 loading 状态,重新请求接口,重复第1、2步。
  4. 如果到了最后一页,列表底部就需要展示“已经没有更多”之类的话术,这时要设置一个 isFinish 的状态。

整个流程的状态图如下:

image_1e23idcv01uq9l4hgbg1lb46nom.png-20.3kB

可以看到,在代码中管理这么多状态本来就已经很头疼了,还要考虑处理各种转换的场景。

/* 真实的业务场景下远比这个要复杂 */
if (isLoading) {
    $("#loading").show();
    // 请求接口...
} else if (isError) {
    $("#loading").hide();
    $("#error).show();
    // 点击重新请求...
} else if (isFinish) {
    $("#loading").hide();
    $("#finish").show();
}
状态转移图: 当前条件/状态 loading error finish
弱网、超时 error
重新加载 loading
最后一页 finish

这么多状态之间的转换,如果用状态机来实现,是不是会清晰很多呢?

const fsm = new StateMachine({
    init: 'noLoading',
    transitions: [
        { name: 'error', from: 'loading',  to: 'error' },
        { name: 'finish', from: 'loading', to: 'finish'  },
        { name: 'tryAgain', from: 'error', to: 'loading' },
        { name: 'loading', from: 'init', to: 'noLoading' }
    ],
    methods: {
        onEnterLoading() {
            $("#loading").show();
        },
        onLeaveLoading() {
             $("#loading").hide();
        },
        onEnterError() {
            $("#error").show();
        },
        onLeaveError() {
            $("#error").hide();
        },
        onEnterFinish() {
            $("#finish").show();
        },
        onTryAgain() {
            // 重新请求...
        }
      }
    }
  });

经过 javascript-state-machine 重构后的代码,虽然在这个例子中看起来代码量增加了,但这里将 DOM 操作和业务逻辑解耦开了,我们都不需要关心每次 DOM 的隐藏和展示,这在复杂业务下的可维护性会大大提高。

推荐阅读

  1. 设计模式:一目了然的状态机图
  2. web 开发中无处不在的状态机
  3. JavaScript与有限状态机
  4. 如何把业务逻辑这个故事讲好@张克军_ReactConf CN 2018