最近的一次讨论记录

上周 D2,遇到了天翔,聊了一会,谈了一些看法,在这里记录一下大致的讨论。


问:听叔叔安利了 RxJS 做状态管理之后,尝试用了一段时间,总的感觉还是比较好的,但有时候还是会觉得比较别扭,这是为什么呢?


答:可能的原因有这么两点:


1. 强行把不同方向的管道流融合在一起

2. 太强调把一次性的异步操作与管道流统一在一起


问:什么叫不同方向的管道流?


答:打个比方,如果说有一个远程查询的请求,视图要订阅这个结果,步骤是这样的:


视图 -> 发起请求 -> |
                 远程
渲染 <- 得到结果 <- |


从上面这个示意图中,我们可以明显看到存在两个方向,如果不考虑其他更新的汇入来源,这个表达式是比较容易写出来的。但是,考虑到视图渲染可能是多个流订阅来的,汇入过程很可能在两个不同的地方:


- 远程请求得到结果之前

- 远程请求得到结果之后


注意这里,得到结果只是一个请求,得到数据之后不就更新视图了吗,那么,这个“之前”和“之后”是什么含义?


我们考虑的是这个数据,它可能是单一业务数据,可能是复合数据;可能是借助这个结果只渲染一个地方,也可能是多个地方,所以,你可能遇到的比较别扭的地方在于:


- 从视图往远程请求的这个方向,订阅关系是一条直线

- 从远程请求回来的这个方向,订阅关系可能是个网


如果在这个情况下,非要把它们链式写成一个流,往往带来可维护性的负担。可以考虑把它断成两节,上一半只管写,下一半只管读。从写的方向看,一条线到底,拿到结果,无论正常、异常,是否有 pending,都一起写入下面这些流的起始点;从读的方向看,对于任意一个起点,都可能看得到它往视图方向的整个树,从视图方向看,任何一个视图的组合订阅,又都可以看得到往数据层的整个订阅树。虽然这是个网,但看上去也是非常清晰的,可维护性也比较好。


问:那么,一次性和管道流的含义又分别是什么?


所谓一次性,是指非可重复的事情,比如单次的网络请求,通常,你会用 Promise 表达,但对于一个掌握了 RxJS 基本用法的人来说,身怀利器,杀心自起,你会倾向于把原先用 Promise 表达的东西全部换成 RxJS,比如一个简单的 xxx.then,你就要想把它变成 mergeMap,其实这是不必要的。


Future 和 Stream,没有必要在中间步骤上做融合。如果你有一个多级的不涉及外部状态变更的连续请求,应当先用 Promise 搞成串,然后整体与流结合,不需要把串里面的每一段都用流来表达。


有一个简单的办法控制内心欲望,那就是不让代码中出现 Promise,尽可能不要 then,而是用 await 表达这些东西,封装成 async 函数,这样,整体与 Stream 结合就不会有违和感。


总的来说,如果你的目的只是用 RxJS 来做数据流管理,那其实可以只用里面几个比较常用的操作符,适当克制自己的欲望,这样整个代码的可维护性会比较好。


问:这个读写分离的机制,有些像 Redux 了,那为什么叔叔一直对 Redux 这么深恶痛绝呢?


答:几个原因:


1. 代码信噪比太低

2. 异步中间件的实现机制是比较别扭的

3. 与 React 的结合机制,其实破坏了 React 本身的一些东西。


问:前两点很好理解,上次吉姆的知乎回答里也提到了,第三点怎么理解呢?


答:我们需要注意到,在不存在 Redux 的时候,React 本身是有两个特点的:


1. 整个体系是以视图为主体的,一切以视图为重,数据逻辑其实依附在组件生命周期中

2. 架构是分形的:从组件树上取下任意一个分支,都还是可以运行的,而如果把顶级组件再次包装,它整个又成为了别人的分支。


但是在 React-Redux 体系中,这两点都被破坏了。


外置的数据层,尤其是单一 Store,其实对 React 的架构破坏性非常大,因为这是一个很明显的 MDV 结构了,之前的 M 不明显,现在可就不一样了。当你在一个明显存在 M 的场景下,你潜意识就会对 M 精心维护,然后会发现,数据不再是整个架构中的从属部分,而是一个源头,或者说主体,视图反而变成从属了。


当然,我不是说这种架构不正确,不合理,而是说,有没有 Redux,对整个 React 应用的结构是有较大影响的。对于 React 这么一个本身是重视图的初始设计而言,这种做法很容易搞得头重脚轻。比如说,你会经常看到有人希望把一切状态、包括组件内部状态都整合到 Redux 去,把视图极度轻量化,这就是另外一个极端了。


此外,这个架构变成不分形了,也是一个别扭的地方,一个树对一个平级结构的依赖,除非这个平级结构隐含了树形关系,否则就必然是不分形的。不分形,当我们遇到架构调整,比如说多应用整合,每个都要下沉一级,然后搞一个总的菜单,类似阿里云多控制台的统一入口那样,就会比较尴尬。


问:那么,理想的框架应该怎么做才比较合适呢?


答:如果你理解了 CycleJS,并且入门了 RxJS 或者 xstream 这类流式库,那么,你很大概率会不认同 React-Redux 体系。


首先,无论出现什么情况,保持架构的分形都是一个比较好的方式。

其次,如果你期望把数据与视图彻底隔离,最好一开始就把数据提到比较重要的地位去,把视图降低为附庸。


我们来看 CycleJS 的一个组件,它的数据输入、事件定义都完全隔离在视图之外,视图只是最基本渲染,这个分离就非常自然,相比来说,React 的这些问题,都会使得社区存在非常多的流派,并且,逊尼派和什叶派之间的互不认同感可能比对其他框架还强。所以,如果说真的存在面向未来的框架,那也至少是 CycleJS 这样的,而不是 React。


问:那么,其他框架也会有分形的问题吗?


答:是的,会有。


比如说 Vue,它的情况跟 React 类似,都是最初只面向组件化的视图层框架,然而,后期也引入了 VueX 这样的东西,我个人从未评价过 VueX,但实际上认为这个东西的存在,能解决不少工程问题,但也让整个架构有点不那么和谐。


反倒是 Angular,甚至 AngularJS,这方面还稍好。


问:为什么 AngularJS 这方面还稍好呢?它不是有一些什么 Service 之类的东西吗?


答:我作这个论断,主要是两个依据:


1. 组件生命周期方法未以约定方式存在于组件主体中,导致组件的主体是比较纯净的,如果你只把模板当作视图,那这时候,其实要比另外一些框架里,组件声明周期那么明确地表露出来要自然一些。


比如说:


<div ng-init="xxx()">


而不是在 controller 中,弄一个默认的约定:


onInit() {

}


我个人认为,组件中生命周期方法与普通的业务方法混在同一个 Class 中,是不太合适的,最好能分开。


2. 在 AngularJS 中的 Service,其实并未提倡把“状态”分离出来,大部分情况下只是作为类似公共函数那样的东西,并不持有状态,状态的持有是在 Controller 中的。所以它并不破坏视图的分形。


问:可是,AngularJS 并未那么明显地凸显组件,它的 Controller 应该如何理解呢?


答:我们把它转换一种形式:


@tpl(tplStr)

class SomeController {

foo() {}

bar() {}

}


你再看看?


问:这不是 Angular 的写法吗,AngularJS 好像不是这么写的啊?


答:写法只是形式,实际上它是可以转换成这种写法的,比如这个代码:github.com/teambition/t


如果你注意看最下面那句,你就不会怀疑它是不是 AngularJS。


问:这样看来,好像是理解了,几个框架深层次居然有这么多的一致性。


答:没错,如果一个人深刻理解了这些,他是有机会在不同框架之间保留尽可能多的逻辑代码,从而使得迁移代价尽量小的。AngularJS 的主要问题在另外一些方面,设计是一个问题,另外还有一些东西,比如工具链路的重视程度就不如 React 体系,不太敢于在官方实践中引入这类 Decorator 的便利写法,或者 TypeScript 这类语言支持。


