Reactive 就是响应式,在现在已经算是个老概念了。为什么说 more reactive 呢,其实本文最终的主旨还是要给还没有开始接触 Hooks 或者对于 Hooks 不是那么感冒的同学安利一下。Hooks 不光是一组 API,他背后承载的是 React 团队想要宣导的一套编程理念。这其中的一部分,就是我们今天的主角——响应式。我们今天就来看看,响应式给我们带来了什么,以及 hooks 和他有什么关系。
一、先谈主要矛盾
在开始讲正题之前,还是先为讨论定下一个主旨。React 是开发 Web 应用用的一种框架,开发 Web GUI 是我们的一个讨论前提。当前的大背景下,我们的前端开发工作中,主要矛盾之一就是 不断增长的应用复杂度 与 应用开发成本 之间的矛盾。人性就是想付出更少得到更多,复杂度的增长是我们无法去改变的,我们只能在成本上做更多的文章,这其中,让一份程序更好写也更好懂就是一个很常见的做法。这也是我们今天的主角——响应式编程带给我们的东西。
二、什么是响应式编程
首先我们可以先看看学术派的 wikipedia 对于响应式有着什么样的解释:
翻译过来就是 响应式编程是一种关注于数据流与变更的传播的声明式编程范式。 从这段描述中我们需要抓取几个关键词,【声明式编程范式】、【数据流】、【变更传播】。
声明式编程范式是其核心思想,与其对应的可以是命令式的。声明式更像是我们在数学课上学习的那些公式,是声明之后就是一直有效的。上面文字对 a:=b+c 这一表达在两种范式思维下的解释其实很好地表现了这一点。这种范式的好处是在于,我们能够在其指导下,更清晰得去梳理我们的逻辑,且更容易抽象拆分与组合。整个过程很像总结公式定理,公式的组合推论又形成新的公式。每个公式声明都可以很简单,简单的公式往往是稳定的,且易懂的,例如“两点之间直线最短”或者“E=mc^2”等等。
数据流则是该范式中重点关注的对象。响应式编程其描述的主体就是数据流。这种描述以数据作为串联,弱化了声明的前后顺序的影响。
变更传播则是实现数据流的核心机制。一般这种机制可以分为 Pull 和 Push,Pull 是下游主动去获取数据,Push 则是变化源主动触发依赖方的更新。在响应式的实现中,可能 Push 的形式会要更常见一些。方法有很多,常用的是发布订阅与监听者模式,很多响应式编程的实现库,也主要是基于这个方向去打造的。背后的思想其实就是为了完成声明式而实现的控制反转。
下面举个不恰当的例子,来说明一下简单的响应式编程。在电子系统逻辑设计中,我们常以门电路图来描述,用同样的方式,我们也可以画出这样的一个代码描述。A 和 B 可以想象成任何上游操作器,对接整个电路的输入端。实现上我们暂时用 React Hooks 来表达。这里的实现方式可能有点极端,不要在意哈。
电路图:
代码描述逻辑:
function Diag({ A, B }) {
const U4 = useMemo(() => !B, [B]);
const U5 = useMemo(() => !A, [A]);
const U1 = useMemo(() => A && U4, [A, U4]);
const U2 = useMemo(() => B && U5, [B, U5]);
const X = useMemo(() => U1 && U2, [U1, U2]);
return X;
}
可以看到我们每一行都很简单,并且因为是声明式的,前后执行顺序并不要紧。最后会形成从 A,B 到 X 的数据流。代码比较简单,大家应该一看就会,体会一下即可。
在 Javascript 讨论圈中,对于响应式编程也有另外一种解释方式,说他是一种关注异步数据流处理的编程范式,这里的异步数据流也可以是我们前端常提到的事件这个概念。我个人理解就是对一系列不确定什么时间发生的事情,预先提供处理方式。在这种思路的指导下,我们可以将应用中一切的行为、变更都以数据流的方式做描述,特别是异步的那些。然后我们可以使用一套针对数据流的操作符集合去组合逻辑。可以发现这种解释其实是和上面总结的是相通的。最终还是回到了数据流、变更传播、声明式范式的三件套。
三、为什么这么做更好
道理我都懂,但是这种做法究竟好在什么地方呢?下面我们从五个角度去分析一下。
1. 易读性更强,降低理解成本
都说代码是写给机器去运行的,同时也是写给人看的,人看不懂的代码不是好代码。的确,排除竞赛等特殊场景,代码的易读性、可维护性是其在工业生产中很重要的质量指标之一,特别是在前端开发中,甚至可以排上首位。那为什么就说这样写易读性就更好了呢,我们先不多说,先拿一些简单的例子大家感受一下。
// 命令式的
let items = [];
items.push(1);
items.push(2);
items.push(3);
items.push(4);
const newItems = items
.filter(item => item % 2 === 0)
.map(item => item + 0.5);
newItems.forEach(item =>
console.log(item)
);
// Output: 2.5
// Output: 4.5
items.push(5);
items.push(6);
items.push(7);
items.push(8);
// Call again
// 响应式的
import Rx from 'rxjs/Rx';
let items = new Rx.Subject();
items.onNext(1);
items.onNext(2);
items.onNext(3);
items.onNext(4);
const newItems = items
.filter(item => item % 2 === 0)
.map(item => item + 0.5);
newItems.forEach(item =>
console.log(item)
);
items.onNext(5);
items.onNext(6);
// Output: 6.5
items.onNext(7);
items.onNext(8);
// Output: 8.5
实现的功能很简单,就不多做解释了。
总结一下,命令式的程序,就是你告诉计算机你每一步应该怎么做,而响应式则是你告诉计算机,当发生了什么事情之后应该怎么做。听上去是不是就是命令式的更为保姆式一点呢,这种表达形式也与人日常的交流习惯不太一样。而且越细节的行为,可读性相对就越差的。在响应式的写法下,数据的生产与处理是可以剥离的,每一块的代码可以有自己专一的关注点。这也得益于响应式他的执行顺序弱依赖的特性。
2. 更符合交互类应用开发直觉
现代前端工程框架中,无一例外得都采用了一种简单高效的方式去描述整体逻辑,那就是 UI=f(state)
。这同样也构成了整个思想中最重要的根基定理之一,界面只与应用状态直接发生关系。数据流就是对应用状态更为具体的声明描述。长期以来,UI 开发方式的演化证明了,UI 开发本质上就是声明式的,因为 UI 上其数据变化的触发源是多样化的,数据流向是复杂可组合的,数据变化时机是无规律的,因此通过声明式的表达方式可以更简洁明了地表达,并且行为的可预期性可以更强。毕竟优秀的程序需要让人看懂。从而能同时降低开发与维护的成本。
3. 依赖静态上下文,而非运行时上下文
在响应式编程中,我们更少得会去依赖运行时的上下文,因为我们的数据关系是在书写声明时就确定的,所以其依赖的是静态上下文。在 JS 中,词法分析阶段就会确认唯一的作用域,这个就是上面说到的静态上下文。
那这其中有哪些好处呢?第一就是我们在阅读代码的时候就能根据上下文内容确认整体逻辑,减少熟悉逻辑和排查问题的时间成本。第二是我们在编译阶段就能够进行静态代码分析,去保障我们的数据流是否是正确的。第三是我们在书写响应式的具体逻辑时,更习惯用纯函数去描述,这样在测试时,也就更方便我们去细分测试,而不依赖于运行时的一些整体信息。
4. 与具体实现解耦
因为响应式编程是一种声明式编程范式,你甚至可以使用伪代码进行编写,或者使用图来描述
我们并不需要特别关心其具体实现细节,就能够保证整体逻辑的正确性。在切换技术方案,或者本身技术升级时都能尽量少的引入迁移成本。尽量保持一致的设计理念。举个例子,RxJava、RxJs 等,你能发现多种语言的实现版本。
5. 逻辑的可组合性
现代工业崛起的核心是规模化生产,而规模化生产的基石我认为是标准统一与模块化细分。响应式编程的顺序弱关联,依赖串联等特性使逻辑的自由组装更易实现。并且因为有了一致的编程范式,不同目的的逻辑模块的组合成本也会更低。
四、use Hooks, thinking in Reactive
很幸运,我们在使用 React 框架,React 本身就是基于响应式理念开发出来的。不过原本生命周期函数的写法,多多少少有点其他编程方式的影子,有点命令式一点,不够 Reactive。随着 Hooks 的加入,大大加强了 React 开发过程中响应式的短板。
Hooks 中我认为有一个非常重要的概念,就是 deps,依赖。他改变了我们原先在书写组件时更关注在什么时机执行什么的思路。让我们更关注数据本身的变化,真正通过数据去驱动应用的行为。这背后的重点是他改变了我们编程的思考方式,让我们从深入理解 React 在执行的哪个时间点会做什么事情、调用我们的哪个生命周期函数这种事情中解放出来(毕竟有时候生命周期还会调整)。我们只需要关注,在当前的组件上下文中(这个在框架体系内逃不开,而且我觉得 functional 的书写方式天然解决了一部分 immutable 的问题也未必是坏事)中,数据的变化会如何传递,会触发哪些副作用就可以了。代码也会变得更为简洁。对比一下下:
// Classical React Component
class Chart extends Component {
state = {
data: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
componentDidUpdate(prevProps) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
// Component with hooks
const Chart = ({ dateRange }) => {
const [data, setData] = useState()
useEffect(() => {
const newData = getDataWithinRange(dateRange)
setData(newData)
}, [dateRange])
return (
<svg className="Chart" />
)
}
第二个我觉得很有意思的地方是 Hooks 与组件的实例不在书写层面上做强关联,因此逻辑可以独立存在,促进了逻辑与视图的进一步分离,不再需要使用过去相对笨重的万物皆组件的思路去拆分代码。这背后还有一个奇特的隐式上下文,我认为他对响应式编程最大的作用,在于能够将多个不相关的作用域做叠加。
目前 Hooks 中提供的功能都还比较基础。还较难满足复杂的数据流声明。以前常用的 redux + saga 的实现则更像是一个事件模式,其本质可能只算是响应式中的“触发”环节。有一个叫做 redux-observable
的库是将 redux 的 action 模型作为触发器,将其与 Rxjs 的数据流描述能力组合在一起,能够使用更加复杂的数据流声明方式,是一个不错的尝试。与 Hooks 结合就能形成一个闭环。
五、小结一下
本文并不是想说 Hooks 是万能的。只是在一个启发下,认为响应式编程也许更适合前端开发的模式。对于一部分 OOP 的鼓吹者,我认为应该反思一下在我们编写前端 UI 时,是否真的用到了 OOP 的多种特性。例如,继承、多态等,JS 本身就难以支持真正的多态,其继承能力在实际使用中也渐渐少用了,毕竟在 UI 领域,组合大于继承还是比较主流的。OOP 更适合用于抽象复杂的数据模型,不过这个就不在这次的讨论范围内了,我们可以下次再水一篇文章,名字可以暂定《面向对象是不是已经过气了》。
最后点一下题,要想代码漂亮,逻辑清晰。响应式编程理念值得你一试。Make your React App more Reactive. Thanks for reading :)