42. 精读《前端数据流哲学》

2022-03-14 16:02:58 浏览数 (2)

本系列分三部曲:《框架实现》 《框架使用》 与 《数据流哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。

本篇是收官之作 《前端数据流哲学》。

1 引言

写这篇文章时,很有压力,如有不妥之处,欢迎指正。

同时,由于这是一篇佛系文章,所以不会得出你应该用 某某 框架的结论,你应该当作消遣来阅读。

2 精读

首先数据流管理模式,比较热门的分为三种。

  • 函数式、不可变、模式化。典型实现:Redux - 简直是正义的化身。
  • 响应式、依赖追踪。典型实现:Mobx。
  • 响应式,和楼上区别是以流的形式实现。典型实现:Rxjs、xstream。

当然还有第四种模式,裸奔,其实有时候也挺健康的。

数据流使用通用的准则是:副作用隔离、全局与局部状态的合理划分,以上三种数据流管理模式都可以实现,唯有是否强制的区别。

2.1 从时间顺序说起

一直在思考如何将这三个思维串起来,后来想通了,按照时间顺序串起来就非常自然。

暂时略过 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 的顺序解读这三个框架。

2.2 redux 带来了什么

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 的项目,维护的时候需要频繁使用全文搜索,以及至少在两个文件间来回跳跃。

2.3 mobx 带来了什么

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 找到结合点,前端探索的脚步从未停止。

2.4 rxjs 带来了什么

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 串了起来,还发现了他们内在的关联,这三个思想像一张网,复杂的交织在一起。

2.5 可以串起来些什么了

我们发现,redux 和 rxjs 完全隔离了副作用,是因为他们有一个共性,那就是对前端副作用的抽象。

redux 通过在 action 做副作用,将副作用隔离在 reducer 之外,使 reducer 成为了纯函数。

rxjs 将副作用先转化为数据源,将副作用隔离在管道流处理之外。

唯独 mobx,缺少了对副作用抽象这一层,所以导致了代码写的比 redux 和 rxjs 更爽,但副作用与纯函数混杂在一起,因此与函数式无缘。

有人会说,mobx 直接 mutable 改变对象也是导致副作用的原因,笔者认为是,也不是,看如下代码:

代码语言:javascript复制
obj.a = 1

这段代码在 js 中铁定是 mutable 的?不一定,同样在 c 这些可以重载运算符的语言中也不一定了,setter 语法不一定会修改原有对象,比如可以通过 Object.defineProperty 来重写 obj 对象的 setter 事件。

由此我们可以开一个脑洞,通过运算符重载,让 mutable 方式得到 immutable 的结果。在笔者博客 Redux 使用可变数据结构 有说明原理和用法,而且 mobx 作者 mweststrate 是这么反驳那些吐槽 mobx 缺少 redux 历史回溯能力的声音的:

代码语言:javascript复制
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 取代。

再次理解 react mobx 不如用 vue 这句话

首先这句话很有道理,也很有分量,不过笔者今天将从一个全新的角度思考。

经过前面的探讨,可以发现,现在前端开发过程分为三个部分:副作用隔离 -> 数据流驱动 -> 视图渲染。

先看视图渲染,不论是 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 对框架内置数据流处理过程也可以屏蔽,举个例子:

代码语言:javascript复制
<button onClick={() => {
  setState(() => {
    data: {
      name: 'nick'
    }
  })
}}>
  {data.name}
</button>

如果我们将上面的通用 jsx 代码转换为通用 DSL 时,会使用通用的方式描述结构以及方法,而转化为具体 react/vue/angluar 代码时,就会转化为对应内置数据流方案的实现。

所以其实内置数据流是什么风格,在有了上层抽象后,是可以忽略的,我们甚至可以利用 proxy,将 mutable 的代码转换到 react 时,改成 immutable 模式,转到 vue 时,保持 mutable 形式。

对框架封装的抽象度越高,框架之间差异就越小,渐渐的,我们会从框架名称的讨论中解放,演变成对框架 数据流哪种组合更加合适的思考。

3 总结

最近梳理了一下 gaea-editor - 笔者做的一个 web designer,重新思考了其中插件机制,拿出来讲一讲。

首先大体说明一下,这个编辑器使用 dob 作为数据流,通过 react context 共享数据,写法和 mobx 很像,不过这不是重点,重点是插件拓展机制也深度使用了数据流。

什么是插件拓展机制?比如像 VScode 这些编辑器,都拥有强大的拓展能力,开发者想要添加一个功能,可以不用学习其深奥的框架内容,而是读一下简单明了的插件文档,使用插件完成想要功能的开发。解耦的很美好,不过重点是插件的能力是否强大,插件可以触及内核哪些功能、拿到哪些信息、拥有哪些能力?

笔者的想法比较激进,为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分。所以插件可以随意访问和修改内核中任何数据,包括 UI。

让 UI 拥有通用能力比较容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,而插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新的插件替代掉,只要申明相同的名字即可。

剩下一半就是数据能力,笔者使用了依赖注入,将所有内核、插件的 store、action 全量注入到每一个插件中:

代码语言:javascript复制
@Connect
class CustomPlugin extends React.PureComponent {
  render() {
    // this.props.Actions, this.props.Stores
  }
}

同时,每个插件可以申明自己的 store,程序初始化时会合并所有插件的 store 到内存中。因此插件几乎可以做任何事,重写一套内核也没有问题,那么做做拓展更是轻松。

其实这有点像 webpack 等插件的机制:

代码语言:javascript复制
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 下内容的效果:

代码语言:javascript复制
<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 会在更大范围被使用。

纵观前端历史,数据流框架从无到有,但在未来极有可能从有变到无,前端数据流框架消失了,但前端数据流思想永远保留了下来,变得无处不在。

4 更多讨论

讨论地址是:精读《前端数据流哲学》 · Issue #58 · dt-fe/weekly

0 人点赞