石墨表格之 Web Worker 应用实战

石墨表格之 Web Worker 应用实战

石墨表格作为一款全功能的在线表格系统,除了支持多人协作编辑以外,它还包含了复杂的数据计算:公式、条件格式、筛选、排序、复制粘贴等等。比如排序,并不仅仅是只按照数据大小排序就完成了,它还包括公式的平移,比如排序前单元格 C3 的值是 “= A3 + B3”, 排序后第3行被排到第5行,这就需要将原来 C3 单元格的值变成 “= A5 + B5”。除了公式的处理,还有行及单元格样式的替换,最终生成可序列化的内部数据结果保存到服务端,以便后续推送给其他协作者。如果是对一个2万行*100列的数据排序的话,那么它的计算量就是200万的数量级,这时候你会发现你的表格“冻结了”,“卡顿了”,甚至“网页崩溃了、脚本无响应”,如下图:


为什么?单线程、CPU 密集型任务、阻塞 UI

JavaScript 执行是单线程的,同一时间只能做一件事,无法同时运行两个 JavaScript 脚本。当浏览器在执行2万行*100列的排序的函数( CPU 密集型任务)时,浏览器是无法执行其他 的代码逻辑,也就无法立刻执行用户的点击、输入或者滚动等操作的回调函数以及页面的更新,从而导致浏览器进入了“僵死”的状态。而这些点击、输入等操作的回调函数都被加到事件队列中, 等待后面 JS 执行线程空闲时执行。

当执行 JavaScript 的时间到达一定限度时,浏览器就会给用户弹框(如上图),让用户选择是停止脚本还是允许它继续运行。

如何解决大量计算对 UI 渲染阻塞的问题

大量 CPU 密集的任务严重影响了浏览器的交互能力,进而降低了用户的体验。尽管你尽了最大的努力去优化这些计算,但往往因为复杂性的原因不能在更少的时间内完成,那么在这种情况下,有如下几种解决办法:

第一种解决办法:将这种 CPU 密集型任务移到 Server 端计算

的确可以移到 Server 端计算,等计算完成了再将结果返回给客户端。首先放在我们自己的服务器计算,增加了服务端的计算压力和成本。其次,当用户是在弱网、甚至无网的环境下,这时候等待服务端计算并返回结果和直接在本地计算,后者通常会比前者快很多。 而交由客户端计算,既可以在减轻服务端压力,也不会影响用户体验。

第二种解决办法:使用 setTimeout 拆分密集型任务

使用 setTimeout 或 setInterval 将这种密集型任务拆分成一个个小任务,JavaScript 引擎会将这些任务添加到队列中,以便腾出机会给页面渲染。这种方式的弊端:

  • 并不是所有的任务都可以被拆分成一个个小的任务,有些任务是原子的。
  • 增加了代码的复杂性,对于表格来说,表格的每个操作都可能引起大量的数据计算,需要将每个操作引起的计算拆分成一个个子任务,如果用户做了排序 —> 全选复制粘贴 —> 排序,排序还没计算完成,即便响应了用户的粘贴,粘贴还没计算完成,上一次排序工作也还未完成,又做了下一次的排序,同时需要保证子任务计算结果的依赖和顺序。
  • 需要考虑避免将任务拆分的过于碎片化,且无法保证拆分的粒度确实能提升性能并带给用户流畅的体验。

第三种解决办法:使用 Web Worker 方式

setTimeout 的方式是依赖事件循环机制实现的,但本质上仍然是在单线程上执行 JavaScript 代码的。而 Web Worker 使得网页上多线程编程成为可能。 什么是 Web Worker ?Web Worker 是一个独立的线程(独立的执行环境),这就意味着它可以完全和 UI 线程(主线程)并行的执行 js 代码,从而不会阻塞 UI,它和主线程是通过 onmessage 和 postMessage 接口进行通信的。

Web Worker 使得网页中进行多线程编程成为可能。当主线程在处理界面事件时,worker 可以在后台运行,帮你处理大量的数据计算,当计算完成,将计算结果返回给主线程,由主线程更新 DOM 元素。

Web Worker 的限制

  • 无法访问 DOM 元素、window、document

当然不能允许访问,如果两个线程都能操作 DOM,当两个线程同时操作 DOM,一个做删除操作,另一个做改变样式操作,这就冲突了,浏览器到底该如何更新 DOM。所以 Web Worker 只做相应的计算,当计算完成,把数据传给主线程,由主线程去更新 DOM。

  • 无法访问 LocalStorage

和对 dom 元素的限制一样,因为读写 LocalStorage 是同步的,一定会引起 race condition

  • Web Worker 不支持跨域
  • 无法和主线程共享内存、worker 之间也无法共享内存,所以无需保护数据

这些限制听上去都挺严格的,但是其实都是出于安全而这样设计的,想象一下,如果多个线程都在试着更新同一个元素,那简直就是个灾难。

如何迁移已有代码到 Web Worker 中

