前段时间,为了优化某个有点复杂的功能,我采用了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和主线程的交互

示例:

  1. // 主线程:
  2. const worker = new Worker('./worker.js')
  3. worker.onmessage = (e) => {
  4. console.log('[main receive]:',e.data )
  5. }
  6. worker.postMessage('Hello ,this is main thread')
  7.  
  8. // worker.js:
  9. addEventListener('message', function (e) {
  10. console.log('[worker receive]:', e.data )
  11. postMessage('Hi,this is worker thread')
  12. });
  1. 主线程和worker 都是通过 postMessage 方法向对方发送消息。
  2. 双方也都是通过监听 message 事件来接收消息(上面分别有两种监听方法: addEventListener 和 onmessage ,就是个DOM Event )。
  3. 事件句柄的data字段的值就是发送消息时传递的内容。

运行结果:

postMessage发送 + 监听message事件接收——交互原理就这么简单,这也是唯一的交互方式!

深入消息的数据传递

数据绝对不会以引用的方式“共享”过去,要么被复制,要么被转移

拷贝

普通的数据传递,是通过拷贝来进行的。也就是发过去的是一份拷贝而非引用,如果是个对象,那么修改对象属性是互不影响的——数据能独立变化,互不影响。

和indexDB一样,拷贝是采用结构化克隆的规范的,经过测试它至少有以下副作用:

  • 对象里不能含有方法,也不能拷贝方法
  • 对象里不能含有symbol,也不能拷贝symbol,键为symbol的属性会被忽略
  • 大多数对象的类信息会丢失。如:传递一个 obj=new Person() 收到的数据将没有 Person这个类信息。
    但是如果是一个内置对象,如Number,Boolean这样的对象,则不会丢失!(注意:这一点和mdn描述的不一样)
  • 不可枚举的属性(enumerable为false)将会被忽略。
  • 属性的可写性配置(writable配置)将丢失。
  • 经过测试,所有通过 Object.defineProperties 新增的(注意 是新增的!)属性都将被忽略。

转移

拷贝在某些情况下会存在性能问题,比如拷贝一个500M的文件,肯定会花较多时间。除了拷贝还提供通过转移的方式来传递数据。

目前只有4种对象支持转移:ArrayBuffer, MessagePort, ImageBitmapOffscreenCanvas

ArrayBuffer是原始的二进制缓冲区,文件File,Blob,各种 TypedArray ,都是基于arrayBuffer的。接下来以ArrayBuffer来举例说明转移传递数据:

可以转移的数据,也可以通过拷贝来传递:

  1. 1 // 主线程:
  2. 2 const worker = new Worker('./worker.js')
  3. 3 const u8 = new Uint8Array(new ArrayBuffer(1)) // 创建一个长度为1的TypedArray u8
  4. 4 u8[0] = 1
  5. 5 worker.onmessage = (e) => {
  6. 6 const receive = e.data
  7. 7 console.log('[main receive]:', receive, 'orginal:', u8)
  8. 8 }
  9. 9 worker.postMessage(u8) // 通过普通的拷贝,将u8传给worker
  10. 10
  11. 11
  12. 12 // worker.js :
  13. 13 addEventListener('message', function (e) {
  14. 14 const receive = e.data
  15. 15 receive[0] = 9 // worker 收到u8后,改变里面的内容
  16. 16 console.log('[worker change]:',receive)
  17. 17 postMessage(receive)
  18. 18 });

console打印结果:

这个例子仅仅表明,可以转移的bufferArray也可以通过拷贝传递。注意看第二条打印:和预想中的一样,主线程和worker线程的数据会独立变化。

转移传递示例:

转移很简单,仅仅是在postMessage时,额外传入第二参数,表明要转移的对象,将上面例子稍加改造:

  1. 1 // 主线程:
  2. 2 const worker = new Worker('./worker.js')
  3. 3 const u8 = new Uint8Array(new ArrayBuffer(1))
  4. 4 u8[0] = 1
  5. 5 worker.onmessage = (e) => {
  6. 6 const receive = e.data
  7. 7 console.log('[main receive]:', receive, 'orginal:', u8)
  8. 8 worker.postMessage('finish')
  9. 9 }
  10. 10 worker.postMessage(u8 , [u8.buffer]) // 第二个参数表示要转移的对象:注意这必须是一个数组;注意转移的是typedArray的buffer,而不是typedArray!
  11. 11
  12. 12
  13. 13
  14. 14 // worker.js :
  15. 15 let receive
  16. 16 addEventListener('message', function (e) {
  17. 17 if(e.data==='finish'){
  18. 18 console.log('[worker after transfer]',receive)
  19. 19 return;
  20. 20 }
  21. 21 receive = e.data
  22. 22 receive[0] = 9
  23. 23 console.log('[worker change]:',receive)
  24. 24 postMessage(receive,[receive.buffer]) // 转移typedArray的buffer,typedArray长度将变成0!
  25. 25
  26. 26 }, false);

