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

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

什么是时间旅行?

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

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

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

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

为何你不须要时间旅行?

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

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

数据驱动的前端应用

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

事件驱动的前端应用

相比之下,事件驱动的前端应用,其复杂度则来自于用户的输入事件。好比,一个富文本编辑器在编辑时就算彻底不对接后台接口,光是处理用户的粘贴、选中和键盘等事件,就能够成为传说中的『天坑』;再好比一个 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 和时间旅行在理解上的误差,但愿读者指出,我也很是愿意根据讨论去修正、优化本身的观念。

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

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

相关文章
相关标签/搜索