前端视角下的状态机范式

news2024/10/10 21:04:15

状态机范式及状态管理

前言

随着产品迭代、业务量和需求量的增加,通常系统平台会出现代码逻辑复杂、状态混乱、维护成本增加等现象。

做同一个需求,最开始可能只需一天完成,经过长期迭代后,修改一个小点就可能花上两三天甚至更多的时间。原因是代码状态维护成本太高,有可能牵一发而动全身,在一定程度上也降低了业务的迭代速度。

每个应用程序都有其内在的、无法简化的复杂度。这个复杂度是无法去除的,只能想办法进行调整或平衡。

现代前端的复杂度主要集中在:框架、通用组件、业务组件和业务逻辑。当框架和通用组件搭建完成后,系统所能承载的复杂度基本就确定了。无论后续怎么优化改善,也很难突破原有架构的天花板。

问题场景

从实际开发角度来说,程序就是维护各种状态及状态切换时要做出相应的处理。最简单的做法就是穷举:将所有可能的情况都考虑到,针对每种情况进行不同的处理。这种行为看似最直接,但其容易导致以下问题

  • 多种业务状态导致出现大量的控制变量和if-else嵌套,改动一点就有可能导致意想不到的bug

  • 只做单体功能的人,大多不能对业务整体有清楚的认知。通常会在业务功能初版开发完成后,才能发现问题,继而提出新需求,再去修正

  • 对于测试来说,开发人员对于代码的改动具体会影响哪些页面和功能是未知的,结果就是要么测试范围不完整,要么需要全量回归,成本巨大

  • 对于老功能,需要花费大量的时间梳理代码和业务逻辑,导致项目合作、交接变得困难

如何优化?

状态模式

状态模式是一种行为模式,在不同的状态下有不同的行为,它将状态和行为解耦,把状态的判断逻辑转移到表示不同状态的一系列类当中,减少相互间的依赖,可以把复杂的判断逻辑简化。

先来看一个简单的例子:写一个方法:切换状态为显示或隐藏。

// 一般写法
const stateAction = {
    state: 'show',
    change: function(){
        if(this.state == 'show'){
            this.state = 'hide'
        }else{
            this.state = 'show'
        }
    }
}
function toggleState(){
    stateAction.change();
}
// 状态模式写法
const stateAction = {
    state: 'show',
    show: function(){
        this.state = 'hide';
    },
    hide: function(){
        this.state = 'show';
    }
}
function toggleState(){
    const curState = stateAction.state;
    stateAction[curState]();
}

改写前后看似差别不大(甚至更麻烦了),如果再加一两个状态或者多种状态组合呢,大家可以思考一下两种写法的复杂度和可读性。

// 如经典FC游戏-坦克大战,前后左右移动+发射子弹,就会出现多种组合
if(act1=='上' && act2=='发射'){      //向上移动+发射
}else if(act1=='下' && act2=='发射'){//向下移动+发射
}else if(act1=='左' && act2=='发射'){//向左移动+发射
}else if(act1=='右' && act2=='发射'){//向右移动+发射
}else if(act1=='上' && act2==''){   //只向上移动
}else if(act1=='下' && act2==''){   //只向下移动
}else if(act1=='左' && act2==''){   //只向左移动
}else if(act1=='右' && act2==''){   //只向右移动
}
// 其它判断...
// 如果再加入其它行为,则这个方法就会变得及其复杂且不稳定

显然随着状态的增加,一般写法就需要添加多个判断条件,而状态模式只需要添加指定状态和对应的处理方法即可。这类场景都可以用一个通用模型表示:有限状态机。

有限状态机

有限状态机(finite-state machine,FSM),一般具有如下特点

  • 初始状态值 (initial state)
  • 有限的一组状态 (states)
  • 有限的一组事件 (events)
  • 由事件驱动的一组状态转移关系 (transitions)
  • 有限的一组最终状态 (final states)

总结就是:状态总数是有限的,且同一时间只会处于一种状态。

而其一般流程如下:列举出所有状态及其对应的事件,经过事件处理后流转到什么状态

示例1

常用的fetch(或Promise)就是典型的例子。

fetch(url).then().catch()

在这里插入图片描述

  • 初始状态:idle(空)
  • 有限的一组状态:idle、pending、fulfilled、rejected
  • 有限的一组事件:fetch、resolve、reject
  • 事件驱动状态转移关系(如上图):idle处理fetch事件、pending处理reslove和reject事件
  • 有限的一组最终状态:fulfilled、rejected

用一个日常开发中常见的场景来对比两种写法的区别:关键字模糊查询。

原始写法

直接发送请求

search(){
    fetch(url).then((data)=>{
        showList = data;
    })
}

接口响应慢或异常

search(){
    error = false;
    loading = true;
    fetch(url).then((data)=>{
        showList = data;
    	loading = false;
    }).catch(()=>{
        error = true;
    	loading = false;
    })
}

上述写法必须等待前次查询有结果才能进行下次输入,不符合热搜实际

search(){
    error = false;
    loading = true;
    canceled = false;
    fetchAbort = new AbortController();
    fetch(url,{signal: fetchAbort.signal}).then(()=>{
        loading = false;
    }).catch((e)=>{
        if(canceled){
            return
        }
        if (e && e.name == "AbortError") {
            loading = false;
            return
        }
        error = true;
        loading = false;
    })
}
cancel(){
    error = false;
    loading = false;
    canceled = true;
    if (fetchAbort) {
        fetchAbort.abort();
    }
}

其它特殊情况处理

可以看到,随着考虑的场景或需求增多,我们被迫使用大量的flag和if-else来扩写原有逻辑,显然这会导致代码的可读性、扩展性变差。

基于状态模式写法

// 方便理解,简化写法,实际需要将on中事件改为对应的处理function即可
const Actions = {
    initial: 'idle',
    states: {
        'idle':{
            on:{
                search: 'searching'
            }
        },
        'searching':{
            on:{
                resolve: 'success',
                reject: 'failed',
                cancel: 'idle'
            }
        },
        'success':{
            on:{
                search: 'searching'
            }
        },
        'failed':{
            on:{
                search: 'searching'
            }
        }
    }
}

function transition(state, event){
    return Actions.states[state].on(event)
}

在这里插入图片描述

示例2

上面提到的坦克大战的例子就可以作如下改动

// 定义各行为状态
const Actions = {
    up: function(){
        console.log('向上')
    },
    down: function(){
        console.log('向下')
    },
    left: function(){
        console.log('向左')
    },
    right: function(){
        console.log('向右')
    },
    shoot: function(){
        console.log('发射')
    }
}
// 定义控制器
class Action {
    constructor(){
        this.curSate = [];
    }
    changeState(actions){
        this.curSate = actions;
        actions.map(v=>{
            Actions[v] && Actions[v].apply(this);
        })
    }
}
// 初始化
var ctl = new Action();
ctl.changeState(['up','shoot']);// 向上移动+发射

上述例子其本质都是将条件判断的结果转化为对象内部的状态,然后对外暴露一个控制方法,且原始状态/类不依赖其他条件,每个行为及其对应的动作互不干扰。

状态模式是一种适合多状态场景下的设计模式,改写之后代码逻辑较为清晰扩展性和维护性大大增加。

状态模式的优缺点

优点
  • 每个状态对应独立行为,封装在一个类中,修改方便
  • 状态与状态之间、行为和行为之间互不干扰
  • 减少执行非必要判断语句,用哪个取哪个
  • 避免判断语句过多或嵌套
缺点
  • 需要将不同状态及其对应的行为进行拆分,有时候需要将一个动作拆分为多种状态,不好处理
  • 颗粒度过细容易导致可读性变差,无法直观看出整体逻辑

前端状态管理

当前主流的前端框架,几乎都是采用状态描述界面(state=>view),也可以说其本质就是在维护各种状态。

比如父子组件之间、兄弟组件之间的状态共享,往往需要写很多代码来实现。如把状态提升或统一到顶级组件中,相当麻烦。而如果不对状态进行有效管理,那么状态何时、因何发生变化就不受控制,难以跟踪。

对于状态管理的解决思路就是,把组件间需要共享的状态抽取出来,统一管理,由此产生了多种设计模式和实现库。

在介绍之前,简单回顾一下前端中的MV相关模式:MVC,MVP,MVVM

MVC

模型(Model)-视图(View)-控制器(Controller)。

基本结构

在这里插入图片描述

当然也可能出现直接操作Controller,或Model与View耦合等情况。MVC仅将应用抽象,并未限制数据流。

基本工作流程
  • 用户对界面进行操作,触发View的相关事件
  • View感知事件,通知Controller进行处理
  • Controller处理相关业务,并通过Model的接口对业务数据进行更新
  • Model的业务数据改变触发相应事件,通知View业务数据已经发生改变
  • View捕获到Model发出的数据改变事件,重新获取数据并更新用户界面
优点
  • 数据、用户界面、控制逻辑分开,降低耦合性,代码更容易管理和维护
  • 便于单元测试
缺点
  • 增加项目结构的复杂性,对中小型项目不友好
  • 控制器臃肿,降低程序性能,增大内存消耗

MVP

模型(Model)-视图(View)-展示者(Presenter)。

基本结构

在这里插入图片描述

MVP在MVC基础上,限制了通信方式,即Model与View之间不能直接通信。

基本工作流程
  • 用户对界面进行操作,触发View的相关事件
  • View感知这些事件,通知Presenter进行处理
  • Presenter处理相关业务,并通过Model的接口对业务数据进行更新
  • Model数据变化会通知Presenter
  • Presenter收到Model数据变化通知后,调用View暴露的接口更新用户界面
优点
  • 视图和模型完全分离
  • 所有交互都在Presenter中实现,Model设计上会更加灵活
缺点
  • Presenter会很臃肿,维护性变差

MVVM

模型(Model)-视图(View)-视图模型(ViewModel)。

