浅谈对比 XState、Redux 使用

6,869 阅读8分钟

这篇文章主要调研一下 xstate 和 redux 的使用对比,介绍一下 xstate 的优势所在。

前言

redux 的基本原理是将单一状态树作为应用的数据容器(Provider),组件可以发布 Action 修改数据树,数据树的更新也会对应通知到订阅属性的组件。从 API 上来看,即使用 React.createContext、React.useContext 等。

redux 对整个应用的数据、状态描述合并为一个状态树,通过 action 和 reducer 去下发变更,比如:

const appStateTree = {
    state: { ... },
    context: { ... }
};


xstate 的基本原理是设计应用的状态图,通过触发 transition 从应用的某个状态过渡到下个状态。xstate 将应用划分为状态和上下文,一般比如:

const appMachine = {
    initial: 'idle',
    context: { ... },
    states: {
        idle: {},
        loading: {},
        loaded: {}
    }
};

这里指的 state 和 context 从直观意义上理解,state 代表应用的状态(如 UI 状态),而 context 一般代表应用的所有上下文(如应用从后台获取的数据)。

可以看到很明显,在 API 层次上,xstate 是提供了划分的;而在 redux 中,这种划分并不明显,得看使用者自己的定义,state 和 context 完全可以混杂在一起。

那为什么要划分 state 和 context?理论上两个属于不同类型的数据,state 应该是驱动应用进行状态变化的,而 context 是提供给应用进行数据交流和应用的。

(当然,虽说 context 也是驱动应用变化,但主要不是体现在一个交互上,更多的是数据内容上)

xstate 基本介绍

简单来说,比如开发一个列表组件,我们需要从后端获取数据,然后显示数据。那么这个列表很明显会有以下三个状态:初始状态 idle、加载中状态 loading、加载完毕状态 loaded。

如状态图所示:



但是除此之外列表组件可能会有主题色、用来请求数据的 id、还有列表本身需要显示的数据内容。这些理论上属于组件的上下文数据,用 context 管理。

那么在 xstate 中该列表的状态机基本定义如下(可以将下述代码拷贝到 xstate.js.org/viz/ 查看状态图):

const appMachine = Machine({
  // 应用初始状态
  initial: 'idle',
  // 应用的上下文
  context: {
    id: null,
    theme: 'light',
    list: [],
    timestamp: new Date().getTime(),
    error: null
  },
  // 应用的所有状态 idle, loading, loaded, failure
  states: {
    idle: {
      on: {
        // 监听 LOAD 事件,从 idle 状态过渡到 loading
        LOAD: {
          target: 'loading',
          // 触发此事件时修改上下文 context
          actions: assign({
            id: (_ctx, evt) => evt.id
          })
        }
      }
    },
    loading: {
      on: {
        // 监听 SUCCESS 事件,从 loading 状态过渡到 loaded
        SUCCESS: {
          target: 'loaded',
          // 触发此事件时修改上下文 context
          actions: assign({
            list: (_ctx, evt) => evt.listData
          })
        },
        // 监听 FAIL 事件,从 loading 状态过渡到 failure
        FAIL: {
          target: 'failure',
          // 触发此事件时修改上下文 context
          actions: assign({
            list: () => [],
            error: (_ctx, evt) => evt.error
          })
        },
        // 监听 PENDING 事件,重复触发 loading 状态
        PENDING: {
          target: 'loading',
          // 触发此事件时修改上下文 context
          actions: assign({
            timestamp: () => new Date().getTime()
          })
        }
      }
    },
    loaded: {
      // 应用最终状态
      type: 'final',
    },
    failure: {
      // 应用最终状态
      type: 'final',
    }
  }
});

状态过渡(states)

应用的状态机可以通过将状态机编译为一个服务,然后发送事件:

import { interpret } from '@xstate/fsm';
import { appMachine } from './App';
import { getId } from './utils';
// 将纯函数的状态机编译为服务,用户表达副作用
const appService = interpret(appMachine).start();
// 订阅服务
appService.subscribe(currentState => {
  if(currentState.changed) {
    // 监听状态变化,干点什么都行
    console.log(currentState);
  }
});
// 通过发送事件,修改应用状态
// LOAD 发送前,服务处于 idle 状态。发送后,就会使得应用的状态从 idle 转换为 loading 状态。
appService.send({ type: 'LOAD', id: getId() });
// 因为已经处于 loading 状态。loading 状态没有注册 LOAD 的事件,因此什么事情也不干
appService.send({ type: 'LOAD', id: getId() });


上述代码, LOAD 发送前,应用处于 idle 状态,发送后,就会使得应用的状态从 idle 转换为 loading 状态。

因为状态图已经确定,所以此时无论发送 LOAD 事件多少次,应用状态都不会变更,除非发送 loading 注册好的事件,如 SUCCESS,FAIL,PENDING。

所以也就是为什么叫”状态过渡“,而不是”状态修改“,就是因为应用只能按照状态图的约定进行状态直接的转换,相当于可以理解为只能按照地图软件搜索出来的路径来走。你不可能用任意门进行空间跳跃(而 redux 是可以的)。

上下文修改(context)

上下文是可以随意修改的。比如下述 SUCCESS 事件,成功进入 loaded 状态后,会触发 actions,通过 assign 修改上下文(assign 是由 @xstate/fsm 提供)

SUCCESS: {
  target: 'loaded',
  // 触发此事件时修改上下文 context
  actions: assign({
    list: (_ctx, evt) => evt.listData
  })
}

更新这个 list 数据后,对应依赖 list 的组件监听到,即可用 list 数据进行更新。

组件通信

