Web Workers 简介
Web Workers 是一种技术手段,通过JavaScript 提供的API,用于提高在某些特定情况下的Web应用程序的性能以及用户体验。
那这些特定情况是什么呢?
JavaScript语言采用的是单线程模型,意思是所有的任务都在一个线程上完成,当其中有一些费时的任务时,后面的任务都会被阻塞/放慢,这就造成了用户感知到的卡顿。通过使用Web Workers可以允许Web应用程序在JS主线程之外单独开一个worker线程来执行一些JS脚本,两个线程可以同时执行,互不干扰,当worker线程的脚本执行完成后再通过通信机制告诉主线程结果,从而减轻主线程的负担。我们通常利用worker线程处理一些费时任务,这样就能保证主线程(通常是UI线程)不会被阻塞/放慢。除此之外,Web Workers还可以用于解决Web应用的离线应用问题,详情后面会说。
你可以在worker线程中运行任意的代码,但也有一些限制:
- 同源策略限制
分配给worker线程的脚本文件必须是一个网络路径的文件(http开头),且必须与主线程的脚本文件同源。
- 上下文限制
创建的worker线程将运行在与当前的Window不同的全局上下文中。这个全局上下文是一个对象,标准情况下是DedicatedWorkerGlobalScope,共享的workers是SharedWorkerGlobalScope,因此,在worker线程中无法访问DOM元素,无法使用某些window下的属性和方法,但大部分的方法还是可以访问的。
- 通信限制
主线程与worker线程之间通过postMessage()进行发送数据,并通过onmessage事件处理器进行接收,worker线程执行完毕后,可以通过close()进行关闭,主线程可以通过terminate()进行关闭。
- 脚本限制
不能使用alert()和confirm()。
workers分为多个种类,不同的种类运用场景不同,作用也不同,常用的有以下三种:
- Delicated Workers:专用worker,通常用来处理一些耗时任务。
- Shared Workers:可被多个不同的窗体的脚本运行,但需要在同一主域。和专用worker的作用差不多,只不过该线程可以共享。
- Services Workers:作为web应用程序、浏览器和网络之间的代理服务,通常用来做一些离线缓存,以解决web离线应用的问题。
除此之外,还有比如chrome worker,音频worker等,有兴趣的同学可以下来深入了解。
接下来分别介绍下以上三种不同的worker,以及实践应用。
Delicated Worker
主要用来处理一些耗时任务,缓解主线程压力。
我用vue3写了一个demo,先来看下效果:
如图所示,当主线程被占用的时候,界面将卡住,这个demo的代码大致如下:
<template>
<div class="box top">
<button class="btn" @click="consumeTask">这是一个非常耗时的操作</button>
<h2>结果:{{result}}</h2>
</div>
<div class="box">
<button class="btn" @click="reduceFn">减</button>
<h2 class="h2">{{count}}</h2>
<button class="btn" @click="addFn">加</button>
</div>
</template>
<script>
import { ref } from 'vue';
export default {
name: 'App',
setup() {
const result = ref('');
const count = ref(0)
// count减
const reduceFn = () => {
count.value--;
}
// count加
const addFn = () => {
count.value++;
}
// 耗时操作
const consumeTask = () => {
result.value = "目前界面卡死"
for(let i = 0;i<100;i++){
for(let j = 0; j < 1000;j++){
console.log(i,j);
}
}
result.value = "bingo~,界面可以动了"
}
return {
result,
count,
reduceFn,
addFn,
consumeTime
}
}
}
</script>
细心的同学可能已经发现,在consumeTask方法内,刚被触发的时候就已经将结果改成了"目前界面卡死",但是界面并没有刷新出这个效果,正是因为后面的循环操作造成了主线程被占用,没办法去处理UI的更新。
我们使用web worker来将代码更新一下。
首先,创建一个worker.js文件,把耗时操作放进这个文件:
// worker.js
onmessage = function(e){
for(let i = 0;i<100;i++){
for(let j = 0; j < 1000;j++){
console.log(i,j);
}
}
console.log('传进来的值:',e.data);
postMessage('bingo~')
}
这里可能有同学要问postMessage()哪里来了,上面说了,worker线程所在的上下文中有onmessage事件处理器和postMessage()方法。
然后,更新consumeTask方法,通过Worker构造函数创建一个worker对象,Worker构造函数接受2个参数:
/**
* @description 创建一个worker线程
* @param {*} aUrl 一个网络路径(必须遵从同源策略)的脚本地址,即本地的文件地址(file://)将不可用。
* @param {*} options 可选配置项
*/
const worker = new Worker(aUrl,options)
完整的consumeTask方法如下:
// 耗时操作
const consumeTask = () => {
result.value = "耗时操作正在worker线程执行"
const workerUrl = new URL('./worker.js',import.meta.url).href;
if(window.Worker){
const worker = new Worker(workerUrl)
worker.postMessage('start') // 发送消息
worker.onmessage = (res) => {
console.log('result:',res.data) // result: bingo~
result.value = res.data
worker.terminate(); // 关闭线程
}
}else{
console.log('您的浏览器不支持Web Workers');
}
}
来看下效果:
有没有一种性能提升很明显的感觉😏。
以上代码中为了强调aUrl这个参数我才特意使用URL构造静态文件的路径。如果你是使用vite的编译工具的话,可以通过 ?worker 或 ?sharedworker 为后缀导入为 Web Worker,如下:
import Worker from './worker.js?worker'
// 耗时操作
const consumeTask = () => {
result.value = "耗时操作正在worker线程执行"
if(window.Worker){
const worker = new Worker()
worker.postMessage('start') // 发送消息
worker.onmessage = (res) => {
console.log('result:',res.data) // result: bingo~
result.value = res.data
worker.terminate(); // 关闭线程
}
}else{
console.log('您的浏览器不支持Web Workers');
}
}
关于怎么转换文件路径的话这里就不多赘述了。
Shared Worker
SharedWorker也是单开一个线程,和DelicatedWorker不一样的是,shared worker线程可以被其他窗口共享(但必须是同源的,即相同的协议、host 以及端口)。
这个共享指的是共享线程,一个线程可以做很多事情,而不是每次做一个事情都需要开个进程,尽管现在很多有16线程,32线程的处理器,但是资源也不是用来这么浪费的。地主家也有粮尽的时候。为了充分合理利用计算机资源,我们就可能需要用到Shared Worker。
用法如下,在之前的代码上稍作更改:
// worker.js
onconnect = function(e) {
var port = e.ports[0]; // 不同窗口
port.onmessage = function(e) {
for(let i = 0;i<100;i++){
for(let j = 0; j < 1000;j++){
console.log(i,j);
}
}
console.log('传进来的值:',e.data);
port.postMessage('bingo~')
}
}
// index.html
import SharedWorker from './shader.js?sharedworker'
// 耗时操作
const consumeTask = () => {
result.value = "耗时操作正在worker线程执行"
if(window.SharedWorker){
const sharedWorker = new SharedWorker()
sharedWorker.port.postMessage('start')
sharedWorker.port.onmessage = (res) => {
console.log('result:',res.data)
result.value = res.data
}
}else{
console.log('您的浏览器不支持Web Workers');
}
}
Service Worker
简介
这个API旨在创建有效的离线体验,它可以拦截并修改请求和响应资源,并细粒度的缓存资源。由于需要拦截请求,所以只能使用HTTPS,否则被中间人利用就会非常危险。
这也是浏览器缓存位置中的一种,service worker实际使用CacheStroage存储的。被service worker拦截到的请求,无论是拿的缓存还是请求的新资源,最终都会显示来自service worker。
service worker有几个生命周期:
- installing: 正在安装
- installed: 已经安装完毕
- activating:准备阶段,包括清除一些其他worker的相关的老旧资源。
- activated: 可以开始处理们的监听事件(在第一次打开页面的时候等执行到这个生命周期,请求可能都结束了,后面细说。)
- redundant:被更新替换。
基本步骤
- 页面
index.html通过serviceWorkerContainer.register(scriptUrl, scope)注册service worker。 - 如果注册成功,
service-worker.js就在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊类型的 worker 上下文运行环境。 - 注册成功后会触发的
service-worker.js的 install 事件,此时可以使用IndexDB和Cache API预先缓存资源。 - 当 install 事件的处理程序执行完毕后,可以认为 service worker 安装完成了。
- 安装完成后,会接收到一个激活事件( activate )。
onactivate主要用途是清理先前版本的 service worker 脚本中使用的过期资源,防止本地存储爆炸。 - service worker 现在可以控制页面了,但仅是在
register()成功后打开的页面。也就是说,如果页面在 activate 事件被触发之前就可能加载完网络资源,则这些网络资源不会被 service worker 控制。所以,页面需要重新加载让 service worker 获得完全的控制。 - service worker 脚本通过监听fetch, push, sync API事件实现对页面的控制。
注意:在开始之前为了浏览器能够正常运行service worker, 请确保如下设置:
- Firefox Nightly: 访问
about:config并设置dom.serviceWorkers.enabled的值为 true; 重启浏览器; - Chrome Canary: 访问
chrome://flags并开启experimental-web-platform-features; 重启浏览器 (注意:有些特性在Chrome中没有默认开放支持); - Opera: 访问
opera://flags并开启ServiceWorker 的支持; 重启浏览器。
应用
先来看个离线应用的demo:
首先通过navigator.serviceWorker.register注册service worker,register(scriptUrl, scope)接受两个参数,第一个是worker文件的地址,第二个是可选scope配置项,通常是一个相对路径,默认值为./,用来表示service worker可以控制(起作用)的子目录,即scope表示的目录下的请求才会被监听到。
// index.html
if ('serviceWorker' in navigator) {
const serviceWorker = new URL('/service-worker.js',import.meta.url).href;
navigator.serviceWorker.register(serviceWorker,{scope:'./'}).then(function(reg) {
console.log("Service Worker registered with scope: ", reg.scope);
}).catch(function(error) {
// registration failed
console.log('Registration failed with ' + error);
});
}
// 请求数据
fetchData()
注意:通常开发环境下将
service-worker.js放在根目录,这样 worker 便可以作用在整个项目下的请求。
注册成功的service worker如图所示,会提示activated and is running:
随后,在service-worker.js中监听它的oninstall事件处理器,在该方法中设置我们要设置缓存的请求路径:
// service-worker.js
// 监听install事件
oninstall = function (event) {
event.waitUntil(
caches.open('v1').then(function (cache) {
return cache.addAll([
'http://localhost:8081/',
'http://localhost:8081/src/main.js'
'https://www.fastmock.site/mock/2f82fcaef2f445bf7e05e7ff91eb86b0/api/api/getImgList'
]);
})
);
}
// 监听fetch,拦截请求
onfetch = function(event){
event.respondWith(caches.match(event.request).then(function(response) {
if (response !== undefined) {
return response;
} else {
return fetch(event.request).then(function (response) {
let responseClone = response.clone();
caches.open('v1').then(function (cache) {
cache.put(event.request, responseClone);
});
return response;
}).catch(function(){
// 做一下错误处理
// return caches.match('/xxx')
});
}
}));
}
respondWith()返回一个 Response 、 network error 或者 Fetch的方式resolve。
caches.match(event.request)来匹配请求是否有缓存,返回Promise,无论有没有匹配到该Promise始终会resolve,如果匹配到了,返回的response则有值,否则为undefined。当没有匹配到的时候我们可以通过fetch去请求最新的资源,然后进行缓存。
service worker的实际运用远不止这么简单,更多的可能要根据需求去灵活处理,比如涉及到更新缓存版本等,想要深入学习可以查看MDN文档的使用 service worker
注意:当我们在开发的时候更新了service-worker.js文件,有时候并不会生效,有两个解决方案:
- 在控制台勾选service worker的更新状态:
- 取消之前service worker的注册:
通信
单向通信
- 主线程 -> service worker线程
service worker线程通过监听 onmessage 事件接收信息,主线程通过 postMessage 发送数据。在 servce worker注册成功后对应的不同的状态对象上都会有 postMessage 方法,在激活状态时,navigator.serviceWorker.controller 也会有这个方法:
// index.html
navigator.serviceWorker.register("../service-worker.js", { scope: "./" })
.then(function (reg) {
console.log("Service Worker registered with scope: ", reg.scope);
let serviceWorker;
if (reg.installing) {
console.log("Service worker installing");
serviceWorker = reg.installing;
} else if (reg.waiting) {
console.log("Service worker installed");
serviceWorker = reg.waiting;
} else if (reg.active) {
console.log("Service worker active");
// 在active状态下有两种方式
serviceWorker = navigator.serviceWorker.controller || reg.active;
}
serviceWorker.postMessage('data01')
})
.catch(function (error) {
// registration failed
console.log("Registration failed with " + error);
});
// service-worker.js
onmessage = function(event){
console.log('event',event.data); // data01
}
双向通信
- 建立
MessageChannel通信,同样通过postMessage发送消息,将messageChannel的第二个通道通过postMessage第二个参数传给service worker线程,然后在传回来。
// index.html
const messageChannel = new MessageChannel();
serviceWorker.postMessage('成功发送', [messageChannel.port2]);
messageChannel.port1.onmessage = function(event){
console.log('主线程:',event.data) // 主线程: 已接收
}
// service-worker.js
onmessage = function(event){
console.log('service worker 线程',event.data); //service worker 线程 成功发送
event.ports.forEach(port => {
port.postMessage({command:'已接收'});
})
}
- 建立同一命名的
BroadcastChannel,监听onmessage接收消息,postMessage发送消息
// index.html
const channel = new BroadcastChannel("broadcast-channel");
channel.onmessage = function (event) {
console.log("主线程接收", event.data); // 主线程接收 service worker线程发出的消息
};
channel.postMessage('从主线程发出的消息');]
// service-worker.js
const channel = new BroadcastChannel('broadcast-channel');
channel.onmessage = function (event) {
console.log("service worker线程接收", event.data); // service worker线程接收 从主线程发出的消息
};
channel.postMessage('service worker线程发出的消息');
注意:在主线程定义
onmessage方法的话要在注册service worker之前监听。
其他应用场景
service worker除了应用离线缓存外,它还有一些其他用处(搬自MDN):
- 后台数据同步
- 响应来自其它源的资源请求
- 集中接收计算成本高的数据更新,比如地理位置和陀螺仪信息,这样多个页面就可以利用同一组数据
- 在客户端进行CoffeeScript,LESS,CJS/AMD等模块编译和依赖管理(用于开发目的)
- 后台服务钩子
- 自定义模板用于特定URL模式
- 性能增强,比如预取用户可能需要的资源,比如相册中的后面数张图片
最后
以上仅作为本人学习的一个总结,如果不对的地方欢迎留言指正,将不甚感激,谢谢。
参考资料: