导读
阅读完此文,你会了解:
如何使用 RxJS 管理 Web 应用的数据流
如何结合 RxJS 与 WebWorker
在之前分享的文章《《走出雾霾》:时空可视化叙事的创作实践》中,我们使用哥伦布叙事工具(雷尔平台自研的时空可视化叙事创作平台,未来会上线到互联网供大家使用)创作了《走出雾霾》这个可视化叙事作品。整个作品包含了地图数据的各种更新与变换,其中的数据流也是十分复杂的。接下来我介绍一下在哥伦布中如何使用 RxJS 管理复杂 Web 应用的数据流。
遇到的问题
哥伦布的数据输入的来源有 event、ajax、WebWorker 等,数据的处理包括:用户在地图上绘制的的图形过滤器,也有用户添加的字段过滤器,还有用户点击图表时根据点击的图形进行的过滤,不同的视图之间也会根据数据的处理进行联动。这些数据所处的阶段是不同的,他们的用途也是不同的。
数据源与视图
之前的代码中有缓存数据的变量,有判断数据是否返回的变量,有获取不同数据的 Promise 的组合,有发布订阅。不同的数据获取数据的方式不同,代码写法也不同;处理异步与数据组合的代码十分复杂,阅读代码时难以理清不同数据之间的关联与处理流程。
RxJS 如何解决上面的问题
Think of RxJS as Lodash for events.
RxJS 能够将不同的数据源都统一成相同的数据流,通过不同的操作符将同步和异步的数据作为集合来处理,统一了不同数据源之间的联系;RxJS 将数据层分散的数据处理逻辑聚合在一处,提升了代码的可读性。
使用 RxJS 管理数据流
RxJS 通过 Observable 序列来处理同步和异步问题,合理的使用不同的Observable和Operator,就能将不同的数据源进行统一。
哥伦布不同数据的处理流程
为了实现数据与视图的分离,我们实现了一个 Dataset 类来将数据处理逻辑聚合,管理哥伦布的数据流。在上图中每个阶段的数据处理都是一个 Observable 来进行发布与订阅,数据流的每一部分都对应了 Dataset 中的方法。当需要不同阶段的数据时,在相应的位置使用对应的 Observable 进行订阅。BehaviorSubject 保存了发送给消费者的最新值,如果有新的订阅时,会立即从 BehaviorSubject 接收到这个最新值,所以通过 BehaviorSubject 能实现数据处理节点的缓存。
Dataset和WorkerPool的关系
使用WebWorker与RxJS结合实现数据流优化
哥伦布所有的数据处理都是在浏览器中进行的,如果数据量特别大,数据的处理就会阻塞主线程,使用户交互不流畅。WebWorker 能在单独的线程中运行代码,如果将数据处理的逻辑放在 WebWorker 中执行,将会减轻 js 主线程的负担。
WebWorker 也可以看做是一种异步数据源,RxJS 能够很好的处理异步数据。如果能将 RxJS 与 WebWorker 优雅的结合,将会极大方便 RxJS 的使用。RxJS 能使用 from,fromEvent,fromPromise 获取数据,所以可以通过 fromEvent 直接从 WebWorker 获取数据:
// js 直接使用 worker
RxJS.fromEvent(worker, 'message').subscribe((data) => {
// do something
})
但是仅仅使用单个 WebWorker 是不够的,如果这个 WebWorker 正在处理数据,还是会造成阻塞,数据不能及时响应。解决方法是需要多个 WebWorker ,用户只管向线程池加入任务,如何进行调度由线程池决定,最大化利用当前的计算资源。所以需要实现一个 WebWorker Pool 来解决这个问题,并且这个 WebWorker Pool 能够适配 RxJS。
RxJS.fromEvent 形参列表
FromEventTarget 接口声明
从 RxJS.fromEvent 参数列表中可以发现,target 是一个 FromEventTarget 类型的数据,只要实现 FromEventTarget 中一系列接口描述的数据类型,都可以作为 fromEvent 的数据源。FromEventTarget 描述的是一系列发布订阅的接口。所以,WebWorker Pool 只要实现这些接口声明中的一种,RxJS 就能从其中获取数据。
实现适配于 RxJS 的 WebWorker Pool
用到的依赖:
-
在 webpack 中,我们使用 worker-loader 来处理 WebWorker 代码;
-
event-emitter-es6 是一个符合 FromEventTarget 类型的模块,可以继承它来实现符合接口描述的线程池;
简单的 WebWorker 线程池需要实现这些功能:
-
缓存任务
-
清理任务
-
调度空闲的 Worker 处理任务
-
在线程池销毁时,杀死所有的 Worker
首先,我们有一个Worker,以实现奇偶数过滤的工作为例:
// worker.js
const task = {
// 过滤出所有的偶数
evenFilter (data) {
return data.filter(val => val % 2 === 0)
},
// 过滤所有的奇数
oddFilter (data) {
return data.filter(val => val % 2 !== 0)
}
}
function onMessage (e) {
const data = e.data
const { action, payload } = data
const result = task[action](payload.data)
// do something
self.postMessage({
action,
payload: {
timestamp: payload.timestamp,
data: result
}
})
}
self.addEventListener('message', onMessage)
接下来对 Worker 添加一些参数,比如,id 用来标记执行任务的 Worker,busy 用来标记当前的 Worker 是否正在执行任务:
// js Thread类,用来包装 Worker
class Thread {
constructor({ threadCtor, id, onMessage }) {
this.thread = new threadCtor() // 以worker.js为例
this.id = id
this.busy = false
this.onMessage = onMessage
this.thread.addEventListener('message', e => {
this._onMessage(e)
})
}
getId () {
return this.id
}
isBusy () {
return this.busy
}
postMessage (msg, transferable) {
this.busy = true
this.thread.postMessage(msg, transferable)
}
terminate () {
this.thread.terminate()
}
_onMessage (e) {
this.busy = false
this.onMessage(e)
}
}
接下来,需要一个调度 Thread的 ThreadPool,可以如下设计接口:
// js ThreadPool 类
import ee from 'event-emitter-es6'
class ThreadPool extends ee {
/**
* @param { { threadCtor: Worker, maxThreadsNumber: number } } param0
*/
constructor({ threadCtor, maxThreadsNumber }) {
super()
this.threadCtor = threadCtor // worker 构造函数
this.maxThreadsNumber = maxThreadsNumber || window.navigator.hardwareConcurrency
/**
* @type {Array<Thread>}
*/
this.threads = [] // 所有的 worker
this.tasks = [] // 任务缓存
this.init()
}
// 初始化所有的 worker 实例
init () {
for (let i = 0; i < this.maxThreadsNumber; i++) {
const thread = new Thread({
threadCtor: this.threadCtor,
id: i,
onMessage: msg => {
// 发送给RxJS的事件
this.emitSync('message', msg)
this.onMessage()
}
})
this.threads.push(thread)
}
}
// 向 tasks 中添加任务,并选择一个空闲的 worker 执行任务
postMessage (msg, transferable) {
this.tasks.push({
msg, transferable
})
const thread = this.select()
if (thread) {
const { msg, transferable } = this.tasks.splice(0, 1)[0]
thread.postMessage(msg, transferable)
}
}
/**
* 响应 worker 的 postMessage,触发 emit,RxJS 响应该事件;
* 并检查 tasks 是否为空,如果不为空,选择一个任务给此 worker 执行
*/
onMessage () {
if (this.tasks.length) {
const thread = this.select()
if (thread) {
const { msg, transferable } = this.tasks.splice(0, 1)[0]
thread.postMessage(msg, transferable)
}
}
}
// 选择一个空闲的 worker
select () {
return this.threads.filter(thread => !thread.isBusy())[0]
}
// 清理所有的任务
clearTasks () {
this.tasks = []
}
// 线程池被销毁时,杀死所有的 worker 实例
destroy () {
this.tasks = []
this.threads.forEach(thread => {
thread.terminate()
})
}
}
然后将上文中的worker.js放入 ThreadPool 中执行:
import DataWorker from './worker.js'
const pool = new ThreadPool({
threadCtor: DataWorker,
maxThreadsNumber: 2
})
发送消息给线程池处理:
// 生成数据
const initData = new Array(10000000).fill(0).map((val, idx) => idx);
pool.postMessage({
action: "evenFilter",
payload: {
timestamp: Date.now(),
data: initData,
},
});
pool.postMessage({
action: "oddFilter",
payload: {
timestamp: Date.now(),
data: initData,
},
});
pool.postMessage({
action: "evenFilter",
payload: {
timestamp: Date.now(),
data: initData,
},
});
RxJS 从线程池中获取数据:
const evenObservable$ = Rx.fromEvent(pool, 'message');
Rx.fromEvent(pool, "message").subscribe((data) => {
const { action, payload } = data.data;
console.log(Date.now() - payload.timestamp);
// ...
})
奇偶数过滤代码中的 WebWorker 的调度
在 Chrome Performance 面板中记录代码的执行过程。从上图可以看到,我们开发的适配于 RxJS 的 WebWorker Pool 能够满足当前条件下不同任务的调度,这个调度的过程对用户来说是透明的。
哥伦布中的 RxJS与 WebWorker Pool 数据流
在哥伦布中,通过结合 RxJS 和 WebWorker Pool,不仅加速了数据的计算,也将不同的数据源处理的代码按照 RxJS 的方式进行组织,使数据的处理流程变得清晰。
总结
RxJS 能够将不同数据源统一处理,帮助我们去思考如何组织数据流与视图的逻辑。