前言
看到阿里的一位前端工程师的一篇文章,关于前端业务复杂度调整的文章,结合到了我自己项目开发工作中,显得有很多共鸣,想必也能引起在座各位的共鸣......
提出问题: 如何使用状态机范式使得产品-开发-测试-验收整条流水线更加顺畅,且降低业务复杂度?作为前端开发,如何去降低前端业务的复杂度呢?
业务复杂度
随着需求的不断迭代,通常都会出现逻辑复杂、状态混乱的现象,维护和新增功能的成本也变的十分巨大,苦不堪言。下图用需求、业务代码、测试代码做对比:
图中分了 3 个阶段:
- 阶段 1:正常,都是线性增长。
- 阶段 2:需求数正常增长,业务代码行数开始增长,测试代码行数大幅度增长。
- 阶段 3:业务代码行数开始大幅增长,测试代码行数剧增(超出屏幕),而需求数开始下降。
看问题还是要去看本质, 根据复杂度守恒定律(泰斯勒定律) ,每个应用程序都具有其内在的、无法简化的复杂度。这一固有的复杂度都无法依照我们的意愿去除,只能设法调整、平衡。而现在前端的复杂度拆分主要包括:框架、通用组件、业务组件和业务逻辑。
当把框架和通用组件建设完成后,能够承担的复杂度基本稳定了,未来无轮再怎么改善或者更换其他框架,也很难再去突破天花板,对业务的复杂度的改变也微乎其微了(如果你的业务经历过底层框架更换,你就能体会到它到底对你的业务复杂度有没有带来变化了)
简单了解状态机
问题所在
把视角聚焦到 “业务逻辑” 侧,这里就要看所有业务中都会面临的问题,是什么让业务复杂度提升上去了。这里主要存在两点,如下:
- 代码层面
-
- 各种各样的业务状态导致的 flag 变量的剧增:即便是自己,写多了这种变量,也很难清楚的知道每个 flag 是干什么用的。
- 各种判断业务状态的逻辑:if/else,isA && isB || !(isC || !isD && isE),if/else 嵌套地狱估计在很多大型的业务产品中都能看到吧。还有内部的各种逻辑判断,即便问 PD,时间久了她也不知道了(同时产品文档可能存在没有同步最新的问题),还有因此可能导致一些意识不到的 Bug。
- 协作层面
-
- PD——开发:不太了解产品业务的同学,很难有全局业务视角,所以面对 PD 的需求很难有话语权。如果需求设计不合理,只能等到你做完了,在 UAT 的阶段才能发现,然后 PD 会给你提一个新需求,让你再去修正(虽然是 PD 的问题,但缺乏避免 PD 犯错的途径)。
- 测试——开发: 测试的内容范围,多数情况下,取决于前端同学给定的测试范围。而很多时候代码的改动,前端也不确定到底哪些页面会受影响。所以要么导致测试同学测试不完整,要么导致测试同学需要全量回归,这可是非常巨大的测试成本。
- 开发——开发: 当其他前端开发人员,参与到项目中时,面临这种复杂的项目也是头大,需要花费很大的成本梳理清楚业务与代码的关联。导致合作或者交接项目时,困难。
\
代码层面
常用代码
初始需求:
根据输入的关键字进行搜索,并将搜索结果显示出来。
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
});
});
}
当用户在等待搜索请求的时候,不应该再去搜索,所以搜索结果返回前,禁止再次发送请求:
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 的代码,会越来越难维护。即使当初是自己写的代码,过了段时间再维护的时候,需要重新理解它的内部业务逻辑。
\
使用状态机
定义状态空闲搜索中成功失败
\
var menu = {
// 当前状态
currentState: 'free',
// 绑定事件
initialize: function() {
var self = this;
self.on("click", self.transition);
},
// 状态转换
state: {
free: {
on: {
搜索:'searching'
}
},
searching: {
on: {
搜索成功: 'success',
搜索失败: 'fail',
取消: 'free'
}
},
success: {
on: {
搜索:'searching'
}
},
fail: {
on: {
搜索:'searching'
}
}
}
};
可以看到,有限状态机的写法,逻辑清晰,表达力强,有利于封装事件。一个对象的状态越多、发生的事件越多,就越适合采用有限状态机的写法。
不需要去写各种各样的if/else了,View 中只需要知道当前是什么状态,已及将事件发送到状态机就可以了,其他什么都不需要做。在新增或者修改需求的情况下,只需要对状态进行新增或者编排就可以了。
而且可视化后,有以下变化:
-清晰的看到有哪些状态
-清晰的看到每个状态可以接受哪些事件
-清晰的看到接受到事件后会转移到什么状态
-清晰的看到到达某个状态的路径是怎么样的
协作方面
那是不是我们在接需求之前就可以在自己的脑袋里生成一个状态图,即使产品不提出来一些细节,开发也可以提出不同状态交互的处理?
- 与测试人员的协作沟通
- 与 PD 人员的协作沟通
- 与其他前端开发人员的协作沟通
- 与用户的协作沟通
\
文档化
目前对项目需求的描述主要有:
- 产品需求文档(PRD)
- 设计稿
状态机方式,要求你在开发之前必须把所有可能的状态都罗列出来,状态之间的关联关系必须描述清晰。基于生成的状态图,是可以完全表达清楚所有的状态交互及变化,且它是来源于代码的,所以它是实时同步的,你代码中怎么运行的,这个状态图就是怎么表达的。
角色影响
- 设计师可以根据状态图中的不同状态,来确定哪种状态合适用什么样的 UI。
- 对于 PD,可以查看状态图,以了解系统行为,并验证是否满足要求。
- 对于测试和用户,状态图完全充当说明书用,以前不知道如何才能到达某个状态,现在一目了然。
- 对于测试还有一个很大的区别,因为基于状态机去写的,所以可以使用 Model-Based Testing,而这部分测试,可以由某些状态机工具自动化掉。
- 对于交接的前端开发来说,有说明书在手,每个状态都十分清晰,能做的事也十分清晰,在具备状态机基础的情况下,是可以快速上手的。
\
优势
- 比传统的编码方式,更容易理解。
- 基于行为建模,与视图解耦。
- 更容易改变行为:组件中的行为被提取到了状态机中,与 把行为和业务逻辑一起嵌入的组件相比,行为的更改相对容易。
- 更容易的理解代码。
- 更容易测试
- 构建状态图的过程必须探索所有状态,也是让你具备业务全局视角的过程,它迫使你考虑所有可能发生的场景。
- 基于状态图的代码比传统代码具有更少的 Bug 数。相关数据表示,错误减少了 80% 到 90%,剩下的错误也很少出现在状态图本身。
- 有助于处理可能会被忽视的特殊情况。
- 随着复杂性的增加,状态图可以很好地扩展。
- 状态图是一个很好的交流工具。
劣势
- 需要学习新的东西,状态机是一种范式的转化,且容易有抵触心里,不愿意走出舒适圈。
- 新的格式
- 新的重构技术
- 新的调试工具
- 部分人觉得可视化这种东西,没什么用。
- 陌生的编码方式,在团队内可能出现不同的阻力。
- 虽然大多数人听过状态机,但实际的编程中离它遥远,所以并不熟悉它。
- 编程方式的转换,很多人需要弄清楚原来的代码,现在该如何去写,如何映射。
- 部分人会质疑它的有效性。
- 必须有人基于这种模式实践过,对它非常了解才可以。
- 如果从来没用过它,使用这种模式会无从下手,令人生畏。
\