基本结构

在这里插入图片描述

使用VM代替Presenter,自动从Model映射到View(模板渲染),不需要手动操作视图。

基本工作流程
  • 界面渲染完成后,ViewModel会将View和Model按照开发时声明的方式进行双向绑定

  • 用户对界面进行操作,触发View的相关事件

  • ViewModel感知View发出的事件,调用Model的接口处理业务逻辑

  • Model内容改变,用户界面立即更新,无需额外操作

优点
  • 数据双向绑定,无需手动更新界面,降低程序开发复杂性和工作量
  • 提高页面渲染速度
缺点
  • 双向绑定会额外消耗资源,占用更多内存
  • 双向绑定会使数据变化难以追踪

我们在上面提到,现代前端框架通常采用状态描述页面,那么如何合理的修改state才是最高效的?有此一问的原因在于:传统设计模式在应对复杂逻辑时通常表现不佳,数据流动混乱,如下图所示

在这里插入图片描述

可以看出,在只有一个逻辑处理规则时,不同的Model会产出多种结果。不仅数据流向混乱,而且不同的Model有可能产出相同的View。这就会带来性能问题:重渲染。

Store模式

回到上面提到的状态管理解决方式,最简单的就是把状态存到一个外部变量中,通过处理函数来修改状态,简单实现如下

var store = {
    state: {
        count: 0,
    },
    addCountAction(newValue){
        this.state.count = newValue
    }
}

在这里插入图片描述

store中state用于存数据,store中包含数个action用于改变state。因为都走了action,我们就可以知道state改变是如何触发的。

但是这里并没有严格限制各组件不能直接修改state。因此必须限定组件必须通过action来分发(dispatch)事件去通知store改变。这样可以完整记录store中所有state的改变,甚至做到快照和回滚等操作。

Flux

Flux的核心思想是利用单向数据流和逻辑单向流来应对MVC架构中出现状态混乱的问题。

基本结构

在这里插入图片描述

  • View:视图层
  • Action:动作,视图层发出的消息(如鼠标事件等)
  • Dispatcher:派发器,用来接受Actions、执行回调函数
  • Store:数据层,用来存放应用的状态,发生变动,提醒Views更新页面

需要注意的是Controller在Flux中是存在的,被称为controller-views,其通常位于顶层的视图view,这些从stores中获取数据并将数据传递给其子元素。

基本工作流程
  • 用户访问View
  • View发出用户的Action
  • Dispatcher 收到 Action,要求 Store 进行相应的更新
  • Store 更新后,发出一个"change"事件
  • View 收到"change"事件后,更新页面

上述过程中,数据总是"单向流动"的,任何相邻的部分都不会发生数据的"双向流动"。

基于上面的流程,可以对各节点进行详细说明

Dispatcher

dispatcher 是管理 Flux 应用所有数据流的中心。它本质上是注册到 stores 的回调,本身并没有任何功能,是一个分发 actions 到 stores 的简单装置。每一个 dispatcher 注册自己并提供一个回调。当一个 action 创建者向 dispatcher 提供一个新的 action,应用中所有的 stores 会通过注册的回调接收到这个 action。

dispatcher 可以通过按特定顺序调用注册的回调来管理 stores 之间的依赖关系。stores 可以声明式地等待其他 stores 完成更新(waitFor),然后相应地更新自己。

Stores

stores 包含了应用程序的状态(state)和逻辑。它们与传统的 MVC 模式中的 model 类似,但它们管理多个对象的状态,比起简单的管理 ORM 风格的对象集合,stores 管理应用特定域中的状态。

一个 store 将自己注册到 dispatcher 中,并为它提供一个回调,这个回调接收 action 作为参数。在 store 注册的回调中,使用基于操作类型的 switch 语句来解释 action,并提供合适的钩子到 store 的内部方法。这允许一个 action 通过 dispatcher 更新 store 中的状态。更新 store 后,它们广播一个事件声明状态已更改,因此视图可以查询新状态并更新自己。

Views

在顶部视图层,有一种特殊类型的视图侦听它所依赖的 stores 所广播的事件,称为 controller-view,它提供了从 stores 中获取数据并将该数据传递到其后代链的功能。

当从 store 中接收到事件时,它首先通过 stores 的公共 getter 方法请求它需要的新数据,然后调用自己的 setState() 或 forceUpdate() 方法,然后运行它的 render() 方法和它所有后代的 render() 方法。

Actions

dispatcher 暴露一个方法,允许我们触发一个 dispatch 到 stores,并且包含数据的有效负载(payload),我们称之为 action。action 的创建可以包装到语义化工具方法中,该方法将 action 发送给 dispatcher。

简单实例

下面以一个具体案例来看下具体数据流动过程(脚手架这里不做赘述)。

  • dispatcher

    创建dispatcher单例

    var Dispatcher = require('flux').Dispatcher;
    module.exports = new Dispatcher();
    
  • Action

    借助dispatcher来分发action

    "use strict"
    var dispatcher = require('./dispatcher');
    var appActionCreator = {
      create: function(text) {
        dispatcher.dispatch({
          actionType: 'CREATE',
          text: text
        });
      }
    };
    module.exports = appActionCreator;
    
  • Store

    向dispatcher注册并处理dispatcher分发过来的action,提供接口使得view可以侦听数据变化,查询数据

    "use strict"
    var dispatcher = require('./dispatcher');
    var EventEmitter = require('events').EventEmitter;
    var assign = require('object-assign');
    
    var textList = [];
    var appStore = assign({}, EventEmitter.prototype, {
      create: function(text){
        textList.push(text);
      },
      getAll: function() {
        return textList;
      },
      emitChange: function() {
        this.emit('change');
      },
      addChangeListener: function(callback) {
        this.on('change', callback);
      },
      removeChangeListener: function(callback) {
        this.removeListener('change', callback);
      }
    });
    
    dispatcher.register(function(action) {
      switch(action.actionType) {
        case 'CREATE':
          appStore.create(action.text);
          appStore.emitChange();
          break;
        default:
      }
    });
    
    module.exports = appStore;
    
  • View

    侦听store的数据变化,在用户交互时,发出action

    "use strict"
    var React = require('react');
    var appActionCreator = require('./actions');
    var appStore = require('./appStore');
    var App = React.createClass({
        componentDidMount: function(){
            appStore.addChangeListener(this._onChange);
        },
        componentWillUnmount: function(){
            appStore.removeChangeListener(this._onChange);
        },
        _onChange: function(){
            var arr = appStore.getAll();
            this.setState({'infoList': arr});
        },
        getInitialState:function(){
            var arr = appStore.getAll();
            return({'infoList': arr});
        },
        add: function(){
            appActionCreator.create(React.findDOMNode(this.refs.textArea).value);
        },
        render: function(){
            var textList = this.state.infoList.map(function(item){
                return <p>{item}</p>;
            });
            return(
                <div className="container">
                    <input ref="textArea" type="text"></input>
                    <button className="button" onClick={this.add}>新增</button>
                    {textList}
                </div>
            );
    	}
    });
    
    module.exports = App;
    
  • 实现效果如下

    在这里插入图片描述

缺点

每个应用需要手动创建一个dispatcher实例,一个应用可以包含多个store,而这些store之间可能存在依赖关系。

Redux和Vuex大家比较熟悉,这里仅作简单说明。当然有兴趣的也可以了解Dva和MobX等方式。

Redux

Redux是 JavaScript 状态容器,提供可预测化的状态管理。Redux 除了和 React 一起用外,还支持其它界面库。它改进了Flux,同时借鉴了Elm 来避免复杂性(注意:这里我们说的并非是React-Redux)。

基本结构

在这里插入图片描述

  • store:数据仓库
  • reducer:store处理数据的方法
  • actions:数据更新指令
  • UI:订阅store中的数据
简单使用
import { createStore } from 'redux'

/*
 * 这是一个 reducer,形式为 (state, action) => state 的纯函数。描述了 action 如何把 state 转变成下一个 state。
 * state 可以是任意类型
 * 当 state 变化时需要返回全新的对象,而不是修改传入的参数。
 */
function counter(state = 0, action) {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1
        case 'DECREMENT':
            return state - 1
        default:
            return state;
    }
}

// 创建 Redux store 来存放应用的状态
// API 是 { subscribe, dispatch, getState }
const store = createStore(counter);

// 可以手动订阅更新,也可以事件绑定到视图层。
store.subscribe(() =>
    const sotreState = store.getState();
	// ...
)

// 改变内部 state 唯一方法是 dispatch 一个 action。
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'DECREMENT' })

Redux的三大原则

  • 单一数据源:整个应用的state存在唯一的store中
  • state是只读的:唯一改变state的方法是触发action
  • 使用纯函数来执行修改:通过reducer接收先前的state,并返回新的state,reducer必须是一个纯函数

React-Redux是Redux官方提供的React绑定库,其使用也遵循上面的原则。

其将所有组件分成两大类:UI 组件和容器组件

  • UI 组件:只负责 UI 的呈现,不带有任何业务逻辑、没有状态、所有数据都由参数 this.props 提供、不使用任何 Redux 的 API
  • 容器组件:负责管理数据和业务逻辑,不负责 UI 的呈现、带有内部状态、使用 Redux 的 API

通常复杂应用需要配合redux-thunk(异步)、combineReducer(拆分)等插件使用,这里不做详述。

Vuex

Vuex是专门为 Vue.js 设计的状态管理库,以利用 Vue.js 的细粒度数据响应机制来进行高效的状态更新。它借鉴了 Flux、Redux、Elm等设计。

采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。它主要用来解决多个组件共享状态的问题。

基本结构

在这里插入图片描述

  • store:数据仓库
  • mutations:store处理数据的方法
  • actions:数据更新指令
  • UI:订阅store中的数据
