从时间旅行的乌托邦,看状态管理的设计误区

Redux 的状态管理理念非常优雅,随之附带的时间旅行调试支持也非常酷炫。但这个特性是否是传说中的银弹,又会给使用者带来什么额外的负担呢?让我们重新思考一下吧。

什么是时间旅行?

在 2015 年的 React Europe 会议上,Dan Abramov 展示了通过 Redux DevTools 让开发者在历史状态中自由穿梭,从而提升调试体验的 Demo,这个工具的使用体验非常惊艳,也取得了非常好的反响。在此之后,Vuex 与 MobX 等状态管理库也陆续在它们的调试工具中引入了对类似功能的支持。

我们可以认为,前端状态管理领域中,狭义的『时间旅行』概念是在满足了下面这几个前提后,开发时在历史状态中任意回溯的功能:

  • 将局部 state 统一到全局 store 中做状态管理。
  • 开发环境中安装了与状态管理库配套的 DevTools,或引入了特殊的监控组件。
  • 开发环境中启用了 Webpack 的 HMR 热加载。

需要特别注意的是,这个功能完全是调试时使用的。不过,由于这个能力给人的印象过于深刻,它也成为了许多人转向 React + Redux 技术栈的主要理由之一:漂亮的概念模型加上漂亮的调试体验,这套方案简直就是神器啊!而正如 React 第一个在浏览器里实现了声明式渲染一样,Redux 也第一个在浏览器里实现了理想中的调试体验,这些原创性的工作对前端领域的贡献是非常大的。在下文中,我们对 React + Redux 一些潜在问题的分析,也是建立在尊重社区工作的基础上的。

为什么你不需要时间旅行?

在刚刚结束的 D2 上,笔者虽然没有看到完全颠覆性的新轮子,但对于不少开放性的问题获得了全新的答案。这其中的一个问题帮助笔者重新梳理了对前端的理解,并构成了本节最主要的论据。这个问题是:前端的复杂应用该如何分类?

传统上,我们会将功能作为区分应用类别的维度。比如:管理后台、活动 H5、聊天 IM、电商购物、视频直播……我们有非常多细分领域,每个领域都有不同的业务痛点和侧重点,这样看来要想一通百通地『打通任督二脉』是很困难的。但有没有更简单的划分方式呢?这里,我们有了一个更简单的答案,即将复杂的前端应用简单地分为两类:数据驱动事件驱动

数据驱动的前端应用

这类应用的业务复杂度完全来自于后台无穷无尽的数据和复杂业务流程。比如,一个购物网站的浏览页并没有太多的输入需要处理,但来自后端接口的商品数据可以是千人千面的;再比如 12306 的订票平台,虽然它的前端界面显得简陋,但整个业务流程的复杂度可能不是一个普通用户甚至开发者所能想象的。概况地说,这类最多让用户填几个表单和验证码的应用,业务逻辑里的坑有多深常常只有摸过的同学才懂。这些应用都可以理解为是数据驱动的。

事件驱动的前端应用

相比之下,事件驱动的前端应用,其复杂度则来自于用户的输入事件。比如,一个富文本编辑器在编辑时就算完全不对接后台接口,光是处理用户的粘贴、选中和键盘等事件,就可以成为传说中的『天坑』;再比如一个 H5 版的《太鼓达人》游戏只需要从后端拉取静态的音乐资源,但用户点击的节奏只要差上几十毫秒,界面的状态和最后的结果都可能完全不同。构建这类应用的时候,其难点主要来自于在大量不同类型的异步事件可以任意地排列组合,使得可能的状态空间极度膨胀而容易出错——相信只要在页面中同时维护过几个定时器的同学都能理解。我们可以把这样的应用归类为事件驱动的。

时间旅行与应用分类

时间旅行的概念,和上面提及的两种应用分类有什么关系呢?这牵扯到很多技术选型中决定使用 Redux 的动机:Redux 开发工具能支持时间旅行,所以我们的应用在遇到类似需要回溯状态的场景时,上 Redux 的风险更小。

这听起来确实充分考虑了后期的拓展性,但它的问题在哪呢?一旦我们重新考虑了对应用的分类维度,那么对时间旅行的能力就会出现截然不同的需求

  • 数据驱动的前端应用,几乎完全不需要时间旅行的能力。由于来自后端的数据才是实质上的 Single Source of Truth,在前端基于状态管理工具的回溯操作非常容易破坏这种对数据源的依赖,导致前后端的状态不一致。一个非常简单的例子是:如果某管理后台应用的表单页支持了时间旅行,那么对表单提交事件的『旅行』重放显然会带来重复的 POST 请求,而这并不是一个幂等的操作,这时前端的时间旅行甚至会违背 RESTful 的理念。
  • 事件驱动的前端应用,非常重度依赖时间旅行类的技术。市面上几乎所有的靠谱富文本编辑器,都维护了自己的一套撤销栈——这就是时间旅行的核心功能!再比如,游戏的进度保存、读取功能也是典型的时间旅行功能。对这类应用,时间旅行甚至是影响体验的核心因素之一:一个撤销后内容格式会出莫名其妙问题的富文本编辑器,对用户还有什么信赖感可言呢?至于一个读取不了之前进度的游戏就更不用说了。甚至,只要撤销功能实现得好,用户在遇上预期外行为乃至编辑器 bug 的时候,也能自己撤销回去,然后尝试其它的交互方式来达成目标——时间旅行是用户体验最后的守卫者!

