Vue录音模块状态机重构笔记
目次
最近被抓去写微信小程序,按照决策者的话说,逻辑都是一样的。
这句话对了一半。
作为一个成熟的 UI 框架,支持的交互能力一定是相似的:点击、长按、滑动、双击,不论是 Android、iOS 还是小程序,最终都会落到同一套用户行为模型上。但另一半恰恰是问题所在——语言和范式,会反过来塑造你组织逻辑和状态的方式。
以 Android 和小程序为例。Android 使用 Java、Kotlin 甚至 C++,这些都是强类型语言,有继承、多态、接口,重复逻辑既可以组合,也可以通过继承收敛。状态往往会被包进类里,由类型系统帮你兜底。
而在 Vue / 小程序中,语言是 JavaScript,本身动态类型语言,在不引入 TypeScript 的情况下,几乎没有编译期类型约束;虽然语法上存在 class,但在实际工程中,很少通过继承体系来承载状态语义。
这在简单页面里问题不大,但一旦交互复杂,很容易失控。
这篇文章记录的,就是我在一个录音页面里,从“多个状态变量维护”一路踩坑,最终重构成状态机的过程。
背景 #
决定重构并不是为了“写得更优雅”,而是代码已经修不动了。
页面里有两个主要流程:
- AI 提问流程:视频播放到指定时间 → 系统引导 → 用户长按录音 → 识别 → 反馈
- 学生主动提问流程:用户点击 → 引导 → 长按录音 → 最多两轮对话 → 结束
限制条件很多:
- 播放音频时不能提问或录音
- 录音按钮和提问按钮是同一个组件
- 长按开始,松手结束,上滑取消
- 问题只会在体验版(类似 Android 的正式包)里完整暴露
真正卡住我的是一个 bug:
长按后立刻松手,重复几次后,会进入“再也无法录音”的状态。
日志看不出异常,复现不稳定,调了大半天没有头绪。越查越发现一个问题: 我已经不知道系统当前到底处在什么状态了。
状态失控 #
先看状态变量。
| |
变量本身都很合理,问题在于: 它们组合在一起,才代表一个“真实状态”,但这种组合完全靠人脑维护。
判断“现在能不能开始录音”,需要这样写:
| |
当这些变量组合在一起,可能性已经完全超过了可以用逻辑理清的范畴
异步时序 #
微信的录音 API 是异步的:
start()不代表录音已经开始- 硬件真正就绪后,才会触发
onStart
正常流程是:
| |
但如果用户“秒按”:
| |
旧代码里的 onStart 并不知道用户已经松手:
| |
于是 UI 会被切到“录音中”,而用户此时已经放开手,状态就此错位。
这正是那个“松手了还在录音”的根源。
重构一:明确状态 #
第一步是把“隐含状态”变成“显式状态”。
| |
现在判断“是否可以录音”只需要一行:
| |
状态本身就是语义,不再需要拼布尔值。
重构二:用会话 ID 隔离异步回调 #
为了解决时序问题,我引入了录音会话 ID。
| |
开始录音时生成会话:
| |
在 onStart 中校验:
| |
如果用户在硬件启动前松手,直接作废会话:
| |
所有异步回调都必须验证 session 是否有效,Race Condition 被彻底隔离。
重构三:状态驱动UI #
所有 UI 行为统一收敛到配置表:
| |
状态切换统一通过:
| |
UI 不关心“为什么”,只关心“现在是什么状态”。
重构四:状态日志面板 #
为了验证状态流是否正确,我在页面里加了一个状态日志面板,记录每一次状态切换和关键日志。
事实证明这是非常值得的投入: 很多“偶现问题”,在日志里其实一眼就能看出来,只是以前没有工具帮你看见。
重构结果 #
- 秒按、长按、取消不再互相污染状态
- 状态判断全部集中,逻辑路径清晰
- 错误恢复不再依赖“记得重置哪些变量”
判断当前的状态由十几个变量改为一个变量,通过判断变量状态的值就能够判断当前显示的样式。好处很明显,降低了复杂度(想不到写代码还会用到这个词),只要流程是清晰的,状态的改变一定是按照预先考虑的顺序的,即使中间有状态的转换。
当控件由多个布尔值维护时,其实已经意识到这部分代码很可能已经失控了, 变量越用越多,状态就越来越复杂,总有复杂到理不清楚的状态。
反思 #
回过头再想,重构做的好吗,是还能做的更好的,这明显就是状态模式,可惜的是 JavaScript 在语言层面并不支持 sealed 这类类型系统能力,只能通过约定和运行时校验来模拟。
回过头来看这个录音模块,可以先抽象出两个高优先级维度,再衍伸出具体业务状态。
第一类:交互方式维度
- 点击触发(Tap)
- 长按触发(Long Press)
第二类:是否允许触发
- 可触发
- 不可触发(被音频播放、处理中、次数限制等条件锁住)
在这两个维度之下,才进一步派生出更具体的业务状态,比如:
- 等待输入
- 正在录音
- 系统处理中
- 等待退出
- 奖励展示
在 JavaScript / Vue 里,这些关系通常会被压扁成一堆布尔值; 而在 Kotlin 里,这是密封类(sealed class)绝佳的使用场景。
密封类描述状态 #
如果这是 Android,我大概率会先定义一个状态模型,而不是一堆 flag。
| |
这里有几个关键点:
状态是封闭集合
- 不存在“忘记处理的状态”
- 编译器会强制你在
when里覆盖完整
交互方式本身是一级语义
- Tap 和 LongPress 是两棵完全独立的状态树
- 不需要靠
isLongPress && canRecord && !isPlaying这种组合判断
是否可触发被状态本身表达
Disabled就是不允许- 不需要额外的
canTrigger变量
状态驱动 UI #
使用会非常简单,比如在 UI 层:
| |
这里没有任何“隐式约定”,每一种 UI 行为,都由一个确定的状态类型驱动。
这次最难处理的,其实不是状态本身,而是录音 start / stop 的异步时序。
在 Kotlin 中,我很可能会把它直接建模进状态里:
| |
然后在回调里只做一件事:
| |
非法回调在类型层面就就被过滤掉了,用不到依赖其他变量或是逻辑。
对比 #
在 Vue / JavaScript 里,我只能通过:
- 手动维护
currentRecordSession - 手动判断
isHardwareRunning - 手动约定哪些状态允许哪些行为
才能达到类似的效果。
我认为这是语言的定位不同造成的,JS最最开始也是只操作dom树的语言,至于后续为什么演变成现在的状态,我没有经历过,不敢轻下妄言。
这次在小程序里用状态机,本质上是模拟 Kotlin sealed class :
- 状态是有限的
- 转换路径是显式的
- UI 行为由状态决定,而不是由事件临时判断
只是这些,在 Kotlin 里,编译器可以帮你约束; 在 JavaScript 里,只能靠你自己。