从实际例子看状态机(XState)在前端的落地体验

5,134 阅读7分钟

最近看到一篇状态机的文章,出于好奇,抱着多了解一些相关知识的心态多找了几篇文章,对于高大上的概念我其实是不太感冒的,作为技术人,我还是比较看重实际代码,结果找了几篇文章,都在说概念,提到代码,要么只有大概的伪代码要么就是没头没尾的片段,最好的就是给个跟实际场景差得有点远的 demo,这我就有点纳闷了,既然这东西那么好,你倒是 show me the code

没有完整的代码,光看概念,我实在是难以评判这个东西的落地体验到底如何,所以就自己花费了一些时间看了官方文档,然后找了个实际场景的小例子,分别写了两版代码,一版是不用状态机,一版是用状态机,有了代码,才好进行一线技术人的评判

下面是两版实现的效果,左边是正常版本,右边是 XState版本

两版完整可执行代码已经上传到 github 了,有兴趣的可以看下

最终实现效果如上图,就是一个实际工作场景中可以遇到的表格数据展示,表格有分页功能,可以进行搜索,翻页或搜索的时候,会请求远程接口展示loading状态,接口数据返回渲染到表格里

本文两版实现都基于 vue3.x

正常版本

先看下正常是如何实现这个功能的

一些需要展示在页面上的状态

const data = reactive({
  // 是否处于加载中
  loading: false,
  // 页码
  page: 1,
  // 总数
  total: 0,
  // 接口返回的表格数据
  list: [] as TData['list']
  // 搜索字符串
  searchValue: '',
  // 表格组件的 columns
  columns,
})

最主要的是数据刷新方法

const methods = {
  async freshData() {
    data.loading = true
    try {
      const respData = await getTableData(data.page, data.searchValue)
      data.total = respData.total
      data.list = respData.list
    } catch(e) {
      data.list = []
      Message.error('获取数据出错')
    }
    data.loading = false
  }
}

虽然有一些状态的处理逻辑,但结合写起来问题不大,再配合上组件库,其实是感受到什么复杂度的,当然了,根据业务复杂度的不同,这里代码量可能会有所增减,但不管你业务再怎么复杂,本质上的状态还是那几个

状态机(XState)版本

js版本的状态机管理库我这里选择 XState

首先,划分好状态,关于这个模块存在:空闲、请求数据中、请求数据成功、请求数据失败这四个状态,且这四个状态之间存在相互的转化关系,用JSON伪代码描述为:

{
  "initial": "idle",
  "states": {
    "idle": {
      "on": {
        "LOAD": "pending"
      }
    },
    "pending": {
      "on": {
        "success": "success",
        "failure": "failure",
      }
    },
    "success": {
      "on": {
        "LOAD": "pending"
      }},
    "failure": {
      "on": {
        "LOAD": "pending"
      }
    }
  },
}

开始写代码

首先要创建一个状态机,我们遵从 XState的最佳规范,从一个 Model 来创建状态机

const model = createModel({
  loading: false,
  page: 1,
  total: 0
  list: [],
  searchValue: '',
}, {
  events: {
    LOAD: (params: { page: number, searchValue: string }) => params
  }
})

createModel 的第一个参数就是状态机的 context,有了这个东西我们才能将实际要解决的问题与状态机结合起来,这里的 context 数据和上面一版是一样的,只不过我们在这里将它们放到状态机的 context 里去管理了

第二个参数是用于发送请求数据的 actionaction的执行会导致状态的变化

然后从 model创建状态机,这个状态机有四种状态,初始状态是空闲状态

const machine = model.createMachine({
  context: model.initialContext,
  initial: 'idle',
  states: {
    idle: {},
    pending: {},
    success: {},
    failure: {},
  }
})

如果状态机处于 idle状态,那么将到加载数据的 action的时候,就应当执行这个动作,且会将状态机的状态转变为 pending

const machine = model.createMachine({
  // ...
  states: {
    idle: {
      on: {
        LOAD: {
          target: 'pending',
          actions: model.assign({
            loading: () => true,
            page: (_, event) => event.page,
            searchValue: (_, event) => event.searchValue
          })
        }
      }
    },
    // ...
  }
})

在这一步,我们在 action执行的时候,改变了 context 内的数据,以响应页面状态的变化

