保持应用程序的流畅(smooth)和灵敏(responsive)是具有好的性能的应用程序的目标。按照 RAIL 模型,灵敏意味着响应用户行为的时间控制在 100ms 内,而流畅意味着屏幕上任何元素移动时稳定在 60 fps。“帧预算”(frame budget)对应的每一帧的时长为1000ms/60 = 16.6ms 。
RAIL 作为一个指导框架至今已经 6 年了。实际上 60fps 只是一个占位值,它表示的是用户的显示设备原生刷新率。例如,新的智能手机 有 90Hz 的屏幕 而 iPad Pro 的屏幕是 120Hz 的,这会让帧预算分别减少到 11.1ms 和 8.3ms。
浏览器的工作包括(但不限于):
同时,高低端设备的性能差距在不断增大。
为了保持应用程序的流畅,需要保证 JavaScript 代码运行连同浏览器做的其他任务(样式、布局、绘制…)的时间加起来不超出设备的帧预算。
为了保持应用程序的 灵敏,需要确保任何给定的事件处理程序不会花费超过 100ms 的时间,这样才能及时在设备屏幕上展示变化。
通常的建议是 “做代码分割(chunk your code)”,这种方式也可以被称作 “出让控制权(yield)给浏览器”。为了给浏览器一个时机来进入下一帧,你需要将代码分割成大小相似的块(chunk),这样一来,在代码块间切换时就能将控制权交还给浏览器来做渲染。为了保证不超出帧预算,你需要在足够小的块(chunk)中完成业务,而且,代码在每一帧至少要出让一次控制权。过于频繁的出让控制权的代码会导致调度任务的开销过重,以至于对应用程序整体性能产生负面影响。 “无法预测的设备性能”的存在使得没有适合所有设备的块(chunk)大小。而且当尝试对 UI 业务进行 “代码分割” 时,通过出让控制权给浏览器来分步渲染完整的 UI 会增加布局和绘制的总成本。
通过使用Web Workers,Web应用程序可以在独立于主线程的后台线程中,运行一个脚本操作。这样做的好处是可以在独立线程中执行费时的处理任务,从而允许主线程(通常是UI线程)不会因此被阻塞/放慢。
Web Worker 能帮助你的应用程序消耗更少的设备电量。通过并行使用更多的 CPU 核心,CPU 会更少的使用 “高性能” 模式,总体来说会让功耗降低。
通过传入一个独立的 JavaScript 文件路径 就可以 创建一个 Web Worker,而这个文件将在新创建的线程里加载和运行:
const worker = new Worker("./worker.js");
虽然 Worker 是 Web 的 “线程”原语 ,但由于JavaScript 没有没有锁机制,意味着依赖于隔离环境的Worker 没有权限访问其创建页面中其他变量和代码,反之,后者也无法访问 Worker 中的变量。数据通信的唯一方式就是调用 postMessage,它会将传递信息复制一份,并在接收端触发 message
事件。隔离环境也意味着 Worker 无法访问 DOM,在Worker 中也就无法更新 UI,除非付出巨大的努力。比如 AMP 的 worker-dom)。
Worker 被理解为 Actor 模型中的 Actor,每个 Actor 都可以选择是否运行在独立的线程上,而且完全保有自己操作的数据。没有其他的线程可以访问它,Actor 只会将信息传播给其他 Actor 并响应它们接收到的信息。
主线程是拥有并管理 DOM 或者说是全部 UI 的 Actor。它负责更新 UI 和捕获外界输入的事件。还会有一个 Actor 负责管理应用程序的状态。DOM Actor 将低级的输入事件转换成应用级的语义化的事件,并将这些事件传递给状态 Actor 。状态 Actor 按照接收到的事件修改状态对象,可能会使用一个状态机甚至涉及其他 Actor。一旦状态对象被更新,状态 Actor 就会发送一个更新后状态对象的拷贝到 DOM Actor。DOM Actor 就会按照新的状态对象更新 DOM 了。
如此,存在两个问题可能对UI响应造成负面影响:
1. 发送的消息需要拷贝,拷贝所花时间不仅取决于消息的大小,还取决于当前应用程序的运行情况。虽然,postMessage 足够快,但在某些场景确实不太行。比如,JSON.stringify(messagePayload)
的参数小于 10kb,即使在速度最慢的手机上,也不用担心会导致卡帧。
通过 postMessage 可以传递非常复杂的消息。其底层算法(叫做 “结构化克隆”)可以处理内部带有循环的数据结构甚至是
Map
和Set
。然而不能处理函数或者类,因为这些代码在 JavaScript 中无法跨作用域共享。通过 postMessage 传一个函数会抛出一个 错误,然而一个类被传递的话,只会被静默的转换为一个普通的 JavaScript 对象,并在此过程中丢失所有方法。
2. 将代码迁移到 Worker 中虽然可以解放主线程,但同时不得不支付通信的开销,而且 Worker 可能会在响应你的消息之前忙于执行其他代码,需要考虑这些问题来做一个平衡。
postMessage 是一种 “Fire-and-Forget” 的消息传递机制,没有请求和响应的概念。若想使用请求/响应机制,可以借助 Comlink 。Comlink 是一个底层使用 RPC 协议的库,它能帮助实现主线程和 Worker 互相访问彼此对象。使用 Comlink 的时候,不需要管 postMessage。需要注意的是,由于 postMessage 的异步性,函数返回的是 promise。Comlink 提炼了 Actor 模式 和 共享内存 两种并发模型中优秀的部分并提供给用户。Comlink 并不神奇,它仍然必须使用 RPC 协议的 postMessage。 若 postMessage 成为瓶颈,可以考虑下面的技巧:
- 将任务拆分,发送更小的信息。
- 如果消息是一个状态对象,只发送变更的部分。
- 将多条消息整合成一条。
- 尝试将你的信息转化为数字表示,转移 ArrayBuffers 而不是基于对象的消息。转移 ArrayBuffer 几乎是即时的,并且涉及所有权的适当转移:发送 JavaScript 作用域失去对过程中数据的访问权限。
传统的线程处理方式是基于共享内存的。JavaScript 中共享内存目前被限制在一个专有类型:SharedArrayBuffer (或简称 SAB)。
SAB 就像 ArrayBuffer,是线性的内存块,可以通过 Typed Array 或 DataView 来操作。如果 SAB 通过 postMessage 发送,那么另一端不会接收到数据的拷贝,而是收到完全相同的内存块的句柄。在一个线程上的任何修改 在其他所有线程上都是可见的。为了能自己创建互斥锁和其他的并发数据结构,Atomics 提供了各种类型的工具 来实现 一些原子操作 和 线程安全的等待机制。
SAB 的缺点是多方面的。首先,SAB 只是一块内存(一串字节)。无法按照熟悉的方式去处理 JavaScript 对象和数组,而且增加工程复杂度和维护复杂度。利用 buffer-backed-object 库,可以合成 JavaScript 对象,将对象的值持久化到一个底层缓冲区中。另外,WebAssembly 利用 Worker 和 SharedArrayBuffer 来支持 C++ 或 其他语言 的线程模型。WebAssembly 目前提供了实现共享内存并发最好的方案,但需要放弃 JavaScript 转而使用 WebAssembly,通常也会产出更多的二进制数据。
大多数 打包器 都会依赖模块执行打包和代码分割。使用 Web Worker 构建应用程序最主要的技巧就是将 UI 相关和纯计算逻辑的代码严格分离,转而在 Worker 中完成纯计算逻辑这些任务。此外,尽量少的依依赖同步,以便后续采用诸如回调和 async/await 等异步模式。可以尝试使用 Comlink 来将模块从主线程迁移到 Worker 中,并测算这么做是否能够提升性能。
花时间仔细分析代码中那些部分依赖 DOM 操作或只能在主线程调用的 API,通过重构删除这些依赖关系,并渐近使用并发模型。但是,不论是哪种情况,关键在确保Off-Main-Thread 架构带来的影响是可测量具体数值的。不要假设(或者估算)使用 Worker 会更快还是更慢。浏览器有时会莫名其妙以至于出现反向优化。
大多数 Web 现代开发环境都会使用打包器来显著的提升加载性能。打包器能够将多个 JavaScript 模块打包到一个文件中。然而,对于 Worker,构造函数的要求文件保持独立。将 Worker 的代码分离并编码成 Data URL 或 Blob URL 存在问题:Data URL 在 Safari 中完全无法工作,Blob URL 虽说可以,但是没有 源(origin) 和 路径 的概念,这意味路径的解析和获取无法正常使用。
在使用这些打包器开发应用程序时,使用 ES Module 是很常见的。所有的现代浏览器都支持通过来 "type = module" 运行 JavaScript 模块: