WebWorkers,js单线程的助手,性能优化的利器

140 阅读9分钟

什么是 Web Worker

众所周知,js是单线程的,那么在进行大量的操作和运算的时候,可能会卡顿,影响性能。

Web Worker 是一项 HTML5 新特性,Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。就像之前你的奶茶店只有你一个人,什么事情都需要你自己亲历亲为,现在你可以请一个帮手了,你可以把那些花时间的杂活比如拖地,制冰,烧水。。。让他做,你自己去做主要的流程的事情。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。这意味着,即使脚本执行了很长时间,Web 应用程序的 UI 仍然可以保持响应。

体验到有帮手的好处了吧?而且这个帮手做事情非常专注,他做事情不会被其他人打扰,而且人还老实,不会偷懒,做完事情也不主动休息,简直是踏实肯干的劳动模范!!!

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

Web Worker 分为两种类型,专用线程(Dedicated Web Worker) 和共享线程(Shared Web Worker)。专用线程仅能被创建它的脚本所使用(一个专用线程对应一个主线程),而共享线程能够在不同的脚本中使用(一个共享线程对应多个主线程

先看专用worker

如何使用

  1. 新建一个 Worker 线程

    判断浏览器受否支持

    if (window.Worker) {
    // ...
    }
    

    使用new命令,调用Worker()构造函数来创建一个线程

    let worker = new Worker('work.js');
    

    Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。但是 Worker 不能读取本地文件,所以这个脚本必须来自网络。浏览器会创建一个新的后台线程,加载该 URL 指定的脚本,并在该线程中执行。如果下载没有成功(比如404错误),Worker 就会失败。 image.png

    image.png

    image.png

  2. 线程通信

    Worker 线程和主线程都通过 postMessage() 方法发送消息,通过 onmessage 事件接收消息

    • 主线程给 Worker 发消息

      使用 worker.postMessage(params) 方法

      params就是主线程传给 Worker 的数据。它可以是各种数据类型(除了Error 以及 Function 对象,DOM 节点),包括二进制数据。

      worker.postMessage('Hello World');
      worker.postMessage({method: 'speak', args: ['你是大帅逼']});
      
    • 主线程如何接收 Worker 发的消息呢?

      使用 worker.onmessage 指定监听函数接收子线程发回来的消息

       worker.onmessage = function (event) {
       console.log('Received message ' + event.data);
           doSomething();
       }
      
       function doSomething() {
       // 收到消息 执行任务
           worker.postMessage('gogogo!');
       }
      
    • Worker 接收主线程发得消息

      self.addEventListener('message', function (e) {
          console.log('收到消息:' + e.data);
      }, false);
      
    • Worker 给主线程发消息

      self.addEventListener('message', function (e) {
          console.log('收到消息:' + e.data);
          // do someThing
      
          // 将结果发送回主线程  
          self.postMessage(result);  
      }, false);
      

      在 Worker 线程中,self 和 this 都代表子线程的全局对象。

      image.png

      对于监听 message 事件,以下的几种写法是等效的

      this.addEventListener('message', function (e) {
          this.postMessage('给主线程发的消息');
      }, false);
      
      addEventListener('message', function (e) {
          postMessage('给主线程发的消息');
      }, false);
      
      
      self.addEventListener('message', function (e) {
          self.postMessage('给主线程发的消息');
      })
      
      onmessage = function (e) {
          postMessage('给主线程发的消息');
      }
      
      
    • 这个帮手太老实,所以他做完工作得主动让他下班休息(做完直接让他下班或者给他打电话 让他知道 你做完了就自己下班)

      // 两种关闭方式都行
      worker.terminate(); // 主线程关闭
      self.close() // worker主动关闭
      
    • worker 错误处理

      主线程和worker线程都可以监听 error 时间

      // 主线程监听错误
      worker.onerror(function (event) {
      console.log(event);
      })
      // 或者
      worker.addEventListener('error', function (event) {
          console.log(event);
       });
      
      
      // 子线程监听错误
      onerror = function (event) {
          console.log(event);
      }
      

简单得demo

// 主线程js
// 创建worker
var waiterWorker = new Worker('/worker.js', { name: 'waiterWorker' });

// 向 worker 发送信息
waiterWorker.postMessage({ title: 'waiter,收到请回答,把这个数字乘2', params: 22 });

// 当从 Worker 接收到消息时  
waiterWorker.onmessage = function (event) {
    // 获取 Worker 返回的结果  
    var result = event.data;

    // 在控制台打印结果  
    console.log('我是店主:我收到了来自waiter的结果==>', result);
};
// 当不再需要 Worker 时,可以终止它   任务完成,回炉重造
// waiterWorker.terminate();
// --------------------------------------
// worker.js
// 当接收到消息时  
self.onmessage = function (event) {
    // 获取传递的数据  
    let { data: { title, params } } = event;
    console.log('我是waiter:我收到了来自店主得呼唤==>', title);
    self.postMessage('我是waiter,我是waiter,我马上开干!');
    // 假设我们在这里执行一些计算密集型任务 
    const result = calculate(params);
    //  将结果发送回主线程  
    self.postMessage(result);
};

// 任务完成,回炉重造
// self.close()

function calculate(data) {
    // 这里只是一个简单的示例,你可以替换为实际的计算任务  
    for (var i = 0; i < 100000000; i++) {
        // 执行一些计算  
    }
    return data * 2;  // 假设这就是我们的计算结果  
}

image.png

api 汇总

  • 主线程
主线程 api说明
new Worker(jsUrl, options)接受两个参数。第一个参数是脚本的网址(必须遵守同源政策),该参数是必需的,且只能加载 JS 脚本,否则会报错。第二个参数是配置对象,该对象可选(可以指定 type、credentials、name 三个属性)。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程 // 主线程 var oneWorker = new Worker('worker.js', { name : 'oneWorker' }) // 子线程 self.name // oneWorker
Worker.onerrorerror 事件的监听函数
Worker.onmessagemessage 事件的监听函数,发送过来的数据在Event.data属性中
Worker.onmessageerrormessageerror 事件的监听函数,发送的数据无法序列化成字符串时,会触发这个事件
Worker.postMessage主线程向 Worker 线程发送消息
Worker.terminate结束 Worker 线程
  • 子线程

Web Worker 有自己的全局对象,不是主线程的window,而是一个专门为 Worker 定制的全局对象。因此定义在window上面的对象和方法不是全部都可以使用。

Worker 线程有一些自己的全局属性和方法

子线程 api说明
self.nameworker 的名字。该属性只读,由构造函数指定
self.onmessagemessage事件的监听函数
self.onmessageerrormessageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
self.importScripts加载 JS 脚本
self.postMessage向产生这个 Worker 线程发送消息
self.close关闭 Worker 线程

主线程的 terminate() 方法和在 Worker 线程中使用 close() 方法关闭 worker 这两种方法是等效的,但比较推荐的用法是使用 close(),防止意外关闭正在运行的 Worker 线程。Worker 线程一旦关闭 Worker 后 Worker 将不再响应。

worker 加载外部脚本

importScripts('script1.js')
importScripts('script2.js')

// 或者一次性加载两个
importScripts('script1.js', 'script2.js')

共享worker

共享worker可以同时被多个脚本使用,即使这些脚本正在被不同的window、iframe或者worker访问,也就是说可以使用共享worker进行多个浏览器窗口间通信,当然共享worker的通信必须为同源,不能跨域通信。SharedWorker 线程的创建和使用跟 worker 类似,事件和方法也基本一样。只是构造器的名字不同

他们之间一个很大的区别在于:共享worker必须通过一个确切的打开的端口对象供脚本与worker通信,在专用worker中这一部分是隐式进行的。如果父级线程和worker线程需要双向通信,那么它们都需要调用start()方法,对于消息的传递依然使用postMessage但是必须通过调用端口上的postMessage方法来实现消息通信。

也就是说主线程与 SharedWorker 线程是通过MessagePort建立起链接,数据通讯方法都挂载在SharedWorker.port上

共享worker 是被多个页面共同使用,那么除了与各个页面之间的数据通讯是独立的,同一个 共享worker 线程上下文中的其他资源都是共享的。基于这一点,很容易实现不同页面之间的数据通讯

看是否支持 和 专用worker一样

if (window.SharedWorker) {
    // ...
}
  1. 线程创建

    共享线程使用 Shared Worker() 方法创建,同样支持两个参数,用法与 Worker() 一致

    var sharedWorker = new SharedWorker('worker.js')
    
  2. 数据通信

    在传递消息时,postMessage() 方法和 onmessage 事件必须通过端口对象调用, 这个和 专用worker 是不一样的

    需要注意的是:如果采用 addEventListener 来接收 message 事件,那么在主线程初始化SharedWorker() 后,还要调用 SharedWorker.port.start() 方法来手动开启端口。

    // main.js(主线程)
    const myWorker = new SharedWorker('./worker.js');
    
    myWorker.port.start(); // 开启端口
    
    myWorker.port.addEventListener('message', msg => {
       console.log(msg);
    })
    
    

    如果采用 onmessage 方法,则默认开启端口,不需要再手动调用SharedWorker.port.start()方法

    // main.js(主线程)
    const myWorker = new SharedWorker('./worker.js');
    
    myWorker.port.onmessage = msg => {
        console.log(msg);
    };
    
    

    另外,在 Worker 线程中,需要使用 onconnect 事件监听端口的变化,并使用端口的消息处理函数进行响应。

    onconnect = function (e) {
        let port = e.ports[0]
        port.onmessage = function (e) {
          // ...
        }
    }
    
    
  3. demo

     // 页面1
     if (!!window.SharedWorker) {
         const pageOneTwoWorker = new SharedWorker('/worker.js');
         pageOneTwoWorker.port.start();
         pageOneTwoWorker.port.postMessage('页面1');
         pageOneTwoWorker.port.addEventListener('message', msg => {
             console.log('msg', msg);
         })
     }
    
    
     // 页面2 
     if (!!window.SharedWorker) {
         const pageTwoWorker = new SharedWorker('/worker.js');
         pageTwoWorker.port.start();
         pageTwoWorker.port.postMessage('页面2');
         pageTwoWorker.port.addEventListener('message', msg => {
             console.log('msg', msg);
         })
     }
    
     // worker.js
     // worker线程
     var portArr = [];
     onconnect = function (e) {
         var port = e.ports[0];
         if (portArr.indexOf(port) === -1) portArr.push(port);
         port.onmessage = function (e) {
             portArr.forEach(v => {
                 v.postMessage(e.data);
             })
         }
     }
    
    

Web Worker 需要注意的点

  1. Worker 线程运行的脚本文件,必须与主线程的脚本文件同源

  2. Worker 无法读取主线程所在网页的 DOM 对象,也无法使用document、window、parent这些对象。但是,Worker 线程可以navigator对象和location对象

  3. Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成

  4. Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求

  5. Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

    image.png

想本地调试的可以在VScode里面通过 image.png 调试

  1. 如果 Worker 线程频繁与主线程进行交互,主线程需要处理交互,仍有可能使页面发生阻塞
  2. new Worker传入的js是采用的ESModule 模式,可以使用 worker 的第二个可选参数设置为 module 模式初始化 worker 线程!
     // main.js(主线程)
     const worker = new Worker('worker.js', {
         type: 'module'  // 指定 worker.js 的类型
     });
    

那些情况使用

  • 懒加载
  • 流媒体数据处理
  • canvas 图形绘制
  • 图像处理
  • 计算密集型任务
  • ...