什么是响应式编程

直接举个🌰
比如我们有个需求,要统计发送 http 请求的次数,那么意味着我们会有两个模块,一个是 http request 用于发送 http 请求,一个是 counter 用于统计次数。其实我们的需要就是当 http 请求发送时,counter会执行某个方法令其记录的数据 +1。
最直接的方法就是这样:

  1. class HttpRequest {
  2. send(){
  3. // 实际发送 http 请求的实现
  4. counter.increment();
  5. }
  6. }

当 send 方法被调用时,直接执行 counter.increment(),这样counter内的数据就+1了,然后我们的需求就做完了。
当时我们仔细想想,作为一个 http 模块,它要做的事情应该只是发送一个 http 的请求,它为什么要关心计数的问题,计数明明应该是 counter 需要关心的事情。这样写的话,其实 http 模块和 counter 模块其实是耦合在一起的。
那么我们换个思路来想,既然说计数这个事情应该是由 counter 来做,那么 counter.increment() 的行为是不是应该 counter 自己来调用。
怎么实现呢:

  1. class HttpRequest {
  2. send(){
  3. // 实际发送 http 请求的实现
  4. eventBus.emit("requestDone")
  5. }
  6. }
  1. class Counter {
  2. constructor(){
  3. eventBus.on("requestDone").then(() => {
  4. this.increment();
  5. })
  6. }
  7. }

这样的话,counter 的 increment 就可以是一个私有方法,就不会被外部去改变。而 http 模块也不再需要知道 counter 模块,它只需要完成自己的本职工作就可以了(发送 http 请求)。
所以响应式编程的优势是什么?就是我们软件工程一直提倡的:关注度分离。

前端的响应式编程

其实大家作为前端开发人员,我相信大家都已经接触过响应式编程了。因为我们现在主流的前端框架都是响应式的。联想一下,在各个前端框架中,我们现在要改变视图,不是用jquery命令式地去改变dom,而是通过setState(),修改this.data或修改$scope.data…。我们修改的明明只是数据,但是只要数据更新了,我们就不用管了,这些前端框架会自动帮我们把数据渲染成视图。
响应式编程(Reactive Programming)入门 - 图1
所以有了这些响应式的框架,我们平时开发的心智模型降低了很多,我们只需要去操作数据就可以了,然而,修改数据这件事听着就很像命令式,尤其是随着产品功能越来越复杂,我们要管理的状态也越来越庞大,如果每个地方都可以随意修改这些状态的话,这些状态就会变得越来越不可控,越来越难以追溯。所以当我们开始使用这些响应式的前端框架开发比较大型的项目时,状态管理尤为重要,如果还是以之前命令式的思想去修改状态,无非是从一个地狱跳入到另一个地狱。
为了解决管理状态这个痛点,好多状态管理的库应运而生,不管是 Redux 还是 Mobx,他们解决的方向都是为了让状态的变化可预测,另外再提供一些撤销/重做,时间旅行等附加功能。当时他们都没有解决状态从产生变化再实际 set state 的这段过程该怎么管理。所以我们还是免不了写一大堆命令式的代码去修改状态。
既然现在主流的框架都是响应式的,我们也慢慢摒弃了 jquery 这种命令式的开发视图的模式,说明响应式对于我们前端开发来说是适合的,那么既然视图的更新我们已经做到了响应式的,是不是数据的更新也可以弄成响应式的?视图的响应式开发让我们避免了操作DOM,而数据的响应式开发则会让我们避免操作store。我们只需要关心数据的来源即可,来源的数据产生了变化,store自然而然会跟着变化。

怎么实现响应式编程

  • EventBus
  • Object.defineProperties
  • ES2015 Proxy
  • Streams (with some libraries like RxJS,xstream)

今天我们主要讲最后一个实现方式:数据流

什么是数据流 Stream

大家都知道数组,数组是什么?是内存中的一片空间来存储我们数据的数据结构。所以数组是空间上的序列。
而数据流,则是时间上的数据序列。
响应式编程(Reactive Programming)入门 - 图2
有了数据流之后可以做什么呢?还是继续和数组来对比:

map 操作

数组可以通过 map,把一个数据转换成另一个数组:
响应式编程(Reactive Programming)入门 - 图3
而数据流也可以通过 map, 把一个数据流转换成另一个数据流:
响应式编程(Reactive Programming)入门 - 图4

filter 操作

数组还可以通过 filter,生成一个新数组,数据为过滤后的结果:
响应式编程(Reactive Programming)入门 - 图5
数据流同样也可以,通过 filter 来生成一个新数据流,数据为过滤后的结果:
响应式编程(Reactive Programming)入门 - 图6

时间维度的处理

相比于数组,由于数据流是时间概念的,所以还可以做一些和时间有关的操作
比如我生成一个新的数据流,相比于之前的数据流延迟 2s
响应式编程(Reactive Programming)入门 - 图7
或者按照时间顺序,合并两个数据流
响应式编程(Reactive Programming)入门 - 图8

数据是如何产生的

我们先看一下 React 是怎么渲染页面的:

  1. ReactDOM.createRoot(document.getElementById("root")).render(
  2. <App />
  3. );

其实就是这个 render 方法,这个方法只会在加载时执行一次,我们传入一个 <App />
<App />是什么?我们都知道,它就是一个函数,函数签名是function App():JSX.Element
这个函数没有参数,但是返回的结果每次都不一样,所以这个函数不是纯函数,说明是有副作用导致了这个函数里面的数据发生了变化,从而令这个函数返回不同的内容,那么这些副作用,就是产生数据的地方,他们是可以枚举的:

  • Event - 浏览器的一系列原生事件
  • XHR - XMLHttpRequest
  • Timers - setTimeout( ) 、setInterval( )

那么我们只要管理好这些数据变化的来源,再保证数据的流转过程,这样整个数据变化又变成了一个纯函数,即只要我们知道了来源的数据变更,就一定能推断出最终的结果。
响应式编程(Reactive Programming)入门 - 图9

一些小的应用示例

响应式编程提高了代码的抽象层级,所以你可以只关注定义了业务逻辑的那些相互依赖的事件,而非纠缠于大量的实现细节。RP 的代码往往会更加简明。

示例一

如果我想每点击一次就打印一句“Clicked”,我们一般都会这样写:

  1. document.addEventListener('click', () => console.log('Clicked!'));

很直观很简洁,没有什么问题。
如果用响应式的方式去实现,则会是这样:

  1. import { fromEvent } from 'rxjs';
  2. fromEvent(document, 'click').subscribe(() => console.log('Clicked!'));

大家可能看着没什么区别,感觉就是 api 的名称变了而已,那我稍微换一下写法:

  1. import { fromEvent } from 'rxjs';
  2. const clickEvent$ = fromEvent(document, 'click');
  3. clickEvent$.subscribe(() => console.log('Clicked!'));

这样大家可能就能看出不一样的地方了,前者是监听了一个事件,所后面所有的事情都只能在事件的回调函数里面处理。而我后者则是订阅的数据流,我所有的业务逻辑,都可以通过改变这个数据流来实现。
比如说,我开始有新需求了,我想统计我点击次数,每次点击打印的内容 + 1。
按照之前命令式的写法,我们会这样写:

  1. let count = 0;
  2. document.addEventListener('click', () => console.log(`Clicked ${++count} times`));

这下好了,我们需要维护状态了,因为我们需要保存一下之前打印的内容,从而在新打印的时候 + 1。
命令式编程就是这样,先定义数据,然后通过指令改变数据。
如何用响应式写法去做呢:

  1. import { fromEvent, scan } from 'rxjs';
  2. fromEvent(document, 'click')
  3. .pipe(scan((count) => count + 1, 0))
  4. .subscribe((count) => console.log(`Clicked ${count} times`));

看,依然是一个纯函数,我们不再需要维护一个类似 count 的状态。

