使用 RxJS 管理复杂 Web 应用的数据流的实践

1,740 阅读7分钟

导读

阅读完此文,你会了解:

如何使用 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 能够将不同数据源统一处理,帮助我们去思考如何组织数据流与视图的逻辑。

往期回顾

  1. 《打造服务B端客户的酷炫3D地图可视化产品》
  2. 《数据源与存储计算》
  3. 《地图交互与姿态控制》
  4. 《地图文字渲染》
  5. 《地图建筑渲染》
  6. 《地图建筑建模制作与输出》
  7. 《地理数据可视化》
  8. 《地图酷炫效果和原理揭秘》
  9. 《WebGL 渲染管线在 Web3D 地图中的应用》
  10. 《Web 3D地图中CPU和GPU动画的实现》
  11. 《《走出雾霾》:时空可视化叙事的创作实践》