console的打印结果(注意理解两个空的typedArray,为什么是空的数组,因为buffer的“使用权”被转移了!):

把二进制数据直接转移给子线程,一旦转移,主线程就无法再使用这些二进制数据了!

sharedWorker与专用worker的差异

消息交互的差异:

sharedWorker与主线程交互和专用worker基本一样,只是多了一个port:

  1. 1 // 主线程:
  2. 2 const worker = new SharedWorker('worker.js', { name: '公共服务' })
  3. 3 // 创建worker时,除了文件路径,还可以传入一些额外的配置:如name。
  4. 4 // worker的name有id的功能,不同页面要想共享sharedWorker,名称相同是必要条件!
  5. 5 const key = Math.random().toString(16).substring(2)
  6. 6 worker.port.postMessage(key) // 通过worker.port发送消息
  7. 7 worker.port.onmessage = e => { // 通过worker.port接收消息
  8. 8 console.log(e.data)
  9. 9 }
  10. 10
  11. 11
  12. 12 // worker.js:
  13. 13 const buf = []
  14. 14 onconnect = function (evt) { // 当其他线程创建sharedWorker其实是向sharedWorker发了一个链接,worker会收到一个connect事件
  15. 15 const port = evt.ports[0] // connect事件的句柄中evt.ports[0]是非常重要的对象port,用来向对应线程发送消息和接收对应线程的消息
  16. 16 port.onmessage = (m) => {
  17. 17 buf.push(m.data)
  18. 18 console.log(buf) // 这个打印没看到?请看调试差异小节!
  19. 19 port.postMessage('worker receive:' + m.data)
  20. 20 }
  21. 21 }

注意看上面的注释,信息交互都是通过port进行!通常一个sharedWorker可以对应多个主线程,所以sharedWorker多了一个connect事件,通过这个事件获取各自的port与各自的主线程通信!

需要注意的是,在sharedWorker中,如果不是通过onmessage 而是通过addEventListener监听message来接收消息,必须显式调用start开启连接,否则将无法收到消息,只能发送消息。示例:

  1. // sharedWorker内部:
  2. port.start()
  3. port.addEventListener('message',e=>{
  4. // ...
  5. })
  6.  
  7. // 主线程内部:
  8. worker.port.start()
  9. worker.port.addEventListener('message',e=>{
  10. // ...
  11. })

调试的差异:

在上方的例子有两处打印,第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. 调用:

  1. // 专用worker示例:
  2. globalThis.addEventListener('message', function (e) {})
  3. self.postMessage(msgObj)
  4.  
  5. // serviceWorker 示例:
  6. // 顶级作用域:
  7. this.onconnect = function(evt){}

上面的globalThis,self,this 均可以省略,类似于主线程的window!

正像前面提到过的:专用worker全局对象this是DedicatedWorkerGlobalScope对象,sharedWorker则是SharedWorkerGlobalScope 对象,这两者都是WorkerGlobalScope的派生类,所以可以这样判断:

  1. console.log(this instanceof DedicatedWorkerGlobalScope) // 专用worker 中 true, sharedWorker和主线程中报异常错误
  2. console.log(this instanceof SharedWorkerGlobalScope) // sharedWorker 中 true, 专用worker和主线程中报异常错误
  3. console.log(this instanceof WorkerGlobalScope) // 专用worker和sharedWorker中都是true, 主线程中异常错误

线程生命周期差异:

专用worker很好理解:每打开一个页面就创建一个worker线程,关闭页面worker就销毁,刷新一次页面worker就经历了一次销毁和创建的过程,不同页面互不干扰。

你也可以像下面这样主动销毁一个worker:

  1. // 专用worker内部
  2. self.close() // 主动关闭worker连接,后续发送消息将静默失败
  3.  
  4. // 外部主线程:
  5. worker.terminate() // 或者外部这样关闭连接,注意:一旦关闭worker,worker将会被销毁,worker内的所有进行中的任务(如定时任务)都将直接销毁

