概述
有限状态机,又称有限状态自动机(FSA,finite-state automation),是表示有限个状态在这些状态之间的转移和动作等行为的数学计算模型,有限状态机是"自动机理论"中研究的一类自动机。
自动机一词来自希腊语 αὐτόματος,意思是"自我行动、任性"。自动机是一种抽象的自行计算设备,它自动遵循预定的操作顺序。状态有限的自动机称为有限自动机(FA),或有限状态机(FSM)。自动机系列可以用层次形式来解释,其中,有限状态机是最简单的自动机,图灵机是最复杂的。
下图是一个经典的有限状态机,该状态机包括状态(用圆形表示)和转换(用箭头表示)。当这个状态机接收到一个输入符号(0或1)时,它会根据转换函数跳转到下一个状态。
自动机理论中的每个模型都在计算机应用领域发挥着重要的作用,其中,有限状态机在编译器设计、字符串匹配、通信协议设计、硬件设计、用户界面开发等领域有着广泛的应用,本文将探讨有限状态机在前端组件与交互设计上的应用。
形式定义
自动机
一个自动机可以形式化为一个 5-元组(Tuple) M = (E,R,Q,T,N),
-
E 是一个有限的符号集合,称为自动机的输入字母表
-
R 是另一个有限的符号集合,称为自动机的输出字母表
-
Q 是一个状态集合
-
T 是下一个状态函数,Q x E => Q^'^将状态输入映射到后续状态
-
N 是下一个输出函数,Q x E => R 将状态输入映射到输出
如果 Q 为有限的,那么 M 是一个有限自动状态机。
什么是"状态"
广义上的"状态"代表一组参数决定的一个具体实体,在编程语言中,程序执行的每一时刻都有且仅有一个"状态",该状态可以由内存中的一组参数值唯一表征,因此,只要两种编程语言能够实现从同一种"状态"转移到另一种"状态",我们就可以理解为这两种编程语言实现了相同的功能,例如 Java 与 C++ 都可以实现求和函数,本质上,编程语言都在充当着"状态机"的角色,而状态则为内存中的一组相同的数据。
在前端中,"状态"通常指页面所展示的样式与属性值,前端中的"状态转换"不仅有页面属性值的变化,还有样式的转变与组件的更替,相比于广义的"状态"更加具有"可见性"。
有限状态机与 Redux 的区别与联系
在 React 全家桶中已经存在一个"状态管理库"------ Redux,下图是Redux官网最经典的例子,Redux 中通过定义多个 Reducer 实现 State 的转换逻辑,通过调用 dispatch 来触发 reducer,通过 Store 管理状态。然而,Redux 并不是"有限状态机",它们之间依旧存在着一些区别与联系。
区别:
回顾上一节中的"有限状态机"的定义,一个有限状态机必须具有"有限"的状态,确定的转换函数,在 Redux 中,状态的变化往往由复杂的应用逻辑触发,并且可能包含异步函数,同时,Redux 每次都会产生一个"新的状态",而不是从已有的状态中转化与转移。
联系:
状态管理:Redux 与 有限状态机都涉及了状态的变化与管理。
状态迁移:它们两者的状态都不是随机变化的,都需要遵循一定的规则或逻辑。
可预测性:两者都强调状态转换的可预测性。在有限自动机中,给定当前的状态和输入,下一个状态是确定的。在 Redux 中,给定当前状态和 action,下一个状态是 reducer 函数确定的。
状态模式
在上一部分内容我们分析了"有限状态机"与"Redux"的区别与联系,实际上,像 Redux 这种涉及到状态管理的"类有限状态机"设计已经存在一个具体的设计模式------状态模式。
状态模式通过将多个相关联的步骤抽象成状态,通过定义状态之间的转化函数,就可以将复杂的流程控制语句简化,例如我们在开发一个电梯控制软件,电梯的状态与转换动作之间的联系如下表所示。
开门 | 关门 | 运行 | 停止 | |
---|---|---|---|---|
开门状态 | ❌ | ✅ | ❌ | ❌ |
闭门状态 | ✅ | ❌ | ✅ | ✅ |
运行状态 | ❌ | ❌ | ❌ | ✅ |
停止状态 | ✅ | ❌ | ✅ | ❌ |
如果我们采用普通的设计方式,当用户点击"开门"指令,我们可能写出以下代码
ini
function open(){
if(state === 'opening') return false;
if(state === 'closing') return true;
if(state === 'running') return false;
if(state === 'stoping') return true;
}
如果我们使用了状态模式,将状态抽象,并规定了四个转换函数 open、close、run、
stop
dart
// 状态
const state = '';
// 规定状态转移函数
const ACTION = {
open: () => {state = 'opening' },
close: () => { state = 'closing' },
run: () => { state = 'running' },
stop: () => { state = 'stoping' },
}
const OPEN_ACTION = {
...ACTION,
open: () => {},
run: () => {},
stop: () => {}
}
const CLOSE_ACTION = {
...ACTION,
close: () => {}
}
const RUN_ACTION = {
...ACTION,
open: () => {},
close: () => {},
run: () => {},
}
const STOP_ACTION = {
...ACTION,
stop: () => {},
close: () => {},
}
// 根据不同的状态返回不同的状态转移函数
const getAction = () {
switch(state){
case 'opening':
return OPEN_ACTION;
case 'closing':
return CLOSE_ACTION;
case 'running':
return RUN_ACTION:
case 'stoping':
return STOP_ACTION;
default:
return ACTION;
}
}
上述示例中,我们在进行状态转移时可以通过getAction
函数获取到当前状态对应的状态转移函数
scss
function open() {
const action = getAction();
action.open();
}
这样,我们无需进行大段重复的 if/else 条件控制,通过状态与状态转移函数可以实现状态的自动迁移与转换。
前端组件设计中的"有限状态机"
本节来源于业务开发中一个"特殊"的需求,需要使用到 antd 的 Steps 组件,需要在点击"下一步"时进入到"下一个状态",同时步骤条需要有相同的变化与展示,但是该"特殊"需求要求在我们在点击步骤条的"下一步"时不一定会进入到 Steps 组件的下一个状态,有可能依旧在当前 Step 状态下,但是对于环境列表进行依次遍历,示例图如下:
用简易的话表述就是,步骤条中可能包含一些额外的状态转换逻辑,并不是从第一个 step 简单地转换到第二个 step。
该需求有很多种实现形式,例如通过在步骤 2 中判断当前是否是第一个环境或最后一个环境决定是否继续按照步骤条执行,或者是自定义一个新的步骤条组件,允许传入额外的步骤作为"隐形的步骤",在点击"下一步"时判断"隐形的步骤"是否执行完毕。同样,我们也可以采用"有限状态机"的方式来实现。
定义新的状态
既然我们无法直接采用 Steps 组件作为状态中的一步,我们可以将 Steps 组件本身也抽象成状态中的一个步骤,通过状态中的参数来决定当前 Steps 组件本身处于第几步,而不去利用 Steps 组件来管理状态。
通过分析上述需求,我们可以看到,除了步骤条之外,步骤条下方的内容也是变化的,不仅组件会发生变化,组件的属性值也会发生变化,所以我们同样可以将步骤条中的"组件"与"组件的属性"作为状态的一部分,随着状态变化;同时,不要忘了定义状态之间的转移函数。最后,状态节点的类型设计如下:
css
/**
* 步骤节点
*/
export interface IStepsNode{
/**
* 步骤栏属性
*/
stepProps: any;
/**
* 步骤内容组件
*/
Component: React.ComponentType;
/**
* 步骤内容组件属性
*/
ComponentProps: { [key: string]: any };
/**
* 下一步 回调函数
*/
onStepFinish: () => void;
/**
* 上一步 回调函数
*/
onStepBack: () => void;
}
定义完状态的类型,我们就可以开始实现这个状态组件了,状态组件传入的值为一个状态数组 status,通过"上一步"与"下一步"实现状态 status 之间的切换,并根据状态 currentStatus 进行 Steps 组件与下方内容组件的渲染。
ini
/**
* 发布步骤组件
*/
// ... 省略导入
export interface PublishStepsPropsType {
status: IStepsNode[];
finishButtonText?: string;
onFinish: () => void;
}
const PublishSteps: React.FC<PublishStepsPropsType> = ({ status, onFinish, finishButtonText }) => {
// 当前步骤
const [currentStep, setCurrentStep] = React.useState(0);
// 是否为第一步
const isFirstStep = React.useMemo(() => currentStep === 0, [currentStep]);
// 是否为最后一步
const isLastStep = React.useMemo(() => currentStep === status.length - 1, [currentStep, status.length]);
// 获取当前状态
const currentStatus = React.useMemo(() => status[currentStep], [currentStep, status]);
// 初始化状态
const initStatus = React.useCallback(() => {
setCurrentStep(0);
}, []);
// 上一步
const handlePreStep = React.useCallback(() => {
currentStatus.onStepBack();
setCurrentStep(step => step - 1);
}, [currentStatus]);
// 下一步
const handleNextStep = React.useCallback(() => {
currentStatus.onStepFinish();
setCurrentStep(step => step + 1);
}, [currentStatus]);
// 完成所有步骤
const handleFinish = React.useCallback(() => {
if (isLastStep && currentStatus.onStepFinish) {
currentStatus.onStepFinish();
}
// 完成步骤
onFinish();
// 重置状态
initStatus();
}, [currentStatus, initStatus, isLastStep, onFinish]);
return ( <Row gutter={[0, 12]}> <Steps {...currentStatus.stepProps} /> <currentStatus.Component {...currentStatus.ComponentProps} /> { !isFirstStep && 上一步} { !isLastStep && 下一步} {finishButtonText} ); };
ini
PublishSteps.defaultProps = {
finishButtonText: '确认发布',
};
export default PublishSteps;
在使用时,我们只需要按照我们的状态类型,定义每个步骤需要的状态与 Steps 步骤条的属性,无需关心状态之间是如何转换,组件会帮我们处理。在下方使用过程中,我们同时使用了"工厂模式"来重复创建状态。
typescript
/**
* 构造生产环境发布步骤节点
*/
// ... 省略导入
interface IterationDevPublishStepsPropsType {
onFinish: () => void;
}
// 步骤条定义
const stepItems = [{
title: '发布环境确认',
}, {
title: '发布内容确认',
}];
// 创建步骤节点 工厂方法
function createStepNode(item: Partial<IStepsNode>, index: number): IStepsNode {
const baseStepProps = {
type: 'navigation',
current: index,
items: stepItems,
};
const baseStepNode: IStepsNode = {
stepProps: baseStepProps,
Component: () => <></>,
ComponentProps: {},
onStepFinish() {},
onStepBack() {},
};
return {
...baseStepNode,
...item,
stepProps: {
...baseStepProps,
...item.stepProps,
},
};
}
// 状态数组,定义每一步自定义的状态值
const status: IStepsNode[] = [{
stepProps: {
current: 0,
},
Component: ConfirmPublishEnv,
ComponentProps: {},
},
{
stepProps: {
current: 1,
},
Component: ConfirmPublishContent,
ComponentProps: {},
},
{
stepProps: {
current: 1,
},
Component: ConfirmPublishContent,
ComponentProps: {},
}].map((item, index) => createStepNode(item, index));
const IterationDevPublishSteps: React.FC<IterationDevPublishStepsPropsType> = ({ onFinish }) => (
<PublishSteps
status={status}
onFinish={() => onFinish()}
/>
);
export default IterationDevPublishSteps;
结语
初次接触有限自动机是在《编译原理》这门课程上,在"词法分析"中使用有限状态机来分析一句"代码"的含义,那时才理解了为何不同的编程语言通过不同编译器的转换,可以实现相同的功能;后来在学习南京大学操作系统课程时,老师讲解了状态机在操作系统内核实现中的应用,对于操作系统课程最深刻的一句话就是"整个世界都可以是一个状态机";在周末编写组件时,在面对这个较为"特殊的"需求时,我又想到了通过"有限状态机"来解决这个问题。于我而言,状态模式(有限状态机)与其他多种设计模式一样,都是程序设计中古老又璀璨的艺术品,无论你什么时候去重温都会有新的收获。
作者:ES2049 / 宝可梦
文章可随意转载,但请保留此 原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。