不过,当时那个时候,大家也不容易理解和支持这些,你现在可能觉得 Decorator 挺好的语言特性啊,但那时候,不知道有多少人吐槽 Angular 2 的这个设计了。


问:为什么不认同组件的生命周期方法跟其他业务方法混合的方式,期望把它们分开呢?


答:我举个例子吧。前几天,公司群里有个讨论很有意思:


React 代码中,有人写:


async componentWillMount () {}


这么一句代码引发了一段讨论,有人就认为这个 async 会影响这个生命周期方法的结果,而实际上,这个 async 只是为了让你能在这个方法体内写 await 语句,并没有别的什么作用。


组件的生命周期方法往往带有很多隐藏含义,业务方法跟他混合的时候,有时候比较难区分,如果生命周期方法的名字过于平凡,不是一个明显能区分的方法名,很可能就在某些时候造成业务开发人员的困扰了。


问:可不可以这么理解,基于 RxJS 这样的库,可以直接实现一个框架?为什么 CycleJS 不把 Stream 直接拆到小粒度数据,而是要整体扔给 vdom?为什么 Vue 底层也构建了一层订阅关系,但是用了 getter, setter 的方式,没有用看起来更优雅的 Stream,Stream 不是还能兼容异步吗?


答:不这么做的主要原因在于性能,恰恰就因为 Stream 要兼容异步。注意,并不是因为异步导致性能不好,而是你的每个东西,因为要同时兼容同步和异步,都会要用异步的方式来处理,这个包装过程是会明显变慢的,当在框架底层这么去实现的时候,就可能把这种变慢的东西放大了很多倍,达到不可接受的地步了。



所以,刚才例子中的那个 componentWillMount,它的实现就不太可能兼容异步。同理,为什么 React 的 setState 宁可接受回调作第二个参数,也不变成 Promise 方法,就是这样的考虑。所以,Vue 对数据处理的方式,是在外层推,组件内部拉,这是一个折中方案,也是比较合适的。


注意:我们不能接受组件框架原生异步,但是,是可以接受数据流原生异步的,因为后者承载的东西,很难出现很多倍的放大,你做网络请求之类的事情,本身就不会期望它立刻返回,适当损失有些性能,心里是有所预期的,在这个部分,更关注的会是代码组织的优雅。


问:那么,前端业务逻辑这块,函数式不能特别普及的原因是什么?


答:主要是几点:


1. 函数式的抽象代价比较大

2. 中国的大学教育基本上很少有灌输 SICP 这些概念的

3. JavaScript 语言本身不是函数式语言,只是支持部分函数式特性


所以,很函数式的业务代码做不到“无脑写,无脑读”,门槛就必然高,也正是因为这个,会影响一些框架的流行度。像 React 这样,能这么流行已经很不错了,主要是占了工程体系的光,不然还达不到这样。而在国内这么环境下,不那么函数式的东西自然会更流行,人民群众有他喜闻乐见的东西,各有各的好。


问:那如何看待 TypeScript 的逐渐流行呢?


答: 实际上,这是几个不相关方面导致的吧:


- 一些从 Java 等方面转来的人,误以为这是一种跟 Java 差不多的 OOP 语言,感到亲切

- 一些函数式的人,因为期望类型的推导

- 一些人希望能够在工程角度增加一些稳定性


可以看到,对它的误解还是比较多,比如说,有些声称喜欢函数式的人,说自己厌恶 TS 是因为它的 OOP 倾向,实际上类型在函数式里面才是更重要的东西,而类型跟 OOP 根本就是毫无关系的事情。我也见过一些只用过 JS 就声称自己喜欢函数式编程的人,或者认识不到 Stream 也是一种重要的函数式理念,其实没必要黑,毕竟一山还有一山高,我自己也还徘徊在门口,不知道入门没有呢。

编辑于 2017-12-23 01:59