有限状态机包含五个重要部分:
-
初始状态值 (initial state)
-
有限的一组状态 (states)
-
有限的一组事件 (events)
-
由事件驱动的一组状态转移关系 (transitions)
-
有限的一组最终状态 (final states)
更简洁的总结,就三个部分:
-
状态 State
-
事件 Event
-
转换 Transition
同一时刻,只可能存在一个状态。例如,人有 “睡着” 和 “醒着” 两个状态,同一时刻,要么 “睡着” 要么 “醒着”,不可能存在 “半睡半醒” 的状态。
逻辑学中说,现实生活中描述的事物都可以抽象为命题。命题本质上就是状态机的 State,Event 就是命题的条件,通过命题和条件推导过程。而 Transition 就是命题推导完成的结论。
所以当我们拿到需求的时候,首先要分离出哪些是已知的命题(State),哪些是条件(Event),哪些是结论(Transition)。而我们要通过这些已知命题和条件,推导出结论的过程。
1.2.1. 拿我们经常用到的 Fetch API 来举例子
fetch(url).then().catch()
有限的一组状态:
初始状态:
有限的一组最终状态:
有限的一组事件:
-
Idle
状态只处理FETCH
事件 -
Pending
状态只处理RESOLVE
和REJECT
事件
由事件驱动的一组状态转移关系:
1.3. 状态机 VS 传统编码 示例
下面采用一个小需求来对比一下区别。
1.3.1. 需求描述
根据输入的关键字进行搜索,并将搜索结果显示出来。如下图所示:
1.3.2. 基于传统编码
根据关键字拿到请求结果,再将结果塞回去就行了,代码如下:
function onSearch(keyword) {
fetch(SEARCH_URL + “?keyword=” + keyword).then((data) => {
this.setState({ data });
});
}
看似几行代码就把这个需求搞定了,但其实还有一些其他问题要处理。如果接口响应比较慢,则需要给一个用户预期的交互,如 Loading 效果:
function onSearch(keyword) {
this.setState({
isLoading: true,
});
fetch(SEARCH_URL + “?keyword=” + keyword).then((data) => {
this.setState({ data, isLoading: false });
});
}
还会发生出请求出错的情况:
function onSearch(keyword) {
this.setState({
isLoading: true,
});
fetch(SEARCH_URL + “?keyword=” + keyword)
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
this.setState({
isError: true,
});
});
}
当然,不能忘记把 Loading 关掉:
function onSearch(keyword) {
this.setState({
isLoading: true,
});
fetch(SEARCH_URL + “?keyword=” + keyword)
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
this.setState({
isError: true,
isLoading: false,
});
});
}
我们每次搜索时,还需要把错误清除:
function onSearch(keyword) {
this.setState({
isLoading: true,
isError: false,
});
fetch(SEARCH_URL + “?keyword=” + keyword)
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
this.setState({
isError: true,
isLoading: false,
});
});
}
这就结束了么,是不是我们把所有的 Bug 都考虑进去了?并没有。当用户在等待搜素请求的时候,不应该再去搜索,所以搜索结果返回前,禁止再次发送请求:
function onSearch(keyword) {
if (this.state.isLoading) {
return;
}
this.setState({
isLoading: true,
isError: false,
});
fetch(SEARCH_URL + “?keyword=” + keyword)
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
this.setState({
isError: true,
isLoading: false,
});
});
}
可以看到,应用的复杂度在不断变大,可能你经历的场景比这个小示例还要复杂的多的多。如果因为搜索接口特别慢,用户希望有一个中断搜索的功能,那么新的需求又来了:
function onSearch(keyword) {
if (this.state.isLoading) {
return;
}
this.fetchAbort = new AbortController();
this.setState({
isLoading: true,
isError: false,
});
fetch(SEARCH_URL + “?keyword=” + keyword, {
signal: this.fetchAbort.signal,
})
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
this.setState({
isError: true,
isLoading: false,
});
});
}
function onCancel() {
this.fetchAbort.abort();
}
不能落下对 catch 的特殊处理,因为中断请求会触发 catch:
function onSearch(keyword) {
if (this.state.isLoading) {
return;
}
this.fetchAbort = new AbortController();
this.setState({
isLoading: true,
isError: false,
});
fetch(SEARCH_URL + “?keyword=” + keyword, {
signal: this.fetchAbort.signal,
})
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
if (e.name == “AbortError”) {
this.setState({
isLoading: false,
});
} else {
this.setState({
isError: true,
isLoading: false,
});
}
});
}
function onCancel() {
this.fetchAbort.abort();
}
最后还要处理没有值的情况:
function onSearch(keyword) {
if (this.state.isLoading) {
return;
}
this.fetchAbort = new AbortController();
this.setState({
isLoading: true,
isError: false,
});
fetch(SEARCH_URL + “?keyword=” + keyword, {
signal: this.fetchAbort.signal,
})
.then((data) => {
this.setState({ data, isLoading: false });
})
.catch((e) => {
if (
e &&
e.name == “AbortError”
) {
this.setState({
isLoading: false,
});
} else {
this.setState({
isError: true,
isLoading: false,
});
}
});
}
function onCancel() {
if (
this.fetchAbort.abort &&
typeof this.fetchAbort.abort == “function”
) {
this.fetchAbort.abort();
}
}
仅仅这么简单的一个小需求,从开始几行代码就可以完成,到最终判断各种边界完成的代码,对比一下,如下图所示:
可以看到,这种包含各种 flag
变量和嵌套着各种 if/else
的代码,会越来越难维护,所有的逻辑只存在于你的脑子里。当你写测试的时候必须从头再梳理一遍代码逻辑,才能写出来。
由于业务的高频变化,很多业务开发人员是不写单元测试的,因为成本太高太高,这也导致了交接代码时,别人去理解你的代码是一件很困难的事。写久了,你自己都可能读不懂代码里面的逻辑了。
这样会导致:
-
难以测试
-
难以阅读
-
可能含有隐藏的 Bug
-
难以扩展
-
新功能增加时还会使逻辑进一步混乱
1.3.3. 基于状态机
看一下我们用状态机的做法。记住流程:梳理出有哪些状态,每个状态有哪些事件,经历了这些事件又会转换到什么状态。
下面是用 XState 状态机工具的 JSON 描述:
{
“initial”: “空闲”,
“states”: {
“空闲”: {
“on”: {
“搜索”: “搜索中”
}
},
“搜索中”: {
“on”: {
“搜索成功”: “成功”,
“搜索失败”: “失败”,
“取消”: “空闲”
}
},
“成功”: {
“on”: {
“搜索”: “搜索中”
}
},
“失败”: {
“on”: {
“搜索”: “搜索中”
}
}
}
}
没错,就这几行代码就描述清楚所有的关系了。并且,可以把它可视化出来,如下图所示:
可以看到状态之间表达的非常清晰,结合到 View 中,也不需要再去编写复杂的 flag
及 if/else
了,View 中只需要知道当前是什么状态,已及将事件发送到状态机就可以了,其他什么都不需要做。在新增或者修改需求的情况下,只需要对状态进行新增或者编排就可以了。
而且可视化后,有以下变化:
-
清晰的看到有哪些状态
-
清晰的看到每个状态可以接受哪些事件
-
清晰的看到接受到事件后会转移到什么状态
-
清晰的看到到达某个状态的路径是怎么样的
2. 解决协作的问题
===========
另一个很大的问题是解决协作问题,主要包括:
-
与测试开人员的协作沟通
-
与 PD 人员的协作沟通
-
与其他前端开发人员的协作沟通
-
与用户的协作沟通
这里就需要引用一个可视化的概念了。「可视化,是利用人眼的感知能力对数据进行交互的可视表达以增强认知的技术」 。
所以很大程度上,可视化可以解决一大部分协作问题。当然,必须要确定把什么进行可视化才是有意义的。
要想可视化,状态工具就需要具备可序列化的能力。这也是 Redux 之类的状态管理工具缺乏的,主要有以下几方面问题:
-
不具备可视化的能力
-
状态和数据混在一起
-
所有的状态都是平级的,无法描述状态之间的关系
2.1. 状态图
回到状态机。你单纯用状态机去写代码,需求数量上去了,状态多了,会面临 “状态爆炸” 问题,依然很难维护,且阅读成本巨大。
当然,这个场景其实很早之前就有人考虑到了,1987 年,Harel 就发表论文,解决复杂状态机可视化的问题,在状态机的基础上进一步增强,提出状态图的概念。随后,由微软、IBM、惠普等多家公司,从 2005 到 2015 年花了 10 年时间制定了规范,并推出了 W3C 的 State Chart XML (SCXML) 规范,至此基本稳定,各家编程语言也基于此规范进行了状态图的封装。
看一下,状态机、状态图和手写代码复杂度的对比,如下图所示:
从图中可以看到:
-
传统编码方式,随着状态和逻辑的增加,复杂度是线性增长的。
-
使用状态机,前期复杂度很底,但随着状态的增多,“状态爆炸”现象的出现,复杂度也急剧增长。
-
使用状态图,虽然前期成本略高,但后期的状态和逻辑的增长,基本不太会影响它的复杂度。
前面给状态机画的图,就是状态图。
状态图大概长这样,如下图所示:
最后
基础知识是前端一面必问的,如果你在基础知识这一块翻车了,就算你框架玩的再6,webpack、git、node学习的再好也无济于事,因为对方就不会再给你展示的机会,千万不要因为基础错过了自己心怡的公司。前端的基础知识杂且多,并不是理解就ok了,有些是真的要去记。当然了我们是牛x的前端工程师,每天像背英语单词一样去背知识点就没必要了,只要平时工作中多注意总结,面试前端刷下题目就可以了。