雅趣 2017-12-18
Redux 的状态管理理念非常优雅,随之附带的时间旅行调试支持也非常酷炫。但这个特性是否是传说中的银弹,又会给使用者带来什么额外的负担呢?让我们重新思考一下吧。
在 2015 年的 React Europe 会议上,Dan Abramov 展示了通过 Redux DevTools 让开发者在历史状态中自由穿梭,从而提升调试体验的 Demo,这个工具的使用体验非常惊艳,也取得了非常好的反响。在此之后,Vuex 与 MobX 等状态管理库也陆续在它们的调试工具中引入了对类似功能的支持。
我们可以认为,前端状态管理领域中,狭义的『时间旅行』概念是在满足了下面这几个前提后,开发时在历史状态中任意回溯的功能:
需要特别注意的是,这个功能完全是调试时使用的。不过,由于这个能力给人的印象过于深刻,它也成为了许多人转向 React + Redux 技术栈的主要理由之一:漂亮的概念模型加上漂亮的调试体验,这套方案简直就是神器啊!而正如 React 第一个在浏览器里实现了声明式渲染一样,Redux 也第一个在浏览器里实现了理想中的调试体验,这些原创性的工作对前端领域的贡献是非常大的。在下文中,我们对 React + Redux 一些潜在问题的分析,也是建立在尊重社区工作的基础上的。
在刚刚结束的 D2 上,笔者虽然没有看到完全颠覆性的新轮子,但对于不少开放性的问题获得了全新的答案。这其中的一个问题帮助笔者重新梳理了对前端的理解,并构成了本节最主要的论据。这个问题是:前端的复杂应用该如何分类?
传统上,我们会将功能作为区分应用类别的维度。比如:管理后台、活动 H5、聊天 IM、电商购物、视频直播……我们有非常多细分领域,每个领域都有不同的业务痛点和侧重点,这样看来要想一通百通地『打通任督二脉』是很困难的。但有没有更简单的划分方式呢?这里,我们有了一个更简单的答案,即将复杂的前端应用简单地分为两类:数据驱动和事件驱动。
这类应用的业务复杂度完全来自于后台无穷无尽的数据和复杂业务流程。比如,一个购物网站的浏览页并没有太多的输入需要处理,但来自后端接口的商品数据可以是千人千面的;再比如 12306 的订票平台,虽然它的前端界面显得简陋,但整个业务流程的复杂度可能不是一个普通用户甚至开发者所能想象的。概况地说,这类最多让用户填几个表单和验证码的应用,业务逻辑里的坑有多深常常只有摸过的同学才懂。这些应用都可以理解为是数据驱动的。
相比之下,事件驱动的前端应用,其复杂度则来自于用户的输入事件。比如,一个富文本编辑器在编辑时就算完全不对接后台接口,光是处理用户的粘贴、选中和键盘等事件,就可以成为传说中的『天坑』;再比如一个 H5 版的《太鼓达人》游戏只需要从后端拉取静态的音乐资源,但用户点击的节奏只要差上几十毫秒,界面的状态和最后的结果都可能完全不同。构建这类应用的时候,其难点主要来自于在大量不同类型的异步事件可以任意地排列组合,使得可能的状态空间极度膨胀而容易出错——相信只要在页面中同时维护过几个定时器的同学都能理解。我们可以把这样的应用归类为事件驱动的。
时间旅行的概念,和上面提及的两种应用分类有什么关系呢?这牵扯到很多技术选型中决定使用 Redux 的动机:Redux 开发工具能支持时间旅行,所以我们的应用在遇到类似需要回溯状态的场景时,上 Redux 的风险更小。
这听起来确实充分考虑了后期的拓展性,但它的问题在哪呢?一旦我们重新考虑了对应用的分类维度,那么对时间旅行的能力就会出现截然不同的需求:
从上面的讨论中我们可以发现,只有对于事件驱动的前端应用,时间旅行的功能才有意义(并且还是极其重大的意义!)。而对于管理后台等数据驱动的前端应用,时间旅行只是可有可无的锦上添花罢了——这个业务场景下,把时间旅行作为选择 Redux 的重大理由,实在有些牵强。
相信很多同学看到这里会 argue 说,在管理后台业务中使用 Redux 是有很多成功案例的,难道你认为他们的架构师都是错的吗?并且,Redux 除了时间旅行外还有很多额外的好处,这些东西在决策时都比时间旅行重要得多呀!诚然,Redux 的流行程度已经证明它能够支撑『大规模』的前端应用,但框架的设计一定是伴随着 trade-off 的。**在一个不需要时间旅行的业务场景下,Redux 中为了实现时间旅行而引入的一些框架设计就会带来额外的问题。**因此下面我们要探讨的问题就是:Redux 为了率先实现时间旅行的特性,牺牲了哪些东西呢?
她那时候还太年轻,不知道所有命运赠送的礼物,早已在暗中标好了价格。
——《断头王后》
在刚刚发现 Redux 能够彻底解决 React 中 props 层层传递的问题时,大家非常激动:哇你看这个无状态的组件好优雅啊!哇你看只要全部状态提到 store 里,开发时我们就能随便丝般顺滑地回退啦!很快,两条最佳实践出现了:
那么,按照这两条最佳实践开发出的应用,会存在什么问题呢?
在时间旅行的诱惑下,把全部状态都交给 store 来管理,然后彻底干掉 setState
实在是太有诱惑里了:不仅能完美支持时间旅行,还能解决 React 里一个貌似烦人的问题。然而把全部状态交给 store 管理的时候,坑是少不了的,目前 Redux 在官方文档里对此的意见是 There is no "right" answer for this
,也就是说将全部状态提到 store 中的实践也可以认为是合理的。但真的是这样吗?
不知道有多少同学在初学编程的时候,听到过前辈这样的告诫:少用全局变量。而 React 技术栈中看似高大上的全局状态,只不过是拿 Context 粉饰一新的全局变量而已——你以为穿了件 store
的马甲人家就不认识你了吗?全局变量该有的问题,全局状态一个都躲不掉:
{a: {b: {c: {d: 1 }}}}
几乎是必须借助辅助工具的。对于一个富文本编辑器来说,如果想要表达『表格里支持嵌套表格』的信息,Redux 对应的原生 JSON 数据结构也显得非常单薄,基本必须上 Immutable——不过为什么我不直接使用 Immutable,跳过 Redux 这一层呢?笔者折腾过的 Slate.js 就是这么做的。哦你说 Facebook 亲生的 Draft.js 吗?它用了 Immutable 没错,不过人家实现的是优雅的扁平数据结构,绝不支持表格这种伪需求的。到此为止,对于 Redux 推崇的扁平全局 store,我们已经有足够多的理由来质疑了。虽然这么设计 store 和时间旅行之间没有直接的关系,但对『易于调试、易于推理、易于理解』的『优雅』的全局状态,其诱惑很有可能让开发者踏进更大的陷阱里。这是值得担心的。
当然了,Redux 确实解决了一个痛点问题,即深度嵌套的组件间状态通信的问题。但解决这个问题,并不代表着我们就必须把状态全部提到全局层面。这个问题的体现,可以简单理解为:**在 A 组件里实现的方法,触发它的事件在 B 组件里,而 C 组件又需要订阅执行结果……**这时候纯 React 处理起来确实棘手,但只要将 store 放置在 A、B、C 三个组件中最顶级的一个里——而不需要放置在全局——而后通过 Context 的定制,就足够解决这个问题了。
另一方面,对 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 就能简单实现的。这里以富文本编辑这个事件驱动型应用为例,列举几个业务中遇到的具体例子:
这些场景里,针对每个案例的解决方案都和 Redux 的理念没有太多关系。而对于一些复杂度更高的场景(如富文本编辑的实时协同),这时实现时间旅行的基础就已经不再是简单的撤销栈 + 全量状态替换,而是已经涉及到 OT 等足够写不少论文的高级算法了。这样看来,事件驱动型的应用里,如果需要实现时间旅行类型的功能,阻碍有二:
因此这里的问题总结而言也比较讽刺:在需要时间旅行特性的应用里,Redux 除了引入它的一套约定外,帮不上什么忙。再结合上文的讨论,你可以发现对于时间旅行而言,它在数据驱动的应用里基本不需要实现,而在事件驱动的应用里实现时,Redux 的帮助也很有限……
这篇文章不是来推销新轮子的,不过对于上文中的两种应用场景,我们都确实地发现有更合适的状态管理方案选择。MobX 和 RxJS 是笔者之前有偏好的两个库,在重新审视场景后,会发现它们恰好各有所长:
数据驱动的应用中,领域模型很可能非常细碎而繁多(比如对于每种不同的表单,都可以有自己的数据模型),而且对于每种领域模型,封装出与之对应的增查改删能力就基本足够满足需求了。这时候,MobX 状态管理的抽象显得非常自然:
需要注意的是,MobX 在重绘时的性能优势是以访问劫持后更大的内存占用为代价的。关于这个 trade-off,笔者在 D2 上恰好也向分享 Web 优化的 UC 内核开发者讲师咨询了内存占用对前端性能的影响。根据 dalao 的回复,这方面主要的案例仍然是来自于大量下载图片等明显的反模式,而状态管理中数据模型的内存消耗则不是一个影响性能的瓶颈点。从这个角度来看,MobX 在设计上的权衡与取舍可以认为是值得的。
事件驱动的前端应用中,对异步逻辑的把握则显得非常重要。这方面,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 和时间旅行在理解上的偏差,希望读者指出,我也非常愿意根据讨论去修正、优化自己的观念。
最后的一点私货,是笔者对前端『圈子』的一点理解:个人发现这个领域里很多人对于日常使用的框架和工具有着一种盲目的崇拜情绪:不允许别人评论自己所用框架的问题;将框架的设计问题解释成『你不好用是因为你水平不够』的玄学问题;给同类工具直接贴上『不好』的标签……或许这确实体现了某种对前端的『执着和热爱』,但这也使得国内社区的讨论氛围相比国外,显得很糟糕。笔者在面试时喜欢提的一个开放性问题是『你偏好的这个框架有哪些不好?』,这个问题不仅有区分度(许多表现平庸的候选人常常为了体现自己对框架的熟悉,直接回答『我觉得没有什么不好』……),并且反向的思考其实更有助于我们去结合实际场景,理解框架设计的原理和取舍。
感谢坚持看到这里的你,希望本文能对你有所帮助~