将已有代码移到 Web Worker 中是很困难的,因为你当时写模块 A 时并没有任何限制的,模块里面可能随意使用的 window、document 对象,操作了 DOM,用了其他模块 B 的内存,使得很难剥离出 A 模块使其能单独运行在 Web Worker 里。

  • 所以第一步是剥离代码,我们将表格每个操作中有关数据计算的操作都单独剥离出来,封装成纯函数,同样的输入,就会得到相同的输出,不依赖于当前环境的任何变量。
  • 因为 workers 之间是通过互相调用 sendMessage,onMessage 来通信的,也就会有很多消息注册,以及回调函数,为此我们实现了基于 Promise 的消息传输promiseWorker,调用方式如下:传入两个参数,params 是 worker 计算所需的参数,fallback 是考虑到极端情况下,当用户的浏览器不支持Web Worker、或者 Web Worker 计算超时或出错时,退到主线程调用 fallback() 重新计算。calculatedResults 就是最终 worker 计算并返回的结果。
promiseWorker(params, fallback)
    .then((calculatedResults) => {})
    .catch((error) => {})
  • workers 之间传输数据的性能比较
  • workers 之间传输数据的类型有字符串、对象,也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等二进制数据
  • 传输数据的方式有两种:一种是通过拷贝的方式,通过内部的克隆算法(Structure cloned algorithm),将主线程的数据拷贝一份,传给 worker,这样 worker 改变数据不会影响到主线程。另一种是通过转移的方式(Transferrable Objects),不做任何拷贝,而是直接将数据值的引用转移给 worker,而主线程不会再持有该数据的引用,这样也防止出现多个线程同时修改数据的问题。
  • Transferrable Objects 主要是采用二进制的存储方式,如果使用拷贝的方式,传输一个很大的二进制数据,会造成性能问题。而使用这种转移的方式来发送二进制数据,可以极大的提高传输效率,因为它不存在任何拷贝,那是否需要将我们现在的字符串或对象转换成二进制,然后使用 Transferrable Objects 的方式进行传输呢?其实转换成二进制的成本就很大,除非你的数据本身就是二进制(比如视频、文件等)。如果你的数据是 JSON,就通过简单的 JSON 传输,不要将其转换成二进制。
  • 使用 JSON.stringify 序列化所需传输的数据,Nolan Lawson 通过一系列的测试(High-performance Web Worker messages)证明先 stringify 数据再 postMessage stringified 的字符串的性能比直接 postMessage 原始数据更优。

实例

我们知道浏览器的绘制频率是 60fps,60fps 对我们来说既是压力也是动力,这意味着我们只有 16.7ms 来绘制每一帧,每一帧需要完成 Painting、Rendering、Scripting,来看下图,我们对一个2500行的石墨表格进行排序,timeline 如下:

(石墨表格未使用Web Workers)

可以看到大片区域都是黄色,集中在 Scripting 中,只有少部分的 Rendering、Painting。再来看下一张图:

我们将大部分 Scripting 的工作移到了 Web Worker 中,Scripting 的时间从9984ms减少到3650ms,虽然大部分工作还是 Scripting,但是相比没有 Web Worker,用户的排序操作的响应速度明显快了很多。大家也可以自己操作对比下:使用 Web Worker 的表格(shimo.im/sheet/QaGHHE9S),没有使用 Web Worker 的表格(shimo.im/sheet/QaGHHE9S

最终的目标是希望我们表格的代码可以分为两部分:一部分处理 UI,也就是在主线程完成 Painting、Rendering 和少部分必要的 Scripting 工作, 另一部分处理复杂的计算,而这部分工作交由 Web Workers 来完成,既使代码的分配更加清晰,增加了可测性,也保证了用户流畅的体验。

Web Worker vs Service Worker

对于近期提到很多有关 Service Worker 的,大家可能会有疑问,同样都是 Worker,它和 Web Worker 有什么联系和区别 。Service Worker 和 Web Worker 一样,是在独立一个线程运行,但是 Service Worker 提供了很多新的能力,例如离线、消息推送、后台自动更新等。对于 Web Worker,我们可以使用它来进行复杂的计算,而 Service Worker,我们可以使用它来进行本地缓存和请求转发,它吸取了 HTML5 AppCache 失败的教训,给 WebApp 提供了离线缓存能力。最近经常谈起的渐进式增强 Web 应用(Progressive Web Apps)就是基于 Service Workers 实现的 web 应用。

Web Worker 还可以做什么?

1. Angular2.0 & Web Worker

AngularJS 为了提高渲染性能,借助 Web Worker 将繁重的计算工作(例如 dirty checking)移到了 worker 中,而不阻塞主线程。

2. React & Web Worker

虚拟 DOM 是 ReactJS 的一大亮点,利用虚拟 DOM 来减少对实际 DOM 的操作从而提升性能,整个过程主要包括:

  • 用 JS 对象模拟 DOM 树
  • 利用 diff 算法比较两个虚拟 DOM 树的差异
  • 最后将这些差异应用到真正的 DOM 树上。

而 dom diff 的算法的计算量是非常大的,将 diff 移到 web worker 中去计算,再将计算结果发给主线程。

3. redux & Web Worker

将 Redux 中 reducer 计算状态的部分移到 Web Worker 中去计算。

总结

Web Worker 为前端带来了后台计算的能力,可以实现主 UI 线程与复杂计运算线程的分离,从而极大减轻了因计算量大而造成 UI 阻塞而出现的界面渲染卡、掉帧的情况,从而更大程度地的提高我们的页面性能。同时使你的程序之间的任务分配更加清晰,一个来处理 UI 界面,一个来处理复杂的计算,因此也提高了你代码的可测性。同时 Web Worker 的兼容性也非常不错,大家可以在遇到 CPU 密集型任务时尝试引入 Web Worker。

编辑于 2017-09-08

文章被以下专栏收录