Web Worker,JavaScript 多线程

1,742 阅读8分钟

Web Worker,JavaScript 多线程

我们都知道,JavaScript是单线程的语言,这是因为最初 js 被设计用于浏览器中,主要用于操作dom元素,实现用户和浏览器的交互,如果 js 是多线程,那么可能会出现多个线程同时操作一个dom元素的情况,造成浏览器混乱,所以为了避免这种复杂性,js 被设计成相对简单的单线程的语言。

但是,随着技术的发展,在多核CPU的时代,单线程无法充分发挥计算机的计算能力,于是,在HTML5中,提出了 Web Worker 标准。Web Worker 可以为 JavaScript 脚本创建多个线程,让我们可以将一些复杂的计算任务交给worker线程运行,避免主线程阻塞,等到Worker线程计算完毕再把结果返回主线程。但是子线程完全受主线程控制,有多种限制条件,包括不能操作DOM等。所以,Web Worker并没有改变js单线程的本质。

线程的分类

Web Worker线程其实可以分为几类,例如

  • Dedicated Worker:专用线程
  • Shared Worker:共享线程
  • Service Workers:服务工作线程

​ ......等等

本文主要讲的是Dedicated Worker,即专用线程,专用线程只能被创建它的脚本使用,一个专用线程对应着一个主线程,一般情况下,Web Worker线程运行的代码就是为了当前的脚本(页面)服务,所以专用线程也是最常用的一种线程。

初步使用

创建 Worker 线程

Worker 线程的创建通过Worker 构造函数

let worker = new Worker('xxx.js')

Worker 构造函数的第一个参数是一个脚本文件,这个文件不能是本地文件,因为Worker无法读取本地文件,直接写本地地址会报错,所以这个脚本文件必须来自服务器。

第二个可选参数是一个options对象,可以配置name值来指定Worker的名称,可用于区分多个Worker。

let worker = new Worker('xxx.js', { name: 'my_worker' })

可以理解为当前创建worker线程的代码就是主线程,上面Worker 构造函数的参数脚本文件就是worker线程。

主线程与Worker线程通信

主线程收发数据

主线程创建 Worker 线程后,可以通过 worker.postMessage() 方法向Worker发送数据。该数据可以是各种数据类型,包括二进制数据等。

然后通过 worker.onmessage 或者 worker.addEventListener('message', function(){})的方式接收Worker线程发送过来的数据。

const worker = new Worker('xxx.js')
// 给Worker线程发送数据
worker.postMessage('你好')

// 接收来自Worker线程的数据
worker.onmessage = function (e) {
  console.log('接收到的Worker线程发过来的数据:' + e.data)
}
// 或者
worker.addEventListener('message', function (e) {
  console.log('接收到的Worker线程发过来的数据:' + e.data)
});

Worker线程收发数据

同样,Worker线程可以通过 self.postMessage()给主线程发送数据

通过self.onmessage或者self.addEventListener('message', function(){})的方式接收

值得一提的是在Worker线程中,self 代表线程自身(主线程中self代表window),也可以用this代替self,或者干脆省略不写也是可以的,所以下面三种写法其实是一样的

// 写法一
self.addEventListener('message', function (e) {
  self.postMessage('接收到的主线程发过来的数据:' + e.data);
});

// 写法二
this.addEventListener('message', function (e) {
  this.postMessage('接收到的主线程发过来的数据:' + e.data);
});

// 写法三
addEventListener('message', function (e) {
  postMessage('接收到的主线程发过来的数据:' + e.data);
});

数据通信例子

例如我们有段worker线程的代码如下

self.addEventListener("message", function (e) {
  console.log("接收到的主线程发过来的数据: ", e.data.user.name);
  // 修改接收到的主线程数据	
  e.data.user.name = "mike";
  self.postMessage("你好,我是worker线程");
});

同时主线程的代码如下

let data = {
  user: {
    name: "jack",
  },
};

const worker = new Worker("http://127.0.0.1:8080/worker.js");
worker.postMessage(data);
worker.onmessage = function (e) {
  console.log("接收到的Worker线程发过来的数据: " + e.data);
  console.log(data.user.name); // name值还是jack,并没有变成 mike
};

最终打印结果为

w4.png

可以发现上面代码 Worker线程在接收主线程发过来的数据后将其对象数据上的某个值修改,然后发送数据给主线程,主线程在接收时打印原先发生的data数据,发现name值没变,说明主线程和worker线程的这种数据通信是拷贝关系,而不是简单的传值。所以Worker线程对通信数据的修改并不会影响主线程的数据。

worker线程的限制

  • Worker 脚本文件的限制
    • Worker 线程无法读取本地文件,脚本文件需来自服务器
    • 同源策略:Worker 线程运行的脚本文件,必须与主线程的脚本文件同源
  • Worker 线程全局对象限制
    • Dom限制:如前面所说,为了避免多个线程同时操作dom带来的复杂性,Worker线程不能访问documentwindowparent对象。但是可以访问navigator对象和location对象
    • Worker线程也无法使用alert()方法和confirm()方法。但是Worker可以访问XMLHttpRequest 对象,也就是发AJAX请求,也可以获取setTimeout(), clearTimeout(), setInterval(), clearInterval()等定时操作方法
  • 数据通信限制:如上面所说,Worker线程和主线程并不在同一个上下文环境,不能直接通信。

本地调试方案

脚本文件必须来自网络,那么我们在本地调试的时候怎么调试呢?

其实方案有很多,这里给出几个常用的比较简单的方案供大家参考。

利用Blob

我们可以通过Blob()方式,首先

我们可以把Worker线程代码写出字符串的形式,再通过 new Blob() 和 window.URL.createObjectURL() 的方式来将其转化为可以生效的 worker 脚本文件

const workerCode = `
self.addEventListener("message", function (e) {
  console.log("接收到的主线程发过来的数据: " + e.data);
  self.postMessage("你好,我是worker线程");
});`;
const workerBlod = new Blob([workerCode]);
const worker = new Worker(window.URL.createObjectURL(workerBlod));
worker.postMessage("你好,我是主线程");
worker.onmessage = function (e) {
  console.log("接收到的Worker线程发过来的数据: " + e.data);
};

如果不想通过这种字符串的形式,也可以用一个 script 标签将Worker线程代码包裹起来,并将其type类型设置为js无法识别的自定义类型,那么它就会被认为是一个数据块元素。

示例代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <title></title>
  </head>
  <script id="myWorker" type="javascript/myWorkType">
    self.addEventListener("message", function (e) {
      console.log("接收到的主线程发过来的数据: " + e.data);
      self.postMessage("你好,我是worker线程");
    });
  </script>
  <body>
    <div id="app"></div>
  </body>
  <script defer src="./main.js"></script>
</html>

然后通过document的方式获取其代码

const workerScript = document.getElementById('myWorker').textContent
const workerBlod = new Blob([workerScript]);
const worker = new Worker(window.URL.createObjectURL(workerBlod));

使用http-server

通过安装http-server

npm i -g http-server

然后在相关代码的文件夹下运行命令

http-server

w2.png

这样,便可以创建worker

const worker = new Worker("http://127.0.0.1:8080/worker.js");

Worker的错误处理

在主线程中通过 worker.onerror 或者 worker.addEventListener('onerror', function(){})的方式来监听Worker是否发生错误。

例如,

Worker线程代码:

self.addEventListener("message", function (e) {
  e.data.forEach((item) => {
    console.log(item);
  });
});

主线程代码:

const worker = new Worker("http://127.0.0.1:8080/worker.js");
worker.postMessage(undefined);
worker.onerror = function (e) {
  console.log(
    "Worker报错: " +
      "\n" +
      `错误发生的行号: ${e.lineno};` +
      "\n" +
      `错误发生的文件名: ${e.filename};` +
      "\n" +
      `错误消息: ${e.message}`
  );
};

上面代码执行后worker线程中由于 undefined 上面没有 foreach 方法,于是报错如下

w3.png 在Worker线程中也可以监听错误,不过只能拿到错误的消息数据

self.onerror = function (error) {
  // 这里 error 相当于上面代码中的 e.message
  console.log("错误消息:", error);
};

关闭Worker

Worker一旦创建成功就会始终运行,所以Worker 也比较耗费资源,当Worker 使用完毕时,我们可以手动停止Worker,通过以下代码

worker.terminate();

也可以在Worker线程中关闭

self.close();

Worker中加载其他脚本

Worker内部如果需要加载其他的脚本,可以通过importScripts()来加载

举个简单的例子

下面代码是被加载的文件

// otherScript.js文件
function add(a, b) {
  return a + b;
}

Worker线程的代码如下

importScripts("./otherScript.js");
console.log(add(1, 2));

在主线程运行创建出上面的Worker线程后,控制台可以如期打印出 3 (1+2=3),也就是Worker线程成功引入了 otherScript.js 文件的代码。

同时引入多个文件

importScripts() 也可以同时引入多个文件

importScripts("./first.js", "./second.js");

importScripts的阻塞性

importScripts 是同步的执行代码的,并且有一定的阻塞性,我们看下面两个实验。

首先,有以下的test.js文件代码

// test.js文件
let testArr = [];
for (let i = 0; i < 100000; i++) {
  testArr.push(i);
}

Worker 线程代码

console.time("importScripts time");

importScripts("./test.js");

console.timeEnd("importScripts time");

主线程创建上面Worker 的线程运行后可以在控制台发现

w6.png

时间大概是 8 毫秒。

而相对的,我们的我们直接把那段代码写到Worker线程中

Worker 线程代码

console.time("importScripts time");

let testArr = [];
for (let i = 0; i < 100000; i++) {
 testArr.push(i);
}

console.timeEnd("importScripts time");

打印结果为

image-20220410215139350.png

时间大概是 3 毫秒。

所以,其实importScripts并不是很实用。

在实际开发中,我们肯定是用模块化开发,不可能把Worker的所有代码都写在一个文件上,那么在 importScripts 不实用的情况下,我们可以使用打包工具将Worker的代码打包成一个文件,例如在webpack的项目中,我们可以使用 webworkify-webpack 插件。

webworkify-webpack

笔者之前做过一个在线教育的直播平台,在对师生聊天历史记录的数据处理时,就用到了webworkify-webpack这个插件创建Worker线程。

安装webworkify-webpack

在webpack项目下安装

npm i webworkify-webpack

webworkify-webpack的使用

首先,Worker线程代码需要在包裹在函数中,并用module.exports导出,该函数的参数就是该Worker线程的self。如下示例

module.exports = function (self) {
    self.postMessage('发送给主线程的数据')
  	self.addEventListener('message', function (ev) {
		console.log('接收到主线程发过来的数据', ev)
  	})
}

而主线程中,创建对应Worker如下示例

import work from 'webworkify-webpack'
let worker = work(require.resolve('./xxx.js'))
worker.postMessage('发送给Worker线程的数据')
worker.addEventListener('message', (ev) => {
    console.log('接收到Worker线程发过来的数据', ev)
})

上面代码中,work的参数可以用require.resolve来返回Worker线程文件的绝对路径,require.resolve还可以检查拼接好之后的路径是否存在。

webworkify-webpack原理

以下是webworkify-webpack源码的部分截取,可以发现其原理和上面的通过Blob()方式创建Worker线程一样都是通过 Blob 的方式

module.exports = function (moduleId, options) {
  options = options || {}
  var sources = {
    main: __webpack_modules__
  }

  var requiredModules = options.all ? { main: Object.keys(sources.main) } : getRequiredModules(sources, moduleId)

  var src = ''

  Object.keys(requiredModules).filter(function (m) { return m !== 'main' }).forEach(function (module) {
    var entryModule = 0
    while (requiredModules[module][entryModule]) {
      entryModule++
    }
    requiredModules[module].push(entryModule)
    sources[module][entryModule] = '(function(module, exports, __webpack_require__) { module.exports = __webpack_require__; })'
    src = src + 'var ' + module + ' = (' + webpackBootstrapFunc.toString().replace('ENTRY_MODULE', JSON.stringify(entryModule)) + ')({' + requiredModules[module].map(function (id) { return '' + JSON.stringify(id) + ': ' + sources[module][id].toString() }).join(',') + '});\n'
  })

  src = src + 'new ((' + webpackBootstrapFunc.toString().replace('ENTRY_MODULE', JSON.stringify(moduleId)) + ')({' + requiredModules.main.map(function (id) { return '' + JSON.stringify(id) + ': ' + sources.main[id].toString() }).join(',') + '}))(self);'

  var blob = new window.Blob([src], { type: 'text/javascript' })
  if (options.bare) { return blob }

  var URL = window.URL || window.webkitURL || window.mozURL || window.msURL

  var workerUrl = URL.createObjectURL(blob)
  var worker = new window.Worker(workerUrl)
  worker.objectURL = workerUrl

  return worker
}

webworkify-webpack实例

接下来,我们结合Vue写一个计时器示例:

首先,创建Worker线程文件

// webWorker.js
module.exports = function (self) {
  const strategy = {
    timing: (data) => {
      let startClassTime = +new Date()
      if (data) {
        startClassTime = data
      }
      computeTime()
      // 时间格式化, 个位数前面加‘0’
      function timeFormat(num) {
        if (num < 10) {
          return '0' + num
        } else {
          return num + ''
        }
      }
      // 时间合并返回
      function computeTime() {
        let now = +new Date()
        let dif = now - startClassTime
        let hour = Math.floor(dif / 1000 / 3600)
        let minute = Math.floor((dif / 1000 / 60) % 60)
        let seconds = Math.round((dif / 1000) % 60)
        hour = timeFormat(hour)
        minute = timeFormat(minute)
        seconds = timeFormat(seconds)
        let time = hour + ' : ' + minute + ' : ' + seconds
        self.postMessage({
          type: 'timing',
          data: time,
        })
      }
      setInterval(() => {
        computeTime()
      }, 1000)
    },
  }
  self.addEventListener('message', function (ev) {
    const { type, data } = ev.data || {}
    strategy[type](data)
  })
}

主线程(组件)代码如下

<template>
  <div>
    {{ myTime ? myTime : '00 : 00 : 00' }}
  </div>
</template>

<script>
import work from 'webworkify-webpack'

export default {
  data() {
    return {
      myTime: '00 : 00 : 00',
    }
  },
  created() {
    let worker = work(require.resolve('../../webworker.js'))
    worker.postMessage({
      type: 'timing',
      data: 0,
    })
    worker.addEventListener('message', (ev) => {
      console.log(this.myTime)
      if (ev.data.type === 'timing') {
        this.myTime = ev.data.data
      }
    })
  },
}
</script>

运行后效果如下

w9.gif