这篇文章主要调研一下 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 的使用显得比较陌生,需要一段时间学习。不过从维护上来说,它具备较多的优势:
- 相对扩展性良好,如果设计得当,只需要修改 Machine 扩充状态节点即可。
- 迁移性良好,状态机相当于是和应用一定程度解耦的,因此状态机可以切换应用在不同组件里
- 状态机描述应用,可以更大程度上约束应用,使得应用是可预测、可观测的。
- 可以运用路径算法进行自动化测试(****@****xstate/test)
但是它也有很明显的劣势
- 国内还是缺乏教程、以及最佳实践,概念陌生,学习曲线相对陡峭(不过有很完善的官方文档)
- 同时状态和上下文需要分开关注,对于应用开发来说,需要更多的时间精力成本(体现在应用设计上)
- 状态不能无限扩充,否则复杂度将会极高,所以需要适当进行拆分。
逻辑可视化
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 却不行。