用 RxJS 实现一个协同编辑的表格应用
下面这张图几乎可以代表所有软件的模型了:
输入 -> 计算过程 -> 输出
如果输入和输出都是数字,那么这个软件有可能是一个数学计算类型的软件;如果输入和输出都是字符串,那么这个软件有可能是一个文本处理类型的软件。这些都是比较纯粹的类型,可以把注意力集中在算法的实现上。再来看一个略复杂的情况,输入的个数不止一个,有用户的点击操作、用户的键盘输入、用户声音和图像的变化、来自数据库的数据以及一系列随时间和空间变化的条件,输出则是各种屏幕上的图像。
大家应该已经看出来了,最后一种软件类型就是 web 前端应用。将上面的模型具体一下:
输入 -> store -> element tree -> 输出
store 可以理解为存放数据的地方,element tree 则表示能表达视图的一棵树。element tree 到输出的复杂度已经被 HTML + CSS 等技术解决了,而 store 到 element tree 的复杂度已经被前端 MV* 框架解决了。然而从繁多复杂的输入到 store 的复杂度如何解决呢?RxJS 是一个值得尝试的选择。
第一次听说 RxJS 的时候就被它吸引了,它可以把各种各样的输入(尤其是和和时间相关的输入)通过包装、组合和转换变成成有用的数据,根据上面的软件模型,RxJS 是整个复杂度解决方案的最后一块拼图。
一个相对复杂的示例
为了能发挥出 RxJS 的威力,做了一个交互复杂一点示例,仓库地址是 https://github.com/xxapp/rxjs-excel,它是一个支持协同编辑的表格应用,支持拖拽选择单元格,编辑单元格并且支持多个用户同时编辑,可以在 github 项目首页看到实际效果图。
先来分析下需求:
- 一个基础的表格
- 根据鼠标的拖动显示单元格选区
- 当一个单元格被连续点了两次,进入编辑状态
- 当一个单元格被点了一次再按下键盘按键,也进入编辑状态
- 退出编辑状态时,更新表格内容,并把更新内容同步到其它正在编辑的用户
- 显示当前同时编辑的用户数
按照常规的思路,可以提炼出下面这些数据:
- 表格的行数和列数
- 选区的起止位置信息
- 一个表示哪个单元格正在被编辑的状态
- 表格内容,一个二维数组
- 当前同时编辑表格的用户数
如果我们能让这些数据在正确的时间表示正确的值的话,我们就可以得出正确的效果了。按照常规方法,我们可以监听各种事件,然后修改上面这些数据,重新渲染。这里 RxJS 用的是另一种思路,将软件开发比作自然水源的运输和过滤处理,RxJS 不是大自然的搬运工(更不生产水),RxJS 是大自然的流水线,只要流水线建成,水会自己流进流水线,出来的时候就是能直接饮用的水了。在软件开发中想建这样的流水线就需要考虑如何将输入转换成需要的数据,RxJS 为我们提供了建设流水线的基础能力,比如对数据源和事件的封装与流操作符。
最后我们只需要订阅这个流进行渲染就好了 stream$.subscribe(renderFn)
。
流水线
表格的行数和列数
RxJS 可以封装静态数据,如果有一天这个静态数据需要改为从后端获取,这种包装的价值就体现出来了,因为渲染代码始终从 subscribe 获取数据,不关心数据是同步的还是异步的。
const tableFrame$ = Rx.Observable.of([ROW_COUNT, COLUMN_COUNT]);
选区的起止位置信息
效果图如下,我们需要鼠标按下时的位置和鼠标移动过程中的位置,直到鼠标松开。
这个功能涉及的事件类型比较多,转换过程相对复杂一些,可以用 Marble 图来表示这个过程。
mousedown mouseup
↓switchMap ↑takeUntil
---mousemove--mousemove--mousemove--mousemove-----|-->
map(getPosition)
------pos1-------pos2-------pos3-------pos4-------|-->
distinctUntilChanged(isPositionEqual)
------pos1-------pos2------------------pos4-------|-->
scan
-------------------------------------posRange-----|-->
首先我们想让每个鼠标事件都进入流水线,RxJS 提供了 fromEvent 的包装方法将其包装成“流”,然后可以看到这里使用了很多流操作符,如 swithMap、takeUntil、map 和 scan 等等。“2 号流水线”的代码实现如下。
mousedown$
.switchMap(() => mousemove$.takeUntil(mouseup$))
.map(e => getPosition(e.target))
.distinctUntilChanged((p, q) => isPositionEqual(p, q))
.scan((acc, pos) => {
if (!acc) {
return { startRow: pos.row, startColumn: pos.column, endRow: pos.row, endColumn: pos.column };
} else {
return Object.assign(acc, { endRow: pos.row, endColumn: pos.column });
}
}, null);
单元格正在被编辑的状态
上面提到有两种方式可以进入编辑状态,所以我们要打造一个由两条分支汇聚到一起的一条流水线,一个关键的流操作符是 merge。
第一个分支是连续两次点击同一个单元格,这个单元格就会进入编辑状态。入门了 RxJS 后,实际上代码就可以解释其自身的功能的。为了正在学习的同学理解,在贴代码之前先说明一下 bufferCount 这个运算符的功能,使用 Marble:
---1-------2-------3-------4------|-->
bufferCount(2, 1)
-----------[1, 2]--[2, 3]--[3, 4]-|-->
接下来上代码:
const click$ = Rx.Observable.fromEvent(table, 'click').filter(e => e.target.nodeName === 'TD');
const doubleClick$ = click$
.bufferCount(2, 1)
.filter(([e1, e2]) => e1.target.id === e2.target.id)
.map(([e]) => e);
第二个分支是在一个单元格上按下键盘按键,进入编辑状态。操作符都不需要(需要给单元格设置 tabindex 属性):
const keyDown$ = Rx.Observable.fromEvent(table, 'keydown').filter(e => e.target.nodeName === 'TD');
两个分支都有了,让它们合并也很简单:
doubleClick$.merge(keyDown$)
表格内容
表格内容的变化也有两个途径,一个是当前用户的编辑,另一个是其它用户的编辑。
得到当前用户输入的值很简单,对于来自于其它用户输入的值,这里搭建了一个简单的 websocket 服务,当一个用户修改了一个单元格的值后,就通过服务器向其他正在编辑的用户广播更新。socket.io 这个库使用非常方便,和 RxJS 结合得也非常好。比如我们可以用下面的方法将来自其他用户的数据封装成一个流:
const socket = io();
const dataSync$ = Rx.Observable.fromEvent(socket, 'sync');
当前同时编辑表格的用户数
这个用户数是服务端维护的,也需要 websocket 来实时地将用户数推送给前端。
const socket = io();
const dataSync$ = Rx.Observable.fromEvent(socket, 'uid');
使用数据
前面说了 RxJS 解决了从输入到 store 的复杂度,那数据怎么用就和 RxJS 没关系了,这个例子使用了原生 DOM 操作将数据渲染成 UI,当然也可以使用一些前端框架来实现这个过程。
最后
学习 RxJS 需要转换思想,其中一部分来源于函数式的编程思想。就像从 jQuery 转到 angular 一样,习惯了原来的写法,这个转换的过程就会相当痛苦。在写这个例子的时候,思想就有点转变不过来,总想着搞一个状态,然后修改这个状态。除了转换思想外,另一个难点是决定什么时候用什么运算符,着实需要费一番功夫。
如果把源码中用于渲染的代码去掉,只看 RxJS 的实现部分,可以发现代码结构十分单一且一致,就像乐高积木一样,不管多么复杂的逻辑,都可以通过组合来实现,这留给我们很大的想象空间,RxJS 的魅力也在于此。