石墨表格之 Web Worker 应用实战
小结:
1、
为什么?单线程、CPU 密集型任务、阻塞 UI
JavaScript 执行是单线程的,同一时间只能做一件事,无法同时运行两个 JavaScript 脚本。当浏览器在执行2万行*100列的排序的函数( CPU 密集型任务)时,浏览器是无法执行其他 的代码逻辑,也就无法立刻执行用户的点击、输入或者滚动等操作的回调函数以及页面的更新,从而导致浏览器进入了“僵死”的状态。而这些点击、输入等操作的回调函数都被加到事件队列中, 等待后面 JS 执行线程空闲时执行。
当执行 JavaScript 的时间到达一定限度时,浏览器就会给用户弹框(如上图),让用户选择是停止脚本还是允许它继续运行。
2、
setTimeout 的方式是依赖事件循环机制实现的,但本质上仍然是在单线程上执行 JavaScript 代码的。而 Web Worker 使得网页上多线程编程成为可能。 什么是 Web Worker ?Web Worker 是一个独立的线程(独立的执行环境),这就意味着它可以完全和 UI 线程(主线程)并行的执行 js 代码,从而不会阻塞 UI,它和主线程是通过 onmessage 和 postMessage 接口进行通信的。
Web Worker 使得网页中进行多线程编程成为可能。当主线程在处理界面事件时,worker 可以在后台运行,帮你处理大量的数据计算,当计算完成,将计算结果返回给主线程,由主线程更新 DOM 元素。
石墨表格之 Web Worker 应用实战 - 知乎 https://zhuanlan.zhihu.com/p/29165800
石墨表格作为一款全功能的在线表格系统,除了支持多人协作编辑以外,它还包含了复杂的数据计算:公式、条件格式、筛选、排序、复制粘贴等等。比如排序,并不仅仅是只按照数据大小排序就完成了,它还包括公式的平移,比如排序前单元格 C3 的值是 “= A3 + B3”, 排序后第3行被排到第5行,这就需要将原来 C3 单元格的值变成 “= A5 + B5”。除了公式的处理,还有行及单元格样式的替换,最终生成可序列化的内部数据结果保存到服务端,以便后续推送给其他协作者。如果是对一个2万行*100列的数据排序的话,那么它的计算量就是200万的数量级,这时候你会发现你的表格“冻结了”,“卡顿了”,甚至“网页崩溃了、脚本无响应”,如下图:
![](https://img2023.cnblogs.com/blog/1006507/202304/1006507-20230414010036379-1384832989.png)
为什么?单线程、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 如下:
![](https://img2023.cnblogs.com/blog/1006507/202304/1006507-20230414010035686-702748691.webp)
(石墨表格未使用Web Workers)
可以看到大片区域都是黄色,集中在 Scripting 中,只有少部分的 Rendering、Painting。再来看下一张图:
![](https://img2023.cnblogs.com/blog/1006507/202304/1006507-20230414010036380-1631356645.png)
我们将大部分 Scripting 的工作移到了 Web Worker 中,Scripting 的时间从9984ms减少到3650ms,虽然大部分工作还是 Scripting,但是相比没有 Web Worker,用户的排序操作的响应速度明显快了很多。大家也可以自己操作对比下:使用 Web Worker 的表格(https://shimo.im/sheet/QaGHHE9SHO03Vrey/zsM6G?r=V2ODR),没有使用 Web Worker 的表格(https://shimo.im/sheet/QaGHHE9SHO03Vrey/zsM6G?r=V2ODR&disableWorker)
最终的目标是希望我们表格的代码可以分为两部分:一部分处理 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 还可以做什么?
AngularJS 为了提高渲染性能,借助 Web Worker 将繁重的计算工作(例如 dirty checking)移到了 worker 中,而不阻塞主线程。
虚拟 DOM 是 ReactJS 的一大亮点,利用虚拟 DOM 来减少对实际 DOM 的操作从而提升性能,整个过程主要包括:
- 用 JS 对象模拟 DOM 树
- 利用 diff 算法比较两个虚拟 DOM 树的差异
- 最后将这些差异应用到真正的 DOM 树上。
而 dom diff 的算法的计算量是非常大的,将 diff 移到 web worker 中去计算,再将计算结果发给主线程。
将 Redux 中 reducer 计算状态的部分移到 Web Worker 中去计算。
总结
Web Worker 为前端带来了后台计算的能力,可以实现主 UI 线程与复杂计运算线程的分离,从而极大减轻了因计算量大而造成 UI 阻塞而出现的界面渲染卡、掉帧的情况,从而更大程度地的提高我们的页面性能。同时使你的程序之间的任务分配更加清晰,一个来处理 UI 界面,一个来处理复杂的计算,因此也提高了你代码的可测性。同时 Web Worker 的兼容性也非常不错,大家可以在遇到 CPU 密集型任务时尝试引入 Web Worker。
![](https://img2023.cnblogs.com/blog/1006507/202304/1006507-20230414010035691-958896798.webp)