前端开发中的独立线程 — WebWorker
由于设计原因,长期以来浏览器中 JS 都是单线程工作的,我们通过 EventLoop 驱动异步事件完成工作。然而随着前端页面越来越复杂,有些应用不可避免的要在前端执行大量的计算,这种情况会较长一段时间占用主线程,用户会感觉到明显的页面卡顿。在这种场景下我们可以使用 WebWorker 来解决问题。
使用 WebWorker
在浏览器中 new WebWorker 即可创建一个 WebWorker:
// main.js const worker = new Worker("./worker.js");复制代码
worker.js 中的代码是运行在 worker 线程中的,worker 线程和主线程之间使用 postMessage 进行消息传递:
// main.js worker.onmessage = (e) => { console.log(e.data); }; worker.postMessage('hello worker');复制代码
// worker.js onmessage = (e) => { console.log(e.data); postMessage("hi from worker"); };复制代码
这里需要注意 worker 是运行在独立的线程中,worker 线程的全局上下文不是 window 而是 self,因此上面的程序与下面这段完全相同:
// worker.js self.onmessage = (e) => { console.log(e.data); self.postMessage("hi from worker"); };复制代码
由于不能访问 window,因此 window 上的 API 也都无法访问,不过有很多 API 在 worker 中有对应的实现,可以查看这个链接。这里需要重点关注的就是 DOM 相关 API 不能在 worker 中使用,这与 worker 的设计理念有关。worker 是用来做计算工作释放主线程压力从而提升用户交互体验的,基本的用户交互还是应该由主线程完成。
???? 也有一些通过特殊方式实现 worker 访问 dom 的库可供参考,但是非特殊场景还是不建议违背 worker 的设计理念 [](https://github.com/AshleyScirra/via.js) [](https://github.com/ampproject/worker-dom)
另一个需要注意的问题是在 worker 和主线程之间的数据是通过复制的方式传递的,看下面的例子:
// main.js const worker = new Worker('./worker.js')' const data = { name: "tom", age: 10 }; worker.postMessage(data); worker.onmessage = () => { console.log('data in main', data); } // worker.js onmessage = (e) => { e.data.name = 'jerry'; console.log('data in worker', e.data); postMessage('changed'); } // output: data in worker { name: "jerry", age: 10 } // data in main { name: "tom", age: 10 }复制代码
可以看到 worker 中的是一份全新的数据,worker 线程不会修改主线程数据。复制的好处就在于线程之间是数据独立的,因此虽然 worker 是多线程操作,但是主线程的数据不会被其他线程修改,不会出现传统多线程编程的数据竞争问题。但是复制的缺点也是很明显的,复制过程会经历数据的序列化和反序列化,数据量大的时候会带来较多的额外开销。
可转移对象
浏览器中有一种可转移对象(Transferable objects),这种对象可以进行所有权转移,当数据被转移之后,原始的引用就不可用了,与 rust 中的机制很类似。浏览器中支持的可转移对象有以下几种:
ArrayBuffer
MessagePort
ReadableStream
WritableStream
TransformStream
AudioData
ImageBitmap
VideoFrame
OffscreenCanvas
而在 WebWorker 中,postMessage 方法支持第二个参数,如果传递的数据中存在可转移对象,第二个参数可以控制可转移对象是以复制方式传递还是转移方式传递,看下面例子:
// ========= 不使用转移 ========= // main.js const worker = new Worker("./worker.js"); const data = new Uint8Array([2, 4, 6, 32, 6, 34,5]); worker.postMessage(data); worker.onmessage = (e) => { console.log('data in main', data); }; // worker.js onmessage = (e) => { e.data[0] = 200; console.log('data in worker', e.data); postMessage('changed'); } // output: data in worker Uint8Array(7) [200, 4, 6, 32, 6, 34, 5, buffer: ArrayBuffer(7), byteLength: 7, byteOffset: 0, length: 7] // data in main Uint8Array(7) [2, 4, 6, 32, 6, 34, 5, buffer: ArrayBuffer(7), byteLength: 7, byteOffset: 0, length: 7] // ========= 使用转移 ========= // main.js const worker = new Worker("./worker.js"); const data = new Uint8Array([2, 4, 6, 32, 6, 34,5]); worker.postMessage(data, [data.buffer]); worker.onmessage = (e) => { console.log('data in main', data); }; // worker.js onmessage = (e) => { e.data[0] = 200; console.log('data in worker', e.data); postMessage('changed'); } // output: data in worker Uint8Array(7) [200, 4, 6, 32, 6, 34, 5, buffer: ArrayBuffer(7), byteLength: 7, byteOffset: 0, length: 7] // data in main Uint8Array [buffer: ArrayBuffer(0), byteLength: 0, byteOffset: 0, length: 0]复制代码
在不使用转移时,传入 Worker 中的 data 是复制的,主线程和 worker 各一份数据,二者互不影响。给 postMessage 添加第二个参数 [data.buffer]
后,数据变成了转移行为,原始的 Uint8Array 中 buffer 就不存在了,打印出来的是空的 buffer。
???? 注意 ArrayBuffer 是可转移对象,Uint8Array 不是,因此这里要传入 buffer,只有 buffer 可以转移所有权。
SharedArrayBuffer
除了使用可转移对象,有时确实需要在两个线程之间共享对象,这时可以使用 SharedArrayBuffer,SharedArrayBuffer 创建了一块可以共享的内存。与其他编程语言一样,多线程访问共享数据会出现数据竞争问题,因此浏览器还有一个 Atomics 对象提供原子操作能力。SharedArrayBuffer 和 Atomics 补充了 WebWorker 短板,使我们在浏览器中可以实现完整的多线程编程能力。这一套解决方案成本比较重,不作为常规前端项目的选型方案,在某些特定场景下要求更高性能时可以考虑使用。关于 SharedArrayBuffer 和 Atomics 本文不展开,后面有机会专门写一篇。
其它类型 Worker
除了 WebWorker,浏览器中还有几种也称为 worker 或类似 worker 的东西。
SharedWorker
SharedWorker 如同名字,它可以共享给多个页面使用,与其他 worker 一样,SharedWorker 也是运行在一个独立的线程中,使用 SharedWorker 可以和多个页面通信(需要遵守同源策略)。
// main.js const worker = new SharedWorker("./worker.js"); worker.port.start(); worker.port.onmessage = (e) => { console.log("from worker", e.data); }; worker.port.postMessage("to worker"); // worker.js onconnect = function (e) { const port = e.ports[0]; port.onmessage = function (e) { console.log(e.data); port.postMessage("success"); }; };复制代码
这里需要用 port 来和 worker 交互,从多个页面创建的 SharedWorker 都是同一个,我们可以在 chrome://inspect/#workers 中调试 SharedWorker。
ServiceWorker
ServiceWorker 是一个代理服务器,它可以拦截 web 应用的网络请求,根据网络情况进行处理,能够给 web 应用带来离线的用户体验。ServiceWorker 是实现 PWA 的核心技术,关于 ServiceWorker 也不在本文展开,只需要知道它也是运行在独立的线程中就可以了。
Worklet
Worklet 是一种轻量的 worker,它提供给用户访问底层渲染管线的能力。目前有四种 worklet:
作者:丨隋堤倦客丨
链接:https://juejin.cn/post/7026600608681443359