一个sharedWorker可以对应多个主线程,所以:打开页面时,如果没有sharedWorker时才创建,否则就共用已经存在的sharedWorker;当只有当前页面和sharedWorker连接时,关闭当前页面sharedWorker才会被销毁,刷新当前页面sharedWorker才会先销毁后创建。

sharedWorker的连接也可以主动断开,但仅仅是断开链接,并不会销毁sharedWorker,即便是唯一使用sharedWorker的页面断开了链接。worker内部进行中的任务会正常进行,只是不能正常与主线程通信了!

  1. // 主线程:
  2. worker.port.close() // 仅仅关闭连接
  3.  
  4. // worker内部(拿到port后):
  5. port.close() // 仅仅关闭连接

很多人喜欢像下面这样写代码,但请注意注释中的说明,:

  1. const clients = new Set() // 用于记录所有与worker连接的线程
  2. this.onconnect = function (c) {
  3. let port = c.ports[0]
  4. clients.add(port) // 没有任何方法知道 port 已经断开链接了(如页面关闭),所以clients只能无限添加port。这会引起内存泄露
  5. // 在你不得不这么做,以实现诸如“向所有页面发送消息”的需求时,注意控制内存泄露的幅度:
  6. // 所有port使用同一个onmessageHandler实例和onmessageerrorHandler实例,是个不错的选择!
  7.  
  8. port.onmessage = onmessageHandler
  9. port.onmessageerror = onmessageerrorHandler
  10. }
  11.  
  12. function onmessageHandler(evt){}
  13. function onmessageerrorHandler(evt){}

事件和异常的交互

在面多异常和事件相关的问题时,你必须明白:worker 和 主线程是两个线程!那么就很好理解:
worker中的事件,主线程是没法监听到的,反之亦然;worker中的异常,主线程是无法感知的,反之亦然!再次强调,二者唯一的交互方式就是 postMessage和监听message事件。

  1. // worker.js内部:
  2.  
  3. // ... other code
  4. throw new Error('test error')
  5. // 这个错误无法被主线程获取,相反 你会在worker的console中看到“错误未捕获提示”的错误提示,而不是主线程的console!

主线程中可以监听worker的error事件,但请注意这到底是什么error:

  1. worker.onerror = e=>{
  2. // 请注意 这里主线程监听的是创建worker时的异常,而非worker创建成功后内部运行的异常
  3. // 创建时异常:如下载worker脚本错误,路径错误,worker脚本解析错误等
  4. }

两边都能监听 messageerror 事件,但是经过测试一直都没法触发这个事件,按官方的解释是:当接收到一个消息,但是消息的数据无法成功解析时,会触发这个事件。请注意,这里是“接收”!我尝试发送一个无法拷贝的对象(如含有function字段),但是在发送时就失败了。

可以看到  onerror 和 onmessageerror事件都是和对方无关的事件!

结语

本文深入讲解了 worker 和 sharedWorker  与 主线程的交互。

现在你已经能用两种worker做一些简单的工作了,但是在面临较复杂的工作,以及在面临webpack这样的工程中,使用worker(或sharedWorker)会面临新的问题。敬请期待:深入web workers (下),我将与你详细探讨workers在工程化中的最佳实践。

