本文围绕 react 及其相关生态,探讨其核心理念 UI=f(state) 的演进过程。
范式革命:前端框架的诞生
在谈范式革命之前,我们需要先搞清楚一点:原生的 JavaScript 这门语言,是基于什么范式的?
实际上这个问题有些难以回答,js 作为一门脚本语言诞生之初,只是被用来做简单的命令式 dom 更新;但用户与浏览器的回调式交互机制也决定了 js 一等函数 的特性;基于 prototype 的继承方式使 js 本身就可以进行另类的 OOP 编程;ES5、ES6 则进一步扩展了 class 语法糖和很多声明式 的内置方法。js 根本就是一个多范式的融合怪物!
很不幸的是,多范式=无范式,前端开发者不得不在命令式 dom 操作、函数式回调和 OOP 状态管理中左右横跳。一个简单的 UI 动态更新,背后常常是复杂的事件绑定和 dom 操作语句堆叠而成的屎山,代码难以维护,任何一点变更都可能牵一发而动全身。前端陷入了"混沌时代"。
也正是在这个混沌的环境中,人们开始思考一种更清晰的数据到视图的表达方式。于是像 MVC、MVVM、Flux 等模型逐渐浮出水面,它们的共同点是:将数据与 UI 解耦,并以某种方式完成 数据 → UI 的映射。
前端框架应运而生。React、Vue、Angular......它们就是这些模型的代理人,承担着将状态映射为 UI 的职责;为了让这种映射更高效、可控,这些框架也不可避免地引入了自己的"编程范式"。
举个例子:使用原生 js 需要把数据命令式 插入 dom:
document.querySelector('.name').innerText = name;
而使用 react 则强调声明式 编程,开发者通过 jsx 向 react 声明 他希望数据如何展示,至于数据到底是如何被插入 HTML,背后的 vdom 也好 fiber 也好,开发者并不需要关心:
<span>{name}</span>
文艺复兴:脱离 OOP 思想的桎梏
在 react 的早期,ES6 甚至还没正式投入使用,连 class 关键字都没有的 react 依然选择了使用'类'作为组件的载体。这种组件的建模源于 OOP:组件是真实存在于内存中的类的实例,具有生命周期,可以挂载状态,实例上的 render 函数负责生产 UI。
这是一个简单直观的模型。类天然适合承载状态,时间维度的生命周期也符合人类的直觉。而且对于当时大多数开发者来说,OOP 是最熟悉的范式。
但它并不完美:this 的动态作用域在 js 中本身就是一种不够优雅的设计,开发者对 this.state 乃至于 this 的随意修改更是十分危险。更重要的是,类组件的结构与 react 的核心理念是冲突的,react 想要纯粹声明式的 UI=f(state),但类组件让这个 f 的定义变得复杂而模糊。
其实从 react 设计的最早版本,函数组件就存在了,不过它完全是 props => jsx 的简单、无状态函数,直到 React16.8,那个 hooks 发布的版本。
函数组件终于可以拥有自己的状态、副作用和逻辑控制权。没有 this,没有类,没有对 OOP 的模仿,只有函数和闭包,react 完成了前端世界的一次文艺复兴。
傅里叶变换:类组件到函数组件
傅里叶变换是一种将'时间上的变化'转换为'频率上的分布'的方法。它重新定义了数据观察的'角度'。
react 中类组件与函数组件的差异,并不只是语法形式的演变,更像是"时域"与"频域"的维度切换。
面试中大家可能遇到过这样的问题:类组件的 componentDidMount 生命周期在函数组件里要怎么表达?我个人认为代码上讨论要怎么重构是可以的,但从逻辑上来讲,componentDidMount 和 useEffect 完全是两个维度的事物,是不可以类比的。
对于类组件,componentDidMount 是它在时间维度上的生命周期,而对于函数组件,useEffect 的语义已经表达的非常清楚了:这是在函数执行完毕,UI更新后,衍生副作用的处理器。它会在每次计算 UI=f(state) 后执行,之所以能模拟时间维度上的生命周期,只是因为 useEffect 没有观察任何依赖所以只执行了一次而已。
另外,函数组件更能表达'时间切片'或者'快照'这个概念。类组件由于实例 this 一直存在,会给人一种'连续'或者'持久'的感觉,有的开发者会把类组件的 UI 当成对实例实时观测的结果。实际上无论类组件还是函数组件,UI 本质上都是某个确定时间点上函数执行的结果。函数组件没有实例这一点,让人更容易理解:执行一次就结束了,下一次 UI 更新就是一次全新的计算。
许多初学者会觉得函数组件的闭包是一个陷阱,但其实这正是函数式作用域和闭包的正确表现。函数组件不是一个'持续存在的对象',它是每一次 render 的'快照',每次 render 都会重新定义上下文,掉入所谓闭包陷阱的原因只是错误引用了旧的上下文而已。
只可到此,不可越过:react 的妥协
react 虽然通过增强版的函数组件模型,使自身更加靠近理想中的 UI=f(state),但标准的函数式编程中,f 应该是一个无状态无副作用的纯函数,stateHook 从最开始的设计方向上已经舍弃了纯函数的可能性。
但是我们可以通过一些状态管理库来更进一步:以比较纯粹的 redux 为例。redux 把所有组件的 state 统一维护在和 react 无关的 store 里,再通过 connect & map 函数注入 props。到这一步所有的 state 都被提取到了组件之外,组件只是一个展示层------这似乎已经非常接近函数式编程的哲学了。即便如此,我们仍然没有真正达到纯函数的定义:组件中依然要通过 dispatch(action)
产生副作用。
如果我们想把函数组件做得更纯呢?我们能不能这样思考:函数组件只负责从输入状态计算 UI 和"下一状态",它不触发任何副作用,只是输出 [UI, nextState]
,我们把副作用交给"外部引擎"来处理,彻底实现 f(oldState) = [UI, newState]
。
可惜的是,React 并没有迈出这一步。也许是因为:
- 生态的破坏性更新成本太高;
- 对外部 store 的耦合性增强;
- 又或者只是因为这种形式过于晦涩,失去了 UI=f(state) 的美感。
react 只可到此,但是真的没人可以越过吗?
函数式的极致:狂信者 Elm
没错,Elm 就是那个实现 f(oldState) = [UI, newState]
或者说 update(msg, oldState) => [newState, Cmd]
的狂信者。
所有状态变更都要通过纯函数描述,所有副作用都要显式声明。Elm 抛弃了 js 社区的一切,转而投奔 Haskell 的怀抱------其语言本身几乎就是简化的 Haskell,编译器也是用 Haskell 编写的。
你不会在 Elm 中找到 useEffect
,也不会有 this.state =
,甚至你都不能写一个 副作用而不标注它的类型。Elm 是前端世界中最接近"纯函数式编程范式"的存在,极致的浪漫主义。
当然,看看社区活跃度和使用率,你会明白追求信仰的代价。
王权没有永恒:serverComponent、svelte
UI=f(state) 是 react 独家的诠释吗?不是的,这只是一个前端系统的通用模型而已,其他框架也可以有自己的理解。
像是 Svelte 这样的 AOT 框架,在编译阶段就完成了 f 的推导,比起传统的虚拟 dom 框架,牺牲了一些动态自由却换来了小型程序的效率碾压。
即使是 react 自身,也有 server component 这种由前到后的迁移,浏览器里副作用太多,没有纯粹的 f(state),那么就交给服务去跑。果然历史就是一个循环,写着写着前端又被 next 和 react 联手赶回去写 jsp 了。
再换个问法:函数式就一定是 js 架构设计的最优范式吗?
也许浏览器退休那一天我们才能得到答案。