从上面的讨论中我们可以发现,只有对于事件驱动的前端应用,时间旅行的功能才有意义(并且还是极其重大的意义!)。而对于管理后台等数据驱动的前端应用,时间旅行只是可有可无的锦上添花罢了——这个业务场景下,把时间旅行作为选择 Redux 的重大理由,实在有些牵强。

相信很多同学看到这里会 argue 说,在管理后台业务中使用 Redux 是有很多成功案例的,难道你认为他们的架构师都是错的吗?并且,Redux 除了时间旅行外还有很多额外的好处,这些东西在决策时都比时间旅行重要得多呀!诚然,Redux 的流行程度已经证明它能够支撑『大规模』的前端应用,但框架的设计一定是伴随着 trade-off 的。**在一个不需要时间旅行的业务场景下,Redux 中为了实现时间旅行而引入的一些框架设计就会带来额外的问题。**因此下面我们要探讨的问题就是:Redux 为了率先实现时间旅行的特性,牺牲了哪些东西呢?

时间旅行技术栈有什么负担?

她那时候还太年轻,不知道所有命运赠送的礼物,早已在暗中标好了价格。

——《断头王后》

在刚刚发现 Redux 能够彻底解决 React 中 props 层层传递的问题时,大家非常激动:哇你看这个无状态的组件好优雅啊!哇你看只要全部状态提到 store 里,开发时我们就能随便丝般顺滑地回退啦!很快,两条最佳实践出现了:

  • 尽可能编写无状态组件,它们的状态由全局 store 管理。
  • 全局 store 的数据结构应该尽量扁平。

那么,按照这两条最佳实践开发出的应用,会存在什么问题呢?

全局状态的反模式

在时间旅行的诱惑下,把全部状态都交给 store 来管理,然后彻底干掉 setState 实在是太有诱惑里了:不仅能完美支持时间旅行,还能解决 React 里一个貌似烦人的问题。然而把全部状态交给 store 管理的时候,坑是少不了的,目前 Redux 在官方文档里对此的意见是 There is no "right" answer for this,也就是说将全部状态提到 store 中的实践也可以认为是合理的。但真的是这样吗?

不知道有多少同学在初学编程的时候,听到过前辈这样的告诫:少用全局变量。而 React 技术栈中看似高大上的全局状态,只不过是拿 Context 粉饰一新的全局变量而已——你以为穿了件 store 的马甲人家就不认识你了吗?全局变量该有的问题,全局状态一个都躲不掉:

  • 全局状态非常容易造成命名冲突,这在一个扁平化的 store 里体现得非常明显:各种 Redux 的二道贩子封装框架往往也喜欢定义一些自己的命名约定来保证『一致性』,殊不知如果命名这种事情都不能通过语言的作用域机制本身,而是需要靠脆弱的约定来保证的话,那显然是在人为加重思维负担:在没有作用域机制的汇编语言里去用匈牙利命名法无可厚非,但在 2017 年的软件工程里还在维护这种层面的约定,真的不是在开历史的倒车吗?——当然不是了!汇编语言能支持时间旅行吗?
  • 全局状态很难表达嵌套的数据类型。在 Redux 全家桶里更新 {a: {b: {c: {d: 1 }}}} 几乎是必须借助辅助工具的。对于一个富文本编辑器来说,如果想要表达『表格里支持嵌套表格』的信息,Redux 对应的原生 JSON 数据结构也显得非常单薄,基本必须上 Immutable——不过为什么我不直接使用 Immutable,跳过 Redux 这一层呢?笔者折腾过的 Slate.js 就是这么做的。哦你说 Facebook 亲生的 Draft.js 吗?它用了 Immutable 没错,不过人家实现的是优雅的扁平数据结构,绝不支持表格这种伪需求的。
  • 全局状态的内存模型不符合经典的计算机体系结构。对于一个比浏览器中网页复杂得多的桌面 GUI,每个窗口对应的进程,其对应的内存空间是相互独立,还是混杂在一个支持时间旅行的『全局状态』里呢?——这不正说明了桌面操作系统的落后吗!Mac 和 Windows 这些老古董能像我们基于 Redux 写的网页这样优雅地时间旅行吗?