深入web workers (上)的更多相关文章

  1. Web Workers

    在 Web Workers 中使用 postMessage 和 onmessage 首先,需要在客户端页面的 JavaScript 代码中 new 一个 Worker 实例出来,参数是需要在另一个线程 ...

  2. html5 Web Workers

    虽然在JavaScript中有setInterval和setTimeout函数使javaScript看起来好像使多线程执行,单实际上JavaScript使单线程的,一次只能做一件事情(关于JavaSc ...

  3. 3D拓扑自动布局之Web Workers篇

    2D拓扑的应用在电信网管和电力SCADA领域早已习以为常了,随着OpenGL特别是WebGL技术的普及,3D方式的数据可视化也慢慢从佛殿神堂步入了寻常百姓家,似乎和最近高档会所被整改为普通茶馆是一样的 ...

  4. HTML5 Web Workers来加速您的移动Web应用

    一直以来,Web 应用程序被局限在一个单线程世界中.这的确限制了开发人员在他们的代码中的作为,因为任何太复杂的东西都存在冻结应用程序 UI 的风险.通过将多线程引入 Web 应用程… 在本文中,您将使 ...

  5. (92)Wangdao.com_第二十五天_线程机制_H5 Web Workers 分线程任务_事件 Event

    浏览器内核 支撑浏览器运行的最核心的程序 IE 浏览器内核            Trident内核,也是俗称的IE内核Chrome 浏览器内核            统称为 Chromium 内核或 ...

  6. 通过使用Web Workers,Web应用程序可以在独立于主线程的后台线程中,运行一个脚本操作。这样做的好处是可以在独立线程中执行费时的处理任务,从而允许主线程(通常是UI线程)不会因此被阻塞/放慢。

    Web Workers API - Web API 接口参考 | MDNhttps://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API ...

  7. JavaScript是如何工作的:Web Workers的构建块 + 5个使用他们的场景

    摘要: 理解Web Workers. 原文:JavaScript是如何工作的:Web Workers的构建块 + 5个使用他们的场景 作者:前端小智 Fundebug经授权转载,版权归原作者所有. 这 ...

  8. WijmoJS 使用Web Workers技术,让前端 PDF 导出效率更高效

    概述 Web Workers是一种Web标准技术,允许在后台线程中执行脚本处理. WijmoJS 的2018v3版本引入了Web Workers技术,以便在生成PDF时提高应用程序的运行速度. 一般来 ...

  9. Web Workers文档

    Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法.线程可以执行任务而不干扰用户界面.此外,他们可以使用XMLHttpRequest执行 I/O  (尽管responseXML和 ...

随机推荐

  1. 制作u盘启动盘

    制作u盘启动盘 如果是想要制作 windows 系统启动盘,windows 官网提供途径,这里不在赘述. 以下讨论制作 centos 系统启动盘,需要 centos 系统文件,开源,可从官网下载得到. ...

  2. (转)DBC文件格式解析

    Dbc是描述CAN通信报文和信号信息的文件,用Vector Candb++打开. 用记事本打开后,可以看到固定格式,下面的博客做了详细的解析: https://blog.csdn.net/weixin ...

  3. uni-app引入iconfont字体图标

    1 首先进入你的iconfont项目 很好, 看见圈圈的吗 , 我说蓝色的,记住了,选到这个 ,然后点击下载本地项目, 解压完就是这个了 ,然后把 圈起来的放到你的项目文件里面 ,记得引入的时候路径别 ...

  4. Flutter 1.22 正式发布

    支持iOS 14和Android 11,新的i18n和l10n支持,可用于生产的Google Maps和WebView插件,新的App Size工具等等! 作者:Chris Sells 原文:http ...

  5. Java 客户端操作 FastDFS 实现文件上传下载替换删除

    FastDFS 的作者余庆先生已经为我们开发好了 Java 对应的 SDK.这里需要解释一下:作者余庆并没有及时更新最新的 Java SDK 至 Maven 中央仓库,目前中央仓库最新版仍旧是 1.2 ...

  6. [HAOI 2017]八纵八横

    线段树分治+线形基. 线段树分治是个锤子?? 以时间轴构建线段树,把每个环以"对线段树产生影响的时间区间"的形式加入线段树即可. #include<bits/stdc++.h ...

  7. LR之Oracle 2tier协议录制Oracle脚本

    在一次测试中,需用到sql去查询Oracle数据,并去使用改数据时,查阅各种资料终于实现LoadRunner对Oracle数据库进行操作,分享给大家,也与大家共同进步~   同时也可用Loadrunn ...

  8. 多测师讲解第一个月 _综合面试题_高级讲师肖sir

    第一个月综合面试题 1.  冒烟测试是什么意思?  对主要的用例测试 2.你们公司的项目流程是什么? 3.你们公司的bug分几个级别?  4个 4.你对外键是怎么理解的? 你会使用外键吗?给一个表添加 ...

  9. 多测师讲解python函数 _zip_高级讲师肖sir

    # zip函数 #zip() 函数用于将可迭代的对象作为参数,将对象中对应的元素打包成一个个元组,然后返回由这些元组组成的对象,这样做的好处是节约了不少的内存.1.使用zip讲两个列表打印出来的结果是 ...

  10. 学习go语言并完成第一个作品

    之前有使用C#写一个Windows下的发送邮件的命令行工具,方便一些脚本出现异常时向我的邮箱发送邮件提醒.但这并没有被我频繁使用,因为我的有些脚本还是在linux下面运行,因此我又有一篇文章用linu ...