简单使用
import { createApp } from 'vue'
import { createStore } from 'vuex'
// 创建 Vuex store 来存放应用的状态
const store = createStore({
    state () {
        return {
            count: 0
        }
    },
    // mutations同步 
    mutations: {
        increment (state) {
            state.count++
        }
    },
    // actions异步
    actions: {
        incrementAsync ({ commit }) {
            setTimeout(() => {
                commit('increment')
            }, 1000)
        }
    }
})
const app = createApp({ /* 根组件 */ })
// 将 store 实例作为插件安装
app.use(store)

// commit或dispatch
store.commit('increment')
store.dispatch 异步('incrementAsync')

当然,在应用较大时,Vuex运行将store分割成模块(作用同Redux中的combineReducer)

以上内容简单介绍了前端主流状态管理方式及其发展过程。那么在实际项目中如何使用这种设计模式呢?

可视化大屏

实际项目中,大屏类的智慧项目比重逐渐增加(如智慧园区、智慧医疗等)。相较于传统的进销存类/后台管理类的网站,可视化大屏更直观、实时和全面,再接入三方实时数据和3D技术等,可以帮助用户更清晰地理解和分析数据,提高效率。

这必然意味着系统将存在更多的交互和控制,如事件的响应、模块联动、动画效果等。一个好的交互是大屏设计的基础,也是重中之重。

这也是应用状态管理最多的场景。我们通过一个例子来说明,假设有如下设计

模块1:左右共3个子组件,四种组合效果

在这里插入图片描述

模块2:左右共2个子组件,四种组合效果

在这里插入图片描述

联动效果如下

在这里插入图片描述

这个例子实际上是大屏类项目常见的效果,它包含如下几个功能点

  • 模块1和模块2各有若干个子组件
  • 每个模块下的子组件的显隐,受控于其它条件(如查询有数据,查询无数据,查询异常、手动收缩/展示等,这里用不同状态模式来表示)
  • 从模块1跳转模块2,操作后返回需要维持模块1原有的组件状态,反之亦然
  • 提供直接切换模块的入口,重置模块状态

接下来有两种实现方式(当然路由模式更适合做模块之间无联动的纯展示效果,这里用来代指一般单页面实现,仅做效果模拟)

基于路由模式

需要定义多个flag来控制子组件,在每次切换模块时记录当前模块的flags,当激活模块时优先取缓存状态

// store
state() {
    return {
        module1State: null,
        module2State: null
    }
},
mutations: {
    setModuleState(state, {key,value}) {
    	state[key] = value;
    }
}
// 模块2示例
data(){
    // 定义控制flag
    return {
        state: 0,
		flag1: true,
        flag2: false,
    }
},
computed:{
    // 缓存的状态
    lastModuleState(){
        return this.$store.state.module2State
    }
},
mounted(){
    // 判断还原
    this.resetLastModule();
},
methods:{
    // 根据条件,设置各组件状态
    changeCompState(){
      // if-else或switch-case
      switch(this.state){
        case 0:
          this.flag1 = true;
          this.flag2 = false;
          break;
        case 1:
          this.flag1 = false;
          this.flag2 = true;
          break;
        case 2:
          this.flag1 = true;
          this.flag2 = true;
          break;
        case 3:
          this.flag1 = false;
          this.flag2 = false;
          break;
        default:
          break;
      }
    },
	// 切换到模块1时,存当前状态
    setCurModule(){
        let {flag1, flag2} = this;
		this.$store.commit('saveModuleState', { key: 'module2State', value:{flag1, flag2} })
    },
    // 还原上次状态
    resetLastModule(){
        if(this.lastModuleState){
            // 还原状态
            let { flag1,flag2 } = this.lastModuleState;
            this.flag1 = flag1;
            this.flag2 = flag2;
            // 清除缓存
            this.$store.commit('saveModuleState', { key: 'module2State', value:null })
    	}
    }
}
// 底部tab切换
clearCurModule(){
    // 清除对应模块的缓存状态
    this.$store.commit('saveModuleState', { key: 'module2State', value:null })
}

上面是简单实现状态保存的示例。可以看到存在以下问题

  • 新增一个模块,就需要定义一个状态缓存,同时需要考虑清除和设置缓存的时机
  • 新增一个组件,就需要新增一个flag,且要维护每种判断条件下的逻辑
  • 实现由模块1的A模式直接过渡到模块2的B模式较为困难
  • 无法多次记录状态(即A模式到B模式到C模式,再逐次返回)

维护一个庞大的if-else是每个工程师的噩梦。于是我们参考状态机的实现进行抽象,思路如下:

我们上面提到状态机在同一时间只会处于一种状态,其一般流程为:列举出所有状态及其对应的事件,经过事件处理后流转到什么状态。

实际项目中最终状态(state)一般是简单的可确定的,但处理事件(event)会有多个,可能在不同的层级或入口,可能不同入口的最终结果是一样的。以Vue为例,我们可以借助computed计算属性来解决这个问题

/*
   简化状态机,进行反抽象
   遵循原则:最终状态受控于顶级状态、上级状态、自身状态,优先级依次降低
*/
// 1、抽取所有基础状态
STATE1 = 's1'
STATE2 = 's2'
STATE3 = 's3'
// 2、有顶级状态或上级状态
topState = true
supState = false
// 3、定义每个受控项的flag
compState(){
   return topState && supState=='STATE1' && curState
}

根据实际情况更改某一个控制节点时会自动返回最终状态,无需添加其它处理逻辑。

基于状态管理

此模式下我们需要抽象一层:模块控制器。如下图所示

在这里插入图片描述

流程如下

  • 抽取全局状态,标记为当前活跃模块
  • 提取每个模块的状态枚举
  • 通过computed得到每个子组件的控制flag
  • 每个模块/组件都可发出改变模块/存取状态的指令
// store
state() {
    return {
      	model: 1 // 当前模块
    }
},
mutations: {
    setModel(state, val) {
    	state.model = val;
    }
}
// 列出当前模块下所有状态
let STAGE_INIT = -1; // 不在当前模块
let STAGE_STATE1 = 0; // 状态1(只显示组件1)
let STAGE_STATE2 = 1; // 状态2(只显示组件2)
let STAGE_STATE3 = 2; // 状态3(全部显示)
let STAGE_STATE4 = 3; // 状态4(全部隐藏)
// 模块2示例
data(){
    return {
        state: 0, // 当前模式
		stateHis: [], // 模式记录
    }
},
computed:{
    // 当前模块
    model(){
      return this.$store.state.model
    },
    comp1Class() {
      return this.model == 2 && [STAGE_STATE1,STAGE_STATE3].includes(this.state)  ? '' : 'hide'
    },
    comp2Class() {
      return this.model == 2 && [STAGE_STATE2,STAGE_STATE3].includes(this.state) ? '' : 'hide'
    },
},
mounted(){
    // 监听 模块切换 事件
    this.$bus.$on('modelChanged', this.modelChanged)
    // 监听 缓存当前状态 事件
    this.$bus.$on("storeState", () => this.storeState());
    // 监听 返回上次状态 事件
    this.$bus.$on("restoreState", () => this.restoreState())
},
methods:{
    // 记录一次状态
    storeState() {
      this.stateHis.push([this.state]);
    },
    // 返回上次状态
    restoreState() {
      if (this.stateHis.length > 0) {
        this.state = this.stateHis.pop();
      }
    },
    // 模块变化(这里可以携带其他参数,使其在初始化时处于任何状态)
    modelChanged(n){
      if(n == 2){
        this.state = STAGE_STATE1;
      }else{
        this.state = STAGE_INIT;
      }
    },
    // 由子组件触发的修改模块、返回上次状态的指令
    changeModule(){
      // 还原上次状态
      this.$bus.$emit("restoreState");
      // 切换模块
      this.$store.commit('changeModel', 1)
    },
    // 控制组件状态-只需要改变对应的state即可,无需维护其它逻辑
    changeState(n){
      this.state = n;
    },
}

可以看到,computed控制是层层递进的,先由模块变量决定当前模块的组件是否进入显隐判断,其次由各控制器决定各组件的状态。而切换组件状态只需要修改state,无需设置每一个flag。针对新增的组件,也只要在computed中添加实现即可。

此外,当我们把公共组件抽取到 mainModel 后,就可以实现各级模块及其子组件的联动,这一点也是非常重要的。就像下面的场景:有一个全局组件A,它可以在任意时候出现,且出现时,该位置上原有的组件(假设为B)要隐藏,当组件A隐藏时,组件B才能显示出来(示意效果如下)。

在这里插入图片描述

可以思考一下,如果按一般设计,局部/全局组件和各个模块的组件之间不在同一层级,这就导致双方很难联动,此时要保留各模块的子组件的状态用于还原,实现上就会变得复杂。

// 示例
data(){
    return 
        flag2: false
	}
},
computed:{
    compAState(){
      return this.$store.state.compAState
    },
},
watch:{
    compAState(show){
        if(show){
            // 先存现有状态
            this.setCurModule();
            // 手动置为隐藏
            this.flag2 = false;
        }else{
            // 还原上次状态
            this.resetLastModule();
		}
    }
}

而如果采用状态模式来控制的话就会简化很多,我们只需要再添加一种state,标记为组件A出现状态,此时只需要在需要受控的子组件B中添加条件即可

computed:{
    compAState(){
      return this.$store.state.compAState
    },
    showBComp(){
   		return !this.compAState && shouldShowBComp
	}
}

这完全是一种自动处理过程,不需要添加额外的Function控制。

状态模式的核心是所有模块的控制器部分不会随着模块切换而销毁/重载,这便于进行“跨级控制”和状态分发。当然也不能简单的认为只是把手动控制多个变量改写成了计算属性,而要意识到这其实是抽象状态进行分发控制。