到此为止,对于 Redux 推崇的扁平全局 store,我们已经有足够多的理由来质疑了。虽然这么设计 store 和时间旅行之间没有直接的关系,但对『易于调试、易于推理、易于理解』的『优雅』的全局状态,其诱惑很有可能让开发者踏进更大的陷阱里。这是值得担心的。

当然了,Redux 确实解决了一个痛点问题,即深度嵌套的组件间状态通信的问题。但解决这个问题,并不代表着我们就必须把状态全部提到全局层面。这个问题的体现,可以简单理解为:**在 A 组件里实现的方法,触发它的事件在 B 组件里,而 C 组件又需要订阅执行结果……**这时候纯 React 处理起来确实棘手,但只要将 store 放置在 A、B、C 三个组件中最顶级的一个里——而不需要放置在全局——而后通过 Context 的定制,就足够解决这个问题了。

时间旅行与 Boilerplate

另一方面,对 Redux 普遍的一个诟病在于它的 Boilerplate 代码比较多,要发一个简单的请求,都要 Action、Reducer、Middleware 走一波,思维负担比较大。这个细节其实和时间旅行的实现原理之间有着微妙的关系,简单来说,可以理解为 Redux 为了调试体验,牺牲了开发体验

在 Dan Abramov 的演讲里,提及了 Webpack HMR 和 Redux DevTools 相结合所带来的一个重要能力:一旦你更改了某个 Reducer 的代码,那么所有的 Action 都会重新求值,更新状态。

我们可以把 HMR 的粒度理解为函数级别的热替换(此处笔者理解尚浅,有错漏请务必指出),而 Redux 中实现状态管理逻辑的最小粒度,恰好就是 Reducer 这样的纯函数。从而,对于 Dan 本人而言,在 Redux 的架构上实现这样『只要发现某个函数被 patch 了,那么就把所有 JSON 格式的 Action 重新跑一遍』的特性,就不需要什么奇技淫巧的操作了——于是他在一周内就实现了 Redux DevTools,确实非常强!这时候的代价就是:使用 Redux 的开发者必须在开发阶段使用这一套显得繁重的机制,来使得 Dan 能轻松地改进调试体验……技术上的取舍没有绝对的对错,对于开发和调试成本的权衡,这里不做评论。

时间旅行并非开箱即用

除了 Redux 对时间旅行的支持方式带来的一些问题以外,另外一种隐形的坑在于这种想法:『Redux DevTools 对时间旅行支持得很好,所以在我的应用里整合这个功能应该也不难。』前文已经提及,在实现一个事件驱动型的前端应用时,时间旅行的功能确实特别重要。但实现这个特性的难度,恐怕不是拉进一个 Redux 就能简单实现的。这里以富文本编辑这个事件驱动型应用为例,列举几个业务中遇到的具体例子:

  • 在使用 Slate.js 时,撤销栈在某些情况下会被意外清空。阅读了源码后我们发现,当时的撤销栈实现,会把编辑器初始化时的更改作为栈的第一项推进去。在尝试撤销掉这一项的时候,带来的副作用会意外地破坏编辑器的计数逻辑,导致本应可以重做回去的内容丢失。这个 bug 我们已经提 PR 解决了,但类似的撤销栈细节 Issue 还有不少。
  • 一些业务场景,在撤销与重做时很难通过 push 和 pop 这样基本的栈操作解决。譬如,在上传图片的过程中用户仍然可以输入文本,这时对『进度条进度变更』的撤销事件操作,就会在撤销栈中和用户的输入事件相互『夹杂』而加大撤销的难度。
  • 对连续发生的输入事件,需要做不同的去重处理。比如用户连续地输入了一行文本,那么在撤销时,就需要一次性将整行撤销;而如果用户缓慢地逐字输入,那么就应该逐字撤销。

这些场景里,针对每个案例的解决方案都和 Redux 的理念没有太多关系。而对于一些复杂度更高的场景(如富文本编辑的实时协同),这时实现时间旅行的基础就已经不再是简单的撤销栈 + 全量状态替换,而是已经涉及到 OT 等足够写不少论文的高级算法了。这样看来,事件驱动型的应用里,如果需要实现时间旅行类型的功能,阻碍有二:

  • Redux 原生的机制即便对这个需求的基础情形,也没有很针对性的解决方案。
  • 对于这个需求的进阶情形,解决方案几乎完全和 Redux 完全无关。

因此这里的问题总结而言也比较讽刺:在需要时间旅行特性的应用里,Redux 除了引入它的一套约定外,帮不上什么忙。再结合上文的讨论,你可以发现对于时间旅行而言,它在数据驱动的应用里基本不需要实现,而在事件驱动的应用里实现时,Redux 的帮助也很有限……

我们有什么替代方案?

