本系列分三部曲:《框架实现》 《框架使用》 与 《数据流哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。
本篇是收官之作 《前端数据流哲学》。
写这篇文章时,很有压力,如有不妥之处,欢迎指正。
同时,由于这是一篇佛系文章,所以不会得出你应该用 某某 框架的结论,你应该当作消遣来阅读。
首先数据流管理模式,比较热门的分为三种。
- 函数式、不可变、模式化。典型实现:Redux - 简直是正义的化身。
- 响应式、依赖追踪。典型实现:Mobx。
- 响应式,和楼上区别是以流的形式实现。典型实现:Rxjs、xstream。
当然还有第四种模式,裸奔,其实有时候也挺健康的。
数据流使用通用的准则是:副作用隔离、全局与局部状态的合理划分,以上三种数据流管理模式都可以实现,唯有是否强制的区别。
一直在思考如何将这三个思维串起来,后来想通了,按照时间顺序串起来就非常自然。
暂时略过 Prototype、jquery 时代,为什么略过呢?因为当时前端还在野蛮人时代,生存问题都没有解决,哪还有功夫思考什么数据流,设计模式?前端也是那时候被觉得比后端水的。
好在前端发展越来越健康,大坑小坑被不断填上,加上硬件性能的提高,同时需求又越来越复杂,是时候想想该如何组织代码了。
最先映入眼帘的是 angular,搬来的 mvvm 思想真是为前端开辟了新的世界,发现代码还可以这么写!虽然 angluar 用起来很重,但 mvvm 带来的数据驱动思想已经越来越深入人心,随后 react 就突然火起来了。
其实在 react 火起来之前,有一个框架一步到位,进入了 react + mobx 时代,对,就是 avalon。avalon 也非常火,但是一个框架要成功,必须天时、地利、人和,当时时机不对,大家处于 angular 疲惫期,大多投入了 react 的怀抱。
可能有些主观,但我觉得 react 能火起来,主要因为大家认为它就是轻量 angular + 继承了数据驱动思想啊,非常符合时代背景,同时一大波概念被炒得火热,状态驱动、单向数据流等等,基本上用过 angular 的人都跟上了这波节奏。
虽然 react 内置了分形数据流管理体系,但总是强调自己只是 View 层,于是数据层增强的框架不断涌现,从 flux、reflux、到 redux。不得不说,react 真的推动了数据流管理的独立,让我们重新认识了数据流管理的重要性。
redux 概念太超前了,一步到位强制把副作用隔离掉了,但自己又没有深入解决带来的代码冗余问题,让我们又爱又恨,于是一部分人把目光转向了 mobx,这个响应式数据流框架,这个没有强制分离副作用,所以写起来很舒服的框架。
当然 mobx 如果仅仅是 mvvm 就不会火起来了,毕竟 angular 摆在那。主要是乘上了 react 这趟车,又有很多质疑 angular 脏检测效率的声音,mobx 也火了起来。当然,作为前端的使命是优化人机交互,所以我们都知道,用户习惯是最难改变的,直到现在,redux 依然是绝对主流。
mobx 还在小范围推广时,另一个更偏门的领域正刚处于萌芽期,就是 rxjs 为代表的框架,和 mobx 公用一个 observable 名词,大家 mobx 都没搞清楚,更是很少人会去了解 rxjs。
当 mobx 逐渐展露头角时,笔者做了一个类似的库:dob。主要动机是 mobx 手感还不够完美,对于新赋值变量需要用一些 extendObservable 等 api 修饰,正好发现浏览器对 proxy 支持已经成熟,因此笔者后来几乎所有个人项目几乎都用 dob 替代了 mobx。
这一时期三巨头之一的 vue 火了起来,成功利用:如果 ”react + mobx 很好用,那为什么不用 vue?“ 的 flag 打动了我。
一直到现在,前端已经发展到可谓五花八门的地步,typescript 打败 flow 几乎成为了新的 js,出现了 ember、clojurescript 之后,各大语言也纷纷出了到 js 的编译实现,陆陆续续的支持编译到 webassembly,react 作者都弃坑 js 创造了新语言 reason。
之前写过一篇初步认识 reason 的精读。
能接下来这一套精神洗礼的前端们,已经养出内心波澜不惊的功夫,小众已经不会成为跨越舒适区的门槛,再学个 rxjs 算啥呢?(开个玩笑,rxjs 社区不乏深耕多年的巨匠)所以最近 rxjs 又被炒的火热。
所以,从时间顺序来看,我们可以从 redux - mobx - rxjs 的顺序解读这三个框架。
redux 是强制使用全局 store 的框架,尽管无数人在尝试将其做到局部化。
当然,一方面是由于时代责任,那时需要一个全局状态管理工具,弥补 react 局部数据流的不足。最重要的原因,是 redux 拥有一套几乎洁癖般完美的定位,就是要清晰、可回溯。
几乎一切都是为了这两个词准备的。第一步就要从分离副作用下手,因为副作用是阻碍代码清晰、以及无法回溯的第一道障碍,所以 action + reducer 概念闪亮登场,完美解决了副作用问题。可能是参考了 koa 中间件的设计思路,redux middleware 将 action 对接到 reducer 的黑盒的控制权暴露给了开发者。
由 redux middleware 源码阅读引发的函数式热,可能又拉近了开发者对 rxjs 的好感。同时高阶函数概念也在中间件源码中体现,几乎是为 react 高阶组件做铺垫。
社区出现了很多方案对 redux 异步做支持,从 redux-thunk 到 redux-saga,redux 带来的异步隔离思想也逐渐深入人心。同时基于此的一套高阶封装框架也层出不穷,建议用一个就好,比如 dva。
第二步就是解决阻碍回溯的“对象引用”机制,将 immutable 这套庞大思想搬到了前端。这下所有状态都不会被修改,基于此的 redux-dev-tools “时光机” 功能让人印象深刻。
Immutable 具体实现可以参考笔者之前写的一篇精读:精读 Immutable 结构共享。
当然,由于很像事件机制的 dispatch
导致了 redux 对 ts 支持比较繁琐,所以对 redux 的项目,维护的时候需要频繁使用全文搜索,以及至少在两个文件间来回跳跃。
mobx 是一个非常灵活的 TFRP 框架,是 FRP 的一个分支,将 FRP 做到了透明化,也可以说是自动化。
从函数式(FP),到 FRP,再到 TFRP,之间只是拓展关系,并不意味着单词越长越好。
之前说过了,由于大家对 redux 的疲劳,让 mobx 得以迅速壮大,不过现在要从另一个角度分析。
mobx 带来的概念从某种角度看,与 rxjs 很像,比如,都说自己的 observable 有多神奇。那么 observable 到底是啥呢?
可以把 observable 理解为信号源,每当信号变化时,函数流会自动执行,并输出结果,对前端而言,最终会使视图刷新。这就是数据驱动视图。然而 mobx 是 TFRP 框架,每当变量变化时,都会自动触发数据源的 dispatch,而且各视图也是自动订阅各数据源的,我们称为依赖追踪,或者叫自动依赖绑定。
笔者到现在还是认为,TFRP 是最高效的开发方式,自动订阅 + 自动发布,没什么比这个更高效了。
但是这种模式有一个隐患,它引发了副作用对纯函数的污染,就像 redux 把 action 与 reducer 合起来了一样。同时,对 props 的直接修改,也会导致与 react 对 props 的不可变定义冲突。因此 mobx 后来给出了 action 解决方案,解决了与 react props 的冲突,但是没有解决副作用未强制分离的问题。
笔者认为,副作用与 mutable 是两件事,关于 mutable 与副作用的关系,后文会有说明。也就是 mobx 没有解决副作用问题,不代表 TFRP 无法分离副作用,而且 mutable 也不一定与 可回溯 冲突,比如 mobx-state-tree,就通过 mutable 的方式,完成了与 redux 的对接。
前端对数据流的探索还在继续,mobx 先提供了一套独有机制,后又与 redux 找到结合点,前端探索的脚步从未停止。
rxjs 是 FRP 的另一个分支,是基于 Event Stream 的,所以从对 view 的辅助作用来说,相比 mobx,显得不是那么智能,但是对数据源的定义,和 TFRP 有着本质的区别,似的 rxjs 这类框架几乎可以将任何事件转成数据源。
同时,rxjs 其对数据流处理能力非常强大,当我们把前端的一切都转为数据源后,剩下的一切都由无所不能的 rxjs 做数据转换,你会发现,副作用已经在数据源转换这一层完全隔离了,接下来会进入一个美妙的纯函数世界,最后输出到 dom driver 渲染,如果再加上虚拟 dom 的点缀,那岂不是。。岂不就是 cyclejs 吗?
多提一句,rxjs 对数据流纯函数的抽象能力非常强大,因此前端主要工作在于抽一个工具,将诸如事件、请求、推送等等副作用都转化为数据源。cyclejs 就是这样一个框架:提供了一套上述的工具库,与 dom 对接增加了虚拟 dom 能力。
rxjs 给前端数据流管理方案带来了全新的视角,它的概念由 mobx 引发,但解题思路却与 redux 相似。
rxjs 带来了两种新的开发方式,第一种是类似 cyclejs,将一切前端副作用转化为数据源,直接对接到 dom。另一种是类似 redux-observable,将 rxjs 数据流处理能力融合到已有数据流框架中,
redux-observable 将 action 与 reducer 改造为 stream 模式,对 action 中副作用行为,比如发请求,也提供了封装好的函数转化为数据源,因此,将 redux middleware 中的副作用,转移到了数据源转换做成中,让 action 保持纯函数,同时增强了原本就是纯函数的 reducer 的数据处理能力,非常棒。
如果说 redux-saga 解决了异步,那么 redux-observable 就是解决了副作用,同时赠送了 rxjs 数据处理能力。
回头看一下 mobx,发现 rxjs 与 mobx 都有对 redux 的增强方案,前端数据流的发展就是在不断交融。
我们不但在时间线上,将 redux、mobx、rxjs 串了起来,还发现了他们内在的关联,这三个思想像一张网,复杂的交织在一起。
我们发现,redux 和 rxjs 完全隔离了副作用,是因为他们有一个共性,那就是对前端副作用的抽象。
redux 通过在 action 做副作用,将副作用隔离在 reducer 之外,使 reducer 成为了纯函数。
rxjs 将副作用先转化为数据源,将副作用隔离在管道流处理之外。
唯独 mobx,缺少了对副作用抽象这一层,所以导致了代码写的比 redux 和 rxjs 更爽,但副作用与纯函数混杂在一起,因此与函数式无缘。
有人会说,mobx 直接 mutable 改变对象也是导致副作用的原因,笔者认为是,也不是,看如下代码:
obj.a = 1
这段代码在 js 中铁定是 mutable 的?不一定,同样在 c++ 这些可以重载运算符的语言中也不一定了,setter
语法不一定会修改原有对象,比如可以通过 Object.defineProperty
来重写 obj
对象的 setter
事件。
由此我们可以开一个脑洞,通过运算符重载,让 mutable 方式得到 immutable 的结果。在笔者博客 Redux 使用可变数据结构 有说明原理和用法,而且 mobx 作者 mweststrate 是这么反驳那些吐槽 mobx 缺少 redux 历史回溯能力的声音的:
autorun(() => {
snapshots.push(Object.assign({}, obj))
})
思路很简单,在对象有改动时,保存一张快照,虽然性能可能有问题。这种简单的想法开了个好头,其实只要在框架层稍作改造,便可以实现 mutable 到 immutable 的转换。
比如 mobx 作者的新作:immer 通过 proxy 元编程能力,将 setter
重写为 Object.assign()
实现 mutable 到 immutable 的转换。
笔者的 dob-redux 也通过 proxy,调用 Immutablejs.set()
实现 mutable 到 immutable 的转换。
真的是太看场景了。首先,业务场景的组件适合绑定全局数据流,业务无关的通用组件不适合绑定全局数据流。同时,对于复杂的通用组件,为了更好的内部通信,可以绑定支持分形的数据流。
然而,如果数据流指的是 rxjs 对数据处理的过程,那么任何需要数据复杂处理的场合,都适合使用 rxjs 进行数据计算。同时,如果数据流指的是对副作用的归类,那任何副作用都可以利用 rxjs 转成一个数据源归一化。当然也可以把副作用封装成事件,或者 promise。
对于副作用归一化,笔者认为更适合使用 rxjs 来做,首先事件机制与 rxjs 很像,另外 promise 只能返回一次,而且之后 resolve
reject
两种状态,而 Observable 可以返回多次,而且没有内置的状态,所以可以更加灵活的表示状态。
所以对于各类业务场景,可以先从人力、项目重要程度、后续维护成本等外部条件考虑,再根据具体组件在项目中使用场景,比如是否与业务绑定来确定是否使用,以及怎么使用数据流。
可能在不远的未来,布局和样式工作会被 AI 取代,但是数据驱动下数据流选型应该比较难以被 AI 取代。
首先这句话很有道理,也很有分量,不过笔者今天将从一个全新的角度思考。
经过前面的探讨,可以发现,现在前端开发过程分为三个部分:副作用隔离 -> 数据流驱动 -> 视图渲染。
先看视图渲染,不论是 jsx、或 template,都是相同的,可以互相转化的。
再看副作用隔离,一般来说框架也不解决这个问题,所以不管是 react/ag/vue + redux/mobx/rxjs 任何一种组合,最终你都不是靠前面的框架解决的,而是利用后面的 redux/mobx/rxjs 来解决。
最后看数据流驱动,不同框架内置的方式不同。react 内置的是类 redux 的方式,vue/angular 内置的是类 mobx 的方式,cyclejs 内置了 rxjs。
这么来看,react + redux 是最自然的,react + mobx 就像 vue + redux 一样,看上去不是很自然。也就是 react + mobx 别扭的地方仅在于数据流驱动方式不同。对于视图渲染、副作用隔离,这两个因素不受任何组合的影响。
就数据流驱动问题来看,我们可以站在更高层面思考,比如将 react/vue/angular 的语法视为三种 DSL 规范,那其实可以用一种通用的 DSL 将其描述,并转换对应的 DSL 对接不同框架(阿里内部已经有这种实现了)。而这个 DSL 对框架内置数据流处理过程也可以屏蔽,举个例子:
<button onClick={() => {
setState(() => {
data: {
name: 'nick'
}
})
}}>
{data.name}
</button>
如果我们将上面的通用 jsx 代码转换为通用 DSL 时,会使用通用的方式描述结构以及方法,而转化为具体 react/vue/angluar 代码时,就会转化为对应内置数据流方案的实现。
所以其实内置数据流是什么风格,在有了上层抽象后,是可以忽略的,我们甚至可以利用 proxy,将 mutable 的代码转换到 react 时,改成 immutable 模式,转到 vue 时,保持 mutable 形式。
对框架封装的抽象度越高,框架之间差异就越小,渐渐的,我们会从框架名称的讨论中解放,演变成对框架 + 数据流哪种组合更加合适的思考。
最近梳理了一下 gaea-editor - 笔者做的一个 web designer,重新思考了其中插件机制,拿出来讲一讲。
首先大体说明一下,这个编辑器使用 dob 作为数据流,通过 react context 共享数据,写法和 mobx 很像,不过这不是重点,重点是插件拓展机制也深度使用了数据流。
什么是插件拓展机制?比如像 VScode 这些编辑器,都拥有强大的拓展能力,开发者想要添加一个功能,可以不用学习其深奥的框架内容,而是读一下简单明了的插件文档,使用插件完成想要功能的开发。解耦的很美好,不过重点是插件的能力是否强大,插件可以触及内核哪些功能、拿到哪些信息、拥有哪些能力?
笔者的想法比较激进,为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分。所以插件可以随意访问和修改内核中任何数据,包括 UI。
让 UI 拥有通用能力比较容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,而插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新的插件替代掉,只要申明相同的名字即可。
剩下一半就是数据能力,笔者使用了依赖注入,将所有内核、插件的 store、action 全量注入到每一个插件中:
@Connect
class CustomPlugin extends React.PureComponent {
render() {
// this.props.Actions, this.props.Stores
}
}
同时,每个插件可以申明自己的 store,程序初始化时会合并所有插件的 store 到内存中。因此插件几乎可以做任何事,重写一套内核也没有问题,那么做做拓展更是轻松。
其实这有点像 webpack 等插件的机制:
export default (context) => {}
每次申明插件,都可以从函数中拿到传来的数据,那么通过数据流的 Connect
能力,将数据注入到组件,也是一种强大的插件开发方式。
通过上面插件机制的例子会发现,数据流不仅定义了数据处理方式、副作用隔离,同时依赖注入也在数据流功能列表之中,前端数据流是个很宽泛的概念,功能很多。
redux、mobx、rxjs 都拥有独特的数据处理、副作用隔离方式,同时对应的框架 redux-react、mobx-react、cyclejs 都补充了各种方式的依赖注入,完成了与前端框架的衔接。正是应为他们纷纷将内核能力抽象了出来,才让 redux+rxjs mobx+rxjs 这些组合成为了可能。
未来甚至会诞生一种完全无数据管理能力的框架,只做纯 view 层,内核原生对接 redux、mobx、rxjs 也不是没有可能,因为框架自带的数据流与这些数据流框架比起来,太弱了。
react stateless-component 就是一种尝试,不过现在这种纯 view 层组件配合数据流框架的方式还比较小众。
纯 view 层不代表没有数据流管理功能,比如 props 的透传,更新机制,都可以是内置的。
不过笔者认为,未来的框架可能会朝着 view 与数据流完全隔离的方式演化,这样不但根本上解决了框架 + 数据流选择之争,还可以让框架更专注于解决 view 层的问题。
HTML5 有两个有意思的标签:details
, summary
。通过组合,可以达到 details
默认隐藏,点击 summary
可以 toggle 控制 details
下内容的效果:
<details>
<summary>标题</summary>
<p>内容</p>
</details>
更是可以通过 css 覆盖,完全实现 collapse 组件的效果。
当然就 collapse 组件来说,因为其内部维持了状态,所以控制折叠面板的 打开/关闭 状态,而 HTML5 的 details
也通过浏览器自身内部状态,对开发者只暴露 css。
在未来,浏览器甚至可能提供更多的原生上层组件,而组件内部状态越来越不需要开发者关心,甚至,不需要开发者再引用任何一个第三方通用组件,HTML 提供足够多的基础组件,开发者只需要引用 css 就能实现组件库更换,似乎回到了 bootstrap 时代。
有人会说,具有业务含义的再上层组件怎么提供?别忘了 HTML components,这个规范配合浏览器实现了大量原生组件后,可能变得异常光彩夺目,DSL 再也不需要了,HTML 本身就是一套通用的 DSL,框架更不需要了,浏览器内置了一套框架。
插一句题外话,所有组件都通过 html components 开发,就真正意义上实现了抹平框架,未来不需要前端框架,不需要 react 到 vue 的相互转化,组件加载速度提高一个档次,动态组件 load 可能只需要动态加载 css,也不用担心不同环境/框架下开发的组件无法共存。前端发展总是在进两步退一步,不要形成思维定式,每隔一段时间,需要重新审视下旧的技术。
话题拉回来,从浏览器实现的 details
标签来看,内部一定有状态机制,假如这套状态机制可以提供给开发者,那数据流的 数据处理、副作用隔离、依赖注入 可能都是浏览器帮我们做了,redux 和 mobx 会立刻失去优势,未来潜力最大的可能是拥有强大纯函数数据流处理能力的 rxjs。
当然在 2018 年,redux 和 mobx 依然会保持强大的活力,就算在未来浏览器内置的数据流机制,rxjs 可能也不适合大规模团队合作,尤其在现在有许多非前端岗位兼职前端的情况下。
就像现在 facebook、google 的模式一样,在未来的更多年内,前后端,甚至 dba 与算法岗位职能融合,每个人都是全栈时,可能 rxjs 会在更大范围被使用。
纵观前端历史,数据流框架从无到有,但在未来极有可能从有变到无,前端数据流框架消失了,但前端数据流思想永远保留了下来,变得无处不在。
如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。