redux 的跨组件通信大家都很熟。那么 xstate 的这方面的能力呢?本质上是一样的,都是通过 React.createContext 进行实现。
但是 xstate 本身的 Machine 定义有 context,我们不可能重复编写两套 context。我们可以通过封装一个 hook 进行实现(useAppContext、useMachine 代码最下文可见)

// 声明初始上下文
const AppContext = React.createContext({
  context: {},
  setContext: () => { }
});
function App() {
  const { context, setCurrentContext } = useAppContext(appMachine.config.context);
  const [state, send, service] = useMachine(appMachine);
  useEffect(() => {
    service.subscribe(currentState => {
      if (currentState.changed) {
        setCurrentContext(currentState.context);
      }
    });
  }, [send, service, setCurrentContext]);
  return (
    <AppContext.Provider value={context}>
       <App />
    </AppContext.Provider>
  );
}

此时已经提供了 Provider 能力,对应组件可以通过 useContext 进行消费:

function List(props) {
  const { theme } = props;
  // 使用上下文
  const { list } = useContext(AppContext);
  return (
    <div className={classNames('list', {
      'theme-night': theme === 'night',
      'theme-light': theme === 'light'
    })} >
      {
        list.map(item => <Item key={item.id} {...item} />)
      }
    </div>
  );
}

通过 useContext,使得任意组件拥有了跨组件通信的能力。

组件如果想要修改 context,一般可以通过状态节点的 actions 进行修改比较合理。如果想直接修改 context,也可以通过 setCurrentContext 函数透传进行修改,但不建议。

对比

两者本质上其实并没有冲突。xstate 和 redux 主要是概念上的区别。

一般来说,不用 xstate,直接用 enum + switch + useContext 语句一样也可以达成状态机的思想。
当然不用 redux,使用 useContext + useReducer 一样也可以解决问题。

两者同样作为状态管理库,具体差异对比如下:

概念

xstate xstate.js.org/docs/about/…
redux redux.js.org/introductio…
大家可以通过阅读上述文档,进行概念理解。

压缩体积

xstate 核心包 @xstate/fsm + react 下的 useMachine 实现,约 3~5 kb
redux 基本约为 7 kb。

维护成本

一般来说,如果在 B 端的业务,无需应用体积的高度优化,可以全量引入 xstate 和 @xstate/react 两个包进行开发,大概体积约为 40 ~ 50kb。

如果是 C 端业务,可以引入 @xstate/fsm 核心包进行开发,再加上上文提供的 useMachine、useAppContext 实现,估计体积约 3~5kb。

不过对比 redux 来说,xstate 的使用显得比较陌生,需要一段时间学习。不过从维护上来说,它具备较多的优势:

  1. 相对扩展性良好,如果设计得当,只需要修改 Machine 扩充状态节点即可。
  2. 迁移性良好,状态机相当于是和应用一定程度解耦的,因此状态机可以切换应用在不同组件里
  3. 状态机描述应用,可以更大程度上约束应用,使得应用是可预测、可观测的。
  4. 可以运用路径算法进行自动化测试(****@****xstate/test)

但是它也有很明显的劣势

  1. 国内还是缺乏教程、以及最佳实践,概念陌生,学习曲线相对陡峭(不过有很完善的官方文档)
  2. 同时状态和上下文需要分开关注,对于应用开发来说,需要更多的时间精力成本(体现在应用设计上)
  3. 状态不能无限扩充,否则复杂度将会极高,所以需要适当进行拆分。

逻辑可视化

xstate 的 machine 声明可以在 xstate.js.org/viz/ 上可视化查看。
redux 可以在控制台看到状态树、Action 的触发。

xstate 通过有限状态机描述应用,显得更”俯瞰“式一点,而 redux 比较偏于”面包屑“路径式一点。但是 xstate 也可以通过 subscribe,对状态服务的订阅,完成路径追踪,而 redux 比较难以对状态之间的转换进行强约束。

代码实现

useMachine 源码

import { useState, useEffect, useRef } from 'react';
import { interpret } from '@xstate/fsm';
function useConstant(fn = () => { }) {
  const ref = useRef();
  if (!ref.current) {
    ref.current = { v: fn() };
  }
  return ref.current.v;
}
export default function useMachine(machine) {
  const service = useConstant(() => interpret(machine).start());
  const [state, setState] = useState(service.state);
  // historyMatches 属于对 service 能力的扩展,并不一定需要此功能
  service.historyMatches = states => {
    if (!service.historyStates) {
      return false;
    }
    const targetValues = states.split('/').reverse();
    const values = service.historyStates.map(state => state.value).reverse();
    for (let i = 0; i < targetValues.length; i++) {
      if (values[i] !== targetValues[i]) {
        return false;
      }
    }
    return true;
  };
  useEffect(() => {
    service.historyStates = [state];
    service.subscribe(currentState => {
      if (currentState.changed) {
        service.historyStates.push(currentState);
        setState(currentState);
      }
    });
    setState(service.state);
    return () => {
      service.historyStates = [];
      service.stop();
    };
  },
    // eslint-disable-next-line
    []
  );
  return [state, service.send, service];
}

useAppContext 源码

import { useState, useCallback } from 'react';
export default function useAppContext(targetContext) {
  const [context, setContext] = useState(targetContext);
  const setCurrentContext = useCallback(newContext => {
    setContext({
      ...context,
      ...newContext
    });
  }, [context])
  return { context, setCurrentContext };
}

App 示例

点击 github.com/sulirc/xsta…,查看示例源代码,看实际效果。

总结

目前来说,并没有最佳实践,个人感觉 xstate 虽然有不少优势,但是能不能在项目中运用,还需要摸索一段时间。

整体来说,redux 能干的事,xstate 也能干(不过需要手动扩展一下)。xstate 能干的事,redux 却不行。