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
};
复制代码
最终打印结果为
可以发现上面代码 Worker线程在接收主线程发过来的数据后将其对象数据上的某个值修改,然后发送数据给主线程,主线程在接收时打印原先发生的data数据,发现name值没变,说明主线程和worker线程的这种数据通信是拷贝关系,而不是简单的传值。所以Worker线程对通信数据的修改并不会影响主线程的数据。
worker线程的限制
- Worker 脚本文件的限制
- Worker 线程无法读取本地文件,脚本文件需来自服务器
- 同源策略:Worker 线程运行的脚本文件,必须与主线程的脚本文件同源
- Worker 线程全局对象限制
- Dom限制:如前面所说,为了避免多个线程同时操作dom带来的复杂性,Worker线程不能访问
document
、window
、parent
对象。但是可以访问navigator
对象和location
对象 - Worker线程也无法使用
alert()
方法和confirm()
方法。但是Worker可以访问XMLHttpRequest 对象,也就是发AJAX请求,也可以获取setTimeout(), clearTimeout(), setInterval(), clearInterval()等定时操作方法
- Dom限制:如前面所说,为了避免多个线程同时操作dom带来的复杂性,Worker线程不能访问
- 数据通信限制:如上面所说,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
复制代码
这样,便可以创建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 方法,于是报错如下
在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 的线程运行后可以在控制台发现
时间大概是 8 毫秒。
而相对的,我们的我们直接把那段代码写到Worker线程中
Worker 线程代码
console.time("importScripts time");
let testArr = [];
for (let i = 0; i < 100000; i++) {
testArr.push(i);
}
console.timeEnd("importScripts time");
复制代码
打印结果为
时间大概是 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>
复制代码
运行后效果如下