pending 是用于获取接口数据的状态,应当是一个 promise 的状态机,根据 XState的最佳实践,这里将其作为一个 Service

const machine = model.createMachine({
  // ...
  states: {
    pending: {
      invoke: {
        id: 'pending-data',
        // getTableData 是请求接口获取数据的一个 promise 方法
        src: context => getTableData(context.page, context.searchValue),
        onDone: {},
        onError: {}
      }
    },
    // ...
  }
})

当转变到 pending状态时,会调用 src 指向的服务,我们这里是一个 promise方法,当这个 promise方法 resolve的时候,会执行 onDone方法,当这个 promise方法 reject的时候,会执行 onError方法

onDone的时候,用 actions将请求回来的数据更新到 context,同时将状态机的状态转为 success

onDone: {
  target: 'success',
  actions: model.assign({
    loading: () => false,
    total: (_, event) => ((event as any).data as TData).total,
    list: (_, event) => ((event as any).data as TData).list
  })
},

onError的时候,同样更新 context,并将状态机的状态转为 failure,同时还可以报个 message 错误

onError: {
  target: 'failure',
  actions: [
    model.assign({
      loading: false,
      list: []
    }),
    () => Message.error('获取数据出错')
  ]
}

在我们这个例子,状态机进入 successfailure后,所能转换的下一个状态以及接受的 action都是相同的

success: {
  on: {
    LOAD: {
      target: 'pending',
      actions: actions: model.assign({
        loading: () => true,
        page: (_, event) => event.page,
        searchValue: (_, event) => event.searchValue
      })
    }
  }
},
failure: {
  on: {
    LOAD: {
      target: 'pending',
      actions: actions: model.assign({
        loading: () => true,
        page: (_, event) => event.page,
        searchValue: (_, event) => event.searchValue
      })
    }
  }
}

对状态机进行可视化如下:

2.jpg

状态机定义好了,下一步就是使用了

import { useMachine } from '@xstate/vue'

export default defineComponent({
  components: {
    Table,
    Search: Input.Search
  },
  setup() {
    const { state, send } = useMachine(machine)
    
    send('LOAD', { page: state.value.context.page, searchValue: state.value.context.searchValue })

    const methods = {
      handleSearch(v: string) {
        send('LOAD', { page: 1, searchValue: v })
      },
      handlePageChange(page: number) {
        send('LOAD', { page: page, searchValue: state.value.context.searchValue })
      }
    }

    return {
      state,
      columns,
      ...methods
    }
  }
})

使用 @xstate/vue 提供的 useMachine方法,可以拿到 stateactionsend,通过 state.context 可以获得在状态机里定义的 context数据,通过 send可以发送 action来进行状态的转变

总结

代码行数

两版实现的代码行数分别为 77行(正常版)、134行(XState版),哪怕是抛开样板代码来看,XState版本的代码行数也比正常版要多(最起码不少)

实现体验

XState 版本更像是正常版本的 声明式 实现,其内敛了状态的变化逻辑,使得代码的组织和状态的变化更加有序,但同时也明显地增加了开发者的心智负担

从技术极致的角度看,我当然推荐你使用状态机来组织状态,但我们大部分人写的代码都是为业务所服务的,从权衡的角度来看,这种编写代码的方式或许不仅不能提升代码可维护性,反而会是一个拖累

首先,状态机的概念很多,我几乎花费了两天的时间才把文档看完,且相关的教程和文章相比于其他炙手可热的技术来说少得可怜(甚至比不上 rxjs),这就导致学习门槛较高,就算你给团队成员学习的时间,也难以保证大家都能学得到位,在没有充分理解的情况下很容易编写不符合理念的代码,代码都没写对,怎么指望能发挥应有的效果?

其次,业务千变万化,这就要求写的代码也要足够灵活,甚至有些时候要写一些反逻辑的 hack代码,if...else 再多再难看,最起码你明确地知道无论再怎么糟糕肯定能继续堆下去,但如果一上来就被状态机的条条框框给限死了,改起来痛苦就不说了,万一改都改不了那才叫大麻烦,以我多年从业经验来看,我实在想不到我所遇到的哪些实际场景用状态机来解决会明显更好

当然,我不是说这个东西不好,存在即合理,这个东西必然是以解决相应问题的目的而出现的,它必然是有可以大展拳脚的场景,但不应该认为这是万能灵药,要用辩证的眼光来看待问题