继续,我们再添加新的需求:我想限制一秒内只能点击一次
换成命令式的写法该怎么做?我们又需要维护一个状态,来保存上一次点击的时间,然后在点击触发的时候判断两次时间有没有超过一秒,如果未超过,就不打印东西。

  1. let conut = 0;
  2. let rate = 1000;
  3. let lastClick = Date.now() - rate;
  4. document.addEventListener('click', () => {
  5. if (Date.now() - lastClick >= rate) {
  6. console.log(`你点击了${++conut}次`);
  7. lastClick = Date.now();
  8. }
  9. });

现在,我们已经需要维护两个状态了。而随着功能迭代越来越多,不可避免得我们要维护的状态也会越来越多。而命令式编程带来的问题就是每条命令都是离散的,我们如果想了解一段代码的逻辑,就只能跟着代码运行的过程一条一条去看。代码多了,一个代码块里就可能会参杂好几处逻辑,我们代码的可读性就会变得越来越差。

  1. import { of, map, throttleTime, fromEvent, scan, count } from 'rxjs';
  2. const clickEvent$ = fromEvent(document, 'click');
  3. clickEvent$
  4. .pipe(
  5. throttleTime(1000),
  6. scan((count) => count + 1, 0)
  7. )
  8. .subscribe((count) => console.log(`你点击了${count}次`));

而反观响应式的编程方式,基本上就是靠纯函数的组合来实现业务逻辑,读代码时也不需要安装代码执行的步骤一条一条去看,因为每个函数都是自描述的,就像是用一些单词,拼成了一条完整的句子。

示例二

实现一个简单的拖拽功能

  1. const box = document.getElementById('box');
  2. // 获取鼠标点击时在div中的相对位置
  3. box.onmousedown = (ev) => {
  4. const { x, y } = getTranslate(box);
  5. const relaX = ev.clientX - x;
  6. const relaY = ev.clientY - y;
  7. // 获取当前鼠标位置,减去与div的相对位置得到当前div应该被拖拽的位置
  8. document.onmousemove = (ev) => {
  9. setTranslate(box, { x: ev.clientX - relaX, y: ev.clientY - relaY });
  10. };
  11. document.onmouseup = (ev) => {
  12. document.onmousemove = null;
  13. document.onmouseup = null;
  14. };
  15. };

如果用响应式编程的思路,改怎么做。
首先分析一下,为了相应地移动小方块,我们需要知道的信息有:

  1. 小方块被拖拽时的初始位置
  2. 小方块在被拖拽着移动时,需要移动到的新位置

而怎么理解拖拽呢?我们可以用弹珠图来直观得表示:
响应式编程(Reactive Programming)入门 - 图10

  1. mousedown : --d----------------------d---------
  2. mousemove : -m--m-m-m--m--m---m-m-------m-m-m--
  3. mouseup : ---------u---------------------u---
  4. dragUpdate : ----m-m-m-------------------m-m----

这样我们就可以很直观的看出,drag 的数据流,就是取鼠标按下和抬起之间的 mousemove 数据流就可以了,这么我们只需要按照我们的思路操作数据流即可,而 RxJS 内置的操作符则方便了我们的操作。
http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-switchMap
http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-takeUntil

  1. const box = document.getElementById('box');
  2. const mouseDown$ = fromEvent(box, 'mousedown');
  3. const mouseMove$ = fromEvent(document, 'mousemove');
  4. const mouseUp$ = fromEvent(document, 'mouseup');
  5. const drag$ = mouseDown$.pipe(
  6. map((event: MouseEvent) => {
  7. return {
  8. pos: getTranslate(box),
  9. event,
  10. };
  11. }),
  12. switchMap((initialState) => {
  13. const initialPos = initialState.pos;
  14. const { clientX, clientY } = initialState.event;
  15. return mouseMove$.pipe(
  16. map((moveEvent: MouseEvent) => {
  17. return {
  18. x: moveEvent.clientX - clientX + initialPos.x,
  19. y: moveEvent.clientY - clientY + initialPos.y,
  20. };
  21. }),
  22. takeUntil(mouseUp$)
  23. );
  24. })
  25. );
  26. drag$.subscribe((pos) => {
  27. setTranslate(box, pos);
  28. });

添加初始延迟

需求:在拖拽的实际应用中,有时会希望有个初始延迟。

  1. import { delay, fromEvent, takeUntil, map, switchMap, of } from 'rxjs';
  2. const box = document.getElementById('box');
  3. const mouseDown$ = fromEvent(box, 'mousedown');
  4. const mouseMove$ = fromEvent(document, 'mousemove');
  5. const mouseUp$ = fromEvent(document, 'mouseup');
  6. const drag$ = mouseDown$.pipe(
  7. switchMap((event: MouseEvent) => {
  8. return of({
  9. pos: getTranslate(box),
  10. event,
  11. }).pipe(delay(200), takeUntil(mouseMove$));
  12. }),
  13. switchMap((initialState) => {
  14. const initialPos = initialState.pos;
  15. const { clientX, clientY } = initialState.event;
  16. return mouseMove$.pipe(
  17. map((moveEvent: MouseEvent) => {
  18. return {
  19. x: moveEvent.clientX - clientX + initialPos.x,
  20. y: moveEvent.clientY - clientY + initialPos.y,
  21. };
  22. }),
  23. takeUntil(mouseUp$)
  24. );
  25. })
  26. );
  27. drag$.subscribe((pos) => {
  28. setTranslate(box, pos);
  29. });

拖拽接龙

实现拖动一个方块时,其他方块会在一定的延迟后跟着拖动的方块一起动。
响应式编程(Reactive Programming)入门 - 图11

  1. import {
  2. fromEvent,
  3. map,
  4. interval,
  5. switchMap,
  6. takeUntil,
  7. mergeMap,
  8. tap,
  9. take,
  10. } from 'rxjs';
  11. const headBox = document.getElementById('head');
  12. const boxes = document.getElementsByClassName('box');
  13. const mouseDown$ = fromEvent(headBox, 'mousedown');
  14. const mouseMove$ = fromEvent(document, 'mousemove');
  15. const mouseUp$ = fromEvent(document, 'mouseup');
  16. const delayBoxes$ = interval(100).pipe(
  17. take(boxes.length),
  18. map((n) => boxes[n])
  19. );
  20. const drag$ = mouseDown$.pipe(
  21. map((e: MouseEvent) => {
  22. const pos = getTranslate(headBox);
  23. return {
  24. pos,
  25. event: e,
  26. };
  27. }),
  28. switchMap((initialState) => {
  29. const initialPos = initialState.pos;
  30. const { clientX, clientY } = initialState.event;
  31. return mouseMove$.pipe(
  32. map((moveEvent: MouseEvent) => ({
  33. x: moveEvent.clientX - clientX + initialPos.x,
  34. y: moveEvent.clientY - clientY + initialPos.y,
  35. })),
  36. takeUntil(mouseUp$)
  37. );
  38. })
  39. );
  40. drag$
  41. .pipe(
  42. mergeMap((pos) => {
  43. return delayBoxes$.pipe(
  44. tap((box) => {
  45. setTranslate(box, pos);
  46. })
  47. );
  48. })
  49. )
  50. .subscribe();

总结

命令式编程虽然建模很容易,但是只是针对你已知情况的复刻,它要求你必须知道事情的全部的原因和结果,对一个问题的中间变化,各种情况都要了如指掌。各种各样不同的情境也都需要考虑到,只有这样才能把现实问题在计算机中复刻出来。因为一个运算一个操作,它就是在你已经知道了真是的前因后果后定义出来的,你无法定义你不知道结果的操作。
而函数响应式编程它复刻的就不是某个具体的问题了,而是这个问题背后的逻辑和规律。然后根据这个逻辑和规律去重建整个系统。
有兴趣的话大家可以了解下图灵机和λ演算法。命令式编程的思想就是来源于图灵机,函数编程的思想就是来源于λ演算法。二者是等价的,都是图灵完备,只不过是解决同一个问题的不同思路而已。