深入web workers (上)
前段时间,为了优化某个有点复杂的功能,我采用了shared workers + indexDB,构建了一个高性能的多页面共享的服务。由于是第一次真正意义上的运用workers,比以前单纯的学习有更多体会,所以这里就分享出来!
各种worker概要
有三种worker:普通的worker、shared worker、service worker。(有极少的文档说有四种,多了一个 audio worker,但其实所谓的audio worker 就是 audio context,用于构建强大的音/视频处理系统)
- 普通worker,也叫专用worker,仅能被生成它的脚本所使用,全局对象this是DedicatedWorkerGlobalScope对象
- 共享worker,即sharedworker,能被不同的window页面,iframe,以及worker访问(当然要遵循同源限制),全局对象this是 SharedWorkerGlobalScope 对象。
- serviceWorker,专为PWA应用而生的worker,构建一个PWA必须要基于https,且所使用的密钥签名必须是经过CA认证的,否则你的浏览器都将认为不安全,而不会加载你的service worker。由于这个特殊性,我并没有深入了解service worker!
serviceWorker 一般作为web应用程序、浏览器和网络(如果可用)之前的代理服务器。它们旨在(除开其他方面)创建有效的离线体验,拦截网络请求,以及根据网络是否可用采取合适的行动并更新驻留在服务器上的资源。他们还将允许访问推送通知和后台同步API。
作为官方标准,3种worker当前的浏览器支持性都非常良好,可以放心使用! 呃,等一下,shared worker的支持性好像不太好哟:
不用紧张,不支持的主要是应用场景不多的移动端(移动端应用谁会开启多窗口?)和ios了,总体可以忽略(如果必须考虑ios的web端,那就要考虑回退方案了)。
如果你要实现的功能中,用户多窗口操作是很正常的;有数据库(如indexDB)、socket等链接;大量相同的可共用的变量……毫无疑问你应该使用shared worker!
我所要优化的功能就有这些特点,这就是采用shared worker的原因。
worker与主线程的交互
这里只讲专用worker 和 sharedWorker两种(service worker没有深入了解)。专用worker和sharedWorker差别很小,所以接下来先详细的把专用worker讲解清楚,再讲解sharedWorker的不同点。
专用worker和主线程的交互
示例:
// 主线程: const worker = new Worker('./worker.js') worker.onmessage = (e) => { console.log('[main receive]:',e.data ) } worker.postMessage('Hello ,this is main thread') // worker.js: addEventListener('message', function (e) { console.log('[worker receive]:', e.data ) postMessage('Hi,this is worker thread') });
- 主线程和worker 都是通过 postMessage 方法向对方发送消息。
- 双方也都是通过监听 message 事件来接收消息(上面分别有两种监听方法: addEventListener 和 onmessage ,就是个DOM Event )。
- 事件句柄的data字段的值就是发送消息时传递的内容。
运行结果:
postMessage发送 + 监听message事件接收——交互原理就这么简单,这也是唯一的交互方式!
深入消息的数据传递
数据绝对不会以引用的方式“共享”过去,要么被复制,要么被转移
拷贝
普通的数据传递,是通过拷贝来进行的。也就是发过去的是一份拷贝而非引用,如果是个对象,那么修改对象属性是互不影响的——数据能独立变化,互不影响。
和indexDB一样,拷贝是采用结构化克隆的规范的,经过测试它至少有以下副作用:
- 对象里不能含有方法,也不能拷贝方法
- 对象里不能含有symbol,也不能拷贝symbol,键为symbol的属性会被忽略
- 大多数对象的类信息会丢失。如:传递一个 obj=new Person() 收到的数据将没有 Person这个类信息。
但是如果是一个内置对象,如Number,Boolean这样的对象,则不会丢失!(注意:这一点和mdn描述的不一样) - 不可枚举的属性(enumerable为false)将会被忽略。
- 属性的可写性配置(writable配置)将丢失。
- 经过测试,所有通过 Object.defineProperties 新增的(注意 是新增的!)属性都将被忽略。
转移
拷贝在某些情况下会存在性能问题,比如拷贝一个500M的文件,肯定会花较多时间。除了拷贝还提供通过转移的方式来传递数据。
目前只有4种对象支持转移:ArrayBuffer, MessagePort, ImageBitmap 和 OffscreenCanvas。
ArrayBuffer是原始的二进制缓冲区,文件File,Blob,各种 TypedArray ,都是基于arrayBuffer的。接下来以ArrayBuffer来举例说明转移传递数据:
可以转移的数据,也可以通过拷贝来传递:
1 // 主线程: 2 const worker = new Worker('./worker.js') 3 const u8 = new Uint8Array(new ArrayBuffer(1)) // 创建一个长度为1的TypedArray u8 4 u8[0] = 1 5 worker.onmessage = (e) => { 6 const receive = e.data 7 console.log('[main receive]:', receive, 'orginal:', u8) 8 } 9 worker.postMessage(u8) // 通过普通的拷贝,将u8传给worker 10 11 12 // worker.js : 13 addEventListener('message', function (e) { 14 const receive = e.data 15 receive[0] = 9 // worker 收到u8后,改变里面的内容 16 console.log('[worker change]:',receive) 17 postMessage(receive) 18 });
console打印结果:
这个例子仅仅表明,可以转移的bufferArray也可以通过拷贝传递。注意看第二条打印:和预想中的一样,主线程和worker线程的数据会独立变化。
转移传递示例:
转移很简单,仅仅是在postMessage时,额外传入第二参数,表明要转移的对象,将上面例子稍加改造:
1 // 主线程: 2 const worker = new Worker('./worker.js') 3 const u8 = new Uint8Array(new ArrayBuffer(1)) 4 u8[0] = 1 5 worker.onmessage = (e) => { 6 const receive = e.data 7 console.log('[main receive]:', receive, 'orginal:', u8) 8 worker.postMessage('finish') 9 } 10 worker.postMessage(u8 , [u8.buffer]) // 第二个参数表示要转移的对象:注意这必须是一个数组;注意转移的是typedArray的buffer,而不是typedArray! 11 12 13 14 // worker.js : 15 let receive 16 addEventListener('message', function (e) { 17 if(e.data==='finish'){ 18 console.log('[worker after transfer]',receive) 19 return; 20 } 21 receive = e.data 22 receive[0] = 9 23 console.log('[worker change]:',receive) 24 postMessage(receive,[receive.buffer]) // 转移typedArray的buffer,typedArray长度将变成0! 25 26 }, false);
console的打印结果(注意理解两个空的typedArray,为什么是空的数组,因为buffer的“使用权”被转移了!):
把二进制数据直接转移给子线程,一旦转移,主线程就无法再使用这些二进制数据了!
sharedWorker与专用worker的差异
消息交互的差异:
sharedWorker与主线程交互和专用worker基本一样,只是多了一个port:
1 // 主线程: 2 const worker = new SharedWorker('worker.js', { name: '公共服务' }) 3 // 创建worker时,除了文件路径,还可以传入一些额外的配置:如name。 4 // worker的name有id的功能,不同页面要想共享sharedWorker,名称相同是必要条件! 5 const key = Math.random().toString(16).substring(2) 6 worker.port.postMessage(key) // 通过worker.port发送消息 7 worker.port.onmessage = e => { // 通过worker.port接收消息 8 console.log(e.data) 9 } 10 11 12 // worker.js: 13 const buf = [] 14 onconnect = function (evt) { // 当其他线程创建sharedWorker其实是向sharedWorker发了一个链接,worker会收到一个connect事件 15 const port = evt.ports[0] // connect事件的句柄中evt.ports[0]是非常重要的对象port,用来向对应线程发送消息和接收对应线程的消息 16 port.onmessage = (m) => { 17 buf.push(m.data) 18 console.log(buf) // 这个打印没看到?请看调试差异小节! 19 port.postMessage('worker receive:' + m.data) 20 } 21 }
注意看上面的注释,信息交互都是通过port进行!通常一个sharedWorker可以对应多个主线程,所以sharedWorker多了一个connect事件,通过这个事件获取各自的port与各自的主线程通信!
需要注意的是,在sharedWorker中,如果不是通过onmessage 而是通过addEventListener监听message来接收消息,必须显式调用start开启连接,否则将无法收到消息,只能发送消息。示例:
// sharedWorker内部: port.start() port.addEventListener('message',e=>{ // ... }) // 主线程内部: worker.port.start() worker.port.addEventListener('message',e=>{ // ... })
调试的差异:
在上方的例子有两处打印,第8行 主线程打印worker传过来的消息,第18行worker内部打印缓存下来的[主线程传过来的]消息。奇怪的是,当你打开开发者工具,在Console中并没有看到第18行的打印信息!
要想看到第18行打印的信息对sharedWorker进行调试,需要进行下面两步:
启动一个新的标签页,网址输入:chrome://inspect/#workers 界面如下:
点击 inspect(千万不要点击terminate,这个是结束worker的),你会看到浏览器会打开一个新窗口,新窗口的界面就是开发者工具界面(做过web移动端开发的应该很熟悉这个界面):
切换到Sources页面,就可以对SharedWorker代码进行调试了!
全局对象差异:
在主线程中,一切都很好理解,我们通过创建的worker来监听或发送消息,但在worker内部,则会发现直接调用 postMessage、onmessage等方法。
这是因为在worker内部,有一个全局对象 self,相当于globalThis(如果支持的话),相当于全局作用域下的this,直接调用相当于 self. 调用:
// 专用worker示例: globalThis.addEventListener('message', function (e) {}) self.postMessage(msgObj) // serviceWorker 示例: // 顶级作用域: this.onconnect = function(evt){}
上面的globalThis,self,this 均可以省略,类似于主线程的window!
正像前面提到过的:专用worker全局对象this是DedicatedWorkerGlobalScope对象,sharedWorker则是SharedWorkerGlobalScope 对象,这两者都是WorkerGlobalScope的派生类,所以可以这样判断:
console.log(this instanceof DedicatedWorkerGlobalScope) // 专用worker 中 true, sharedWorker和主线程中报异常错误 console.log(this instanceof SharedWorkerGlobalScope) // sharedWorker 中 true, 专用worker和主线程中报异常错误 console.log(this instanceof WorkerGlobalScope) // 专用worker和sharedWorker中都是true, 主线程中异常错误
线程生命周期差异:
专用worker很好理解:每打开一个页面就创建一个worker线程,关闭页面worker就销毁,刷新一次页面worker就经历了一次销毁和创建的过程,不同页面互不干扰。
你也可以像下面这样主动销毁一个worker:
// 专用worker内部 self.close() // 主动关闭worker连接,后续发送消息将静默失败 // 外部主线程: worker.terminate() // 或者外部这样关闭连接,注意:一旦关闭worker,worker将会被销毁,worker内的所有进行中的任务(如定时任务)都将直接销毁
一个sharedWorker可以对应多个主线程,所以:打开页面时,如果没有sharedWorker时才创建,否则就共用已经存在的sharedWorker;当只有当前页面和sharedWorker连接时,关闭当前页面sharedWorker才会被销毁,刷新当前页面sharedWorker才会先销毁后创建。
sharedWorker的连接也可以主动断开,但仅仅是断开链接,并不会销毁sharedWorker,即便是唯一使用sharedWorker的页面断开了链接。worker内部进行中的任务会正常进行,只是不能正常与主线程通信了!
// 主线程: worker.port.close() // 仅仅关闭连接 // worker内部(拿到port后): port.close() // 仅仅关闭连接
很多人喜欢像下面这样写代码,但请注意注释中的说明,:
const clients = new Set() // 用于记录所有与worker连接的线程 this.onconnect = function (c) { let port = c.ports[0] clients.add(port) // 没有任何方法知道 port 已经断开链接了(如页面关闭),所以clients只能无限添加port。这会引起内存泄露 // 在你不得不这么做,以实现诸如“向所有页面发送消息”的需求时,注意控制内存泄露的幅度: // 所有port使用同一个onmessageHandler实例和onmessageerrorHandler实例,是个不错的选择! port.onmessage = onmessageHandler port.onmessageerror = onmessageerrorHandler } function onmessageHandler(evt){} function onmessageerrorHandler(evt){}
事件和异常的交互
在面多异常和事件相关的问题时,你必须明白:worker 和 主线程是两个线程!那么就很好理解:
worker中的事件,主线程是没法监听到的,反之亦然;worker中的异常,主线程是无法感知的,反之亦然!再次强调,二者唯一的交互方式就是 postMessage和监听message事件。
// worker.js内部: // ... other code throw new Error('test error') // 这个错误无法被主线程获取,相反 你会在worker的console中看到“错误未捕获提示”的错误提示,而不是主线程的console!
主线程中可以监听worker的error事件,但请注意这到底是什么error:
worker.onerror = e=>{ // 请注意 这里主线程监听的是创建worker时的异常,而非worker创建成功后内部运行的异常 // 创建时异常:如下载worker脚本错误,路径错误,worker脚本解析错误等 }
两边都能监听 messageerror 事件,但是经过测试一直都没法触发这个事件,按官方的解释是:当接收到一个消息,但是消息的数据无法成功解析时,会触发这个事件。请注意,这里是“接收”!我尝试发送一个无法拷贝的对象(如含有function字段),但是在发送时就失败了。
可以看到 onerror 和 onmessageerror事件都是和对方无关的事件!
结语
本文深入讲解了 worker 和 sharedWorker 与 主线程的交互。
现在你已经能用两种worker做一些简单的工作了,但是在面临较复杂的工作,以及在面临webpack这样的工程中,使用worker(或sharedWorker)会面临新的问题。敬请期待:深入web workers (下),我将与你详细探讨workers在工程化中的最佳实践。