感觉差异不大?那再加一个全局组件C,优先级更高,同样实现上述效果:当它出现的时候,组件A和B都要隐藏,当其隐藏时,A和B要恢复上次的状态。对于一般设计,这个复杂度显然又f翻了一倍,不仅要记录A、B上次的状态,还要在A、B中监听C的变化去做相应的还原操作等。而在状态模式中,只需要定义一个C状态,同时在受控组件的状态判断中添加条件即可,改动极小且不会有遗漏。

小结

在现代前端框架中其实更容易理解所谓的“状态模式”。如computed有惰性,返回的结果实际上就是一个个的“最终状态”,也就是说,无论有多少个前置条件和判断,最终结果是有限的定值。这时候我们可以把这个过程“反过来”,最终只需要维护一个个状态(中的条件),而触发各个状态改变的方式可以“收敛”到一个方法,甚至一个变量。

当然上面提到的Redux、Vuex等并不能完全看作是完整的“状态机”,应该说Web应用是一个状态机,而它们只是一个具体实现,将状态流转过程做了控制。

后记

无论业务功能在设计之初有多简单,在代码框架上都要把它当成一个复杂的系统进行设计,当然这很考验对业务的理解程度和代码框架水平。否则当系统发展到一定阶段时,必然会遇到一些问题。

实际开发中尽量结合设计模式的几个原则(单一原则、开放封闭原则、接口隔离原则等)进行开发设计。如针对程序中变动频繁或逻辑复杂的部分进行抽象,然后派生实现类进行扩展即可。当功能发生变化时,只需要根据需求改动对应的实现类。

参考

Flux
Redux

Vuex

降低前端业务复杂度新视角

理解Flux架构

Redux 和 Vuex 的异同

常用的一些状态机函数库

javascript-state-machine

XSate

flexstate

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2203116.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

‌美国加勒比烈火波本混合威士忌新品发布会盛大举行‌

近日&#xff0c;美国知名威士忌品牌GARBBEN BLAZE加勒比烈火在北京举行了一场别开生面的新品发布会&#xff0c;正式推出了其最新力作——加勒比烈火波本混合威士忌。此次发布会吸引了众多威士忌爱好者、行业专家代表前来见证这一重要时刻。 加勒比烈火波本威士忌作为GARBBEN …

SSH 公钥认证:从gitlab clone项目repo到本地

这篇文章的分割线以下文字内容由 ChatGPT 生成&#xff08;我稍微做了一些文字上的调整和截图的补充&#xff09;&#xff0c;我review并实践后觉得内容没有什么问题&#xff0c;由此和大家分享。 假如你想通过 git clone git10.12.5.19:your_project.git 命令将 git 服务器上…

【Golang】Go语言Seeker接口与文件断点续传实战

✨✨ 欢迎大家来到景天科技苑✨✨ &#x1f388;&#x1f388; 养成好习惯&#xff0c;先赞后看哦~&#x1f388;&#x1f388; &#x1f3c6; 作者简介&#xff1a;景天科技苑 &#x1f3c6;《头衔》&#xff1a;大厂架构师&#xff0c;华为云开发者社区专家博主&#xff0c;…

kubelet PLEG 实现

概述 kubelet的主要作用是确保pod状态和podspec保持一致&#xff0c;这里的pod状态包括pod中的container状态&#xff0c;个数等。 为了达到这个目的&#xff0c;kubelet需要从多个来源watch pod spec的变化&#xff0c;并周期从container runtime获取最新的container状态。比如…

【Linux:线程锁】

目录 锁的接口&#xff1a; ​编辑 关于锁的几个问题&#xff1a; 从底层实现理解锁&#xff1a; 锁的接口&#xff1a; pthread_mutex_lock函数申请锁成功后&#xff0c;会返回&#xff0c;申请失败&#xff0c;函数不返回&#xff0c;继续申请&#xff0c;线程阻塞pthrea…

python33_数字格式化

数字格式化 数字格式输出说明3.1415926{:.2f}3.14保留小数点后两位3.1415926{:.2f}3.14带符号保留小数点后2位3.1415926{:.0f}3不带小数&#xff0c;但是会四舍五入5{:0>2d}05数字补0&#xff0c;填充左边&#xff0c;宽度为25{:x<4d}5xxx数字补x&#xff0c;填充右边&a…

IPv6 DAD和地址解析实验

实验目的 1、 观察并分析IPv6 DAD过程 2、 观察并分析IPv6地址解析过程 实验拓扑 实验步骤 1、观察并分析IPv6 DAD过程 在AR1的G0/0/0开启抓包。AR1的G0/0/0接口配置ipv6地址2012::1/64。在接口配置ipv6地址后&#xff0c;ipv6有一段称为Tentative生命期&#xff0c;在该阶…

Sql Server 生成脚本中的快速删除空行问题

使用 Sql Server 生成脚本&#xff0c;例如生成存储过程&#xff0c;常常会因为存在大量没用的空行。如下图5w行的脚本&#xff0c;空行很多无用&#xff0c;需要删除。 手动删除很麻烦&#xff0c;想通过快捷方法删除这些空行&#xff0c;方法其实很简单&#xff01; 1、使用…

Vue集成echarts实现统计图表

目录 一、概述 二、Vue实现echarts图表模版 三、测试运行项目 一、概述 官网地址&#xff1a;https://echarts.apache.org/examples/zh/index.html 目前的官网的echarts例子比较古老&#xff0c;如果集成Vue里面需要进行修改&#xff0c;所以可以新建一个Vue的项目代码&am…

不同类型的 LLM 有什么区别

LLM 有不同类型的模型&#xff0c;例如 Base 和 Instruct&#xff0c;他们有什么区别呢&#xff0c;Base 模型是基于原始语料进行训练的模型&#xff0c;生成结果时会持续生成&#xff0c;直到达到最大长度。而指令模型是经过问答数据集进行训练的&#xff0c;回答是会根据上下…

【AI大模型】关于ToB垂直领域大模型的一点探索和尝试

本文分享了物流技术团队在垂直领域大模型开发和部署过程中的技术细节、挑战解决策略以及实际应用案例。 ‍ 背景 大家好&#xff0c;我们是淘天物流技术团队&#xff0c;在过去一年多的实践工作中&#xff0c;我们团队围绕“物流体验”这一垂直领域&#xff0c;尝试通过垂直…

大坝渗流监测设备——渗压计

渗压计是一种用于监测大坝等水工建筑物渗流压力的重要设备&#xff0c;其准确性和可靠性对于保障大坝安全运行至关重要。南京峟思将为大家详细介绍渗压计的工作原理、安装方法及其在大坝渗流监测中的应用。 渗压计主要利用振弦频率的变化来测量渗透水压力。设备由透水部件、感应…

使用UDP网络型灯光协议控制器控制DMX512步进电机

目录 1、硬件介绍 &#xff08;1&#xff09;灯光控制器 &#xff08;2&#xff09;步进电机 &#xff08;3&#xff09;接线图 &#xff08;4&#xff09;多路步进电机DMX接线 2、调试软件测试 &#xff08;1&#xff09;软件界面 &#xff08;2&#xff09;控制器ip地…

今夜无眠!Model 2来猎杀国产新能源了

文 | AUTO芯球 作者 | 雷慢 10月11日国内新能源车企注定会有一个不眠夜&#xff0c; 为什么啊&#xff0c;马斯克已经放出狠话了&#xff0c; 10月10日&#xff08;美东时间&#xff09;的发布会“将载入史册”&#xff0c; 别不信啊&#xff0c;据说这次会发布三款车&…

AI测试入门:向量数据库 知识图谱的适用场景

AI测试入门:向量数据库 & 知识图谱的适用场景 1. 向量数据库概述2. 知识图谱概述3. 适用场景分析3.1 使用向量数据库的场景3.2 使用知识图谱的场景4. 案例分析4.1 搜索引擎中的应用4.2 医疗健康领域的应用4.3 社交媒体平台中的应用4.4 供应链管理中的应用总结1. 向量数据库…

其他浏览器可以联网,但edge不能联网

问题描述&#xff1a; 今早edge无法上网&#xff0c;检测网络连接正常&#xff0c;而且其他chrome&#xff0c;Firefox和360浏览器都可以上网。 解决方案&#xff1a; 注意&#xff1a;为防止是代理问题&#xff0c;可以在扩展中禁用后再试试 如果没有代理或者禁用代理也不…

AIOps探索 | AI与人类协作:未来IT运维应急响应的新模式

内容来源于--布博士&#xff08;擎创科技产品解决方案专家&#xff09; 在科技日新月异的今天&#xff0c;人工智能&#xff08;AI&#xff09;如同一场汹涌澎湃的浪潮&#xff0c;席卷着各个行业领域&#xff0c;IT 运维应急响应也正站在这一变革的风口浪尖。随着信息技术架构…

为什么越来越多的男性开始光顾美容机构?美业管理系统/个案疗愈系统源码

近年来&#xff0c;我们可以清晰地观察到一个明显的趋势&#xff1a;越来越多的男性开始光顾医美美容机构。这种现象的出现并非偶然&#xff0c;而是由多种因素共同作用的结果。 本文将结合社会观念的转变、媒体和广告的影响、职业发展压力、技术进步以及自我关注和健康意识的…

基于非线性干扰观测器的六自由度机械臂控制

1.观测器公式推导 当存在扰动时&#xff0c;机械臂的动力学方程如下&#xff1a; 对上式进行恒等变化得&#xff1a; 设计干扰观测器&#xff1a; 令&#xff0c;则&#xff1a; 观测误差满足 &#xff0c;取&#xff0c;因加速度不可测&#xff0c;定义辅助向量&#xff1a; …

爬虫工具——Fidder的安装

一、Fidder的安装 出现这样的界面就是安装成功了 二、Fiddler的配置 1.端监听手机端设置 但是如果你想要对手机上的app进行抓包怎么办呢&#xff0c;那么你还需要进行以下操作&#xff1a; 首先你的Fiddler所在的电脑和手机必须处在同一个局域网内(即连着同一个路由器)。 …