这篇文章不是来推销新轮子的,不过对于上文中的两种应用场景,我们都确实地发现有更合适的状态管理方案选择。MobX 和 RxJS 是笔者之前有偏好的两个库,在重新审视场景后,会发现它们恰好各有所长:

MobX 与数据驱动应用

数据驱动的应用中,领域模型很可能非常细碎而繁多(比如对于每种不同的表单,都可以有自己的数据模型),而且对于每种领域模型,封装出与之对应的增查改删能力就基本足够满足需求了。这时候,MobX 状态管理的抽象显得非常自然:

  • 基于 class 的数据模型结构,可以非常轻松地封装每种模型的增查改删操作。并且可以非常方便地实例化多个不同 store 的实例,注入到所需的组件中。对于 store 间通信,实例化子 store 时注入一个到 RootStore 的引用即可。
  • 基于 TS 的类型声明远比 Redux 里原始的字符串常量 + 原生 JS 对象要先进。
  • 基于依赖追踪的更新机制能够精确地做到在对象某个属性更新时,按需更新组件。在一般的业务场景下,这比全量更改状态再 Diff 的操作的性能要更好。作为参考,在一个大量重绘的场景下,Dan Abramov 亲自操刀优化后的 Redux 实现,才基本达到了 MobX 开箱即用的水平。

需要注意的是,MobX 在重绘时的性能优势是以访问劫持后更大的内存占用为代价的。关于这个 trade-off,笔者在 D2 上恰好也向分享 Web 优化的 UC 内核开发者讲师咨询了内存占用对前端性能的影响。根据 dalao 的回复,这方面主要的案例仍然是来自于大量下载图片等明显的反模式,而状态管理中数据模型的内存消耗则不是一个影响性能的瓶颈点。从这个角度来看,MobX 在设计上的权衡与取舍可以认为是值得的。

RxJS 与事件驱动应用

事件驱动的前端应用中,对异步逻辑的把握则显得非常重要。这方面,redux-saga 一类的库提供了一些处理异步副作用的方式,但如果你了解了 RxJS,会发现 Saga 看似强大的能力在 Rx 的事件流思维模型面前,简直就是玩具。

如果用数据驱动应用的思维来理解 RxJS,你只会感觉它的 API 十分沉重,侵入性很强。实际上,你需要在事件驱动的场景下来感受这一套理念的强大。这里的一个例子,是每天等电梯时电梯的调度方式:电梯的状态直接由用户按下楼层按钮的事件流所决定,这时通过 RxJS 的响应式编程能够很合理地建模这个业务。作为从例子出发学习 RxJS 的教程,笔者之前撰写过一篇《响应式编程入门:实现电梯调度模拟器》的专栏,还有一个配套的 Demo 实现,欢迎有兴趣的同学阅读。

总结

毫无疑问,时间旅行是一个强大的调试特性。本文讨论的是将时间旅行从调试工具向业务中落地时,可能涉及的一些问题:数据驱动的前端应用对它的需求不大;Redux 实现时间旅行的特性带来了一些反模式;实现时间旅行时要处理的其它技术细节大大超出了 Redux 所能处理的范畴等。作为替代,基于 OO 的状态管理工具 MobX 和基于响应式编程的 RxJS 是笔者在不同场景下更青睐的。对于 GraphQL 等文中没有涉及到的新轮子,希望有相关经验的读者 dalao 能不吝赐教。

本文看起来处处都在针对 Redux,虽然这里确实存在一些利益相关(笔者始终不太喜欢它,对它的使用也不如 MobX、RxJS 甚至 Vuex 深),但文中的结论是以实际的场景作为支撑的,绝对没有 Redux API 好难学所以它肯定很烂 这样的想法。而 Redux 团队的工作,也是非常值得尊敬的。如果文中有任何对 Redux 和时间旅行在理解上的偏差,希望读者指出,我也非常愿意根据讨论去修正、优化自己的观念。

最后的一点私货,是笔者对前端『圈子』的一点理解:个人发现这个领域里很多人对于日常使用的框架和工具有着一种盲目的崇拜情绪:不允许别人评论自己所用框架的问题;将框架的设计问题解释成『你不好用是因为你水平不够』的玄学问题;给同类工具直接贴上『不好』的标签……或许这确实体现了某种对前端的『执着和热爱』,但这也使得国内社区的讨论氛围相比国外,显得很糟糕。笔者在面试时喜欢提的一个开放性问题是『你偏好的这个框架有哪些不好?』,这个问题不仅有区分度(许多表现平庸的候选人常常为了体现自己对框架的熟悉,直接回答『我觉得没有什么不好』……),并且反向的思考其实更有助于我们去结合实际场景,理解框架设计的原理和取舍。

感谢坚持看到这里的你,希望本文能对你有所帮助~

相关推荐