由面试题引发的Worker Study
- 由场景题扯到了这个
- 我: WebWorker我了解到的有两种,一种是Service Worker, 另一种是Shared Worker
- 面试官: 你再想想?WebWorker和Service Worker应该是两种东西, 不太一样, 你了解PWA吗
- 我: 啊??-------
- 面试官: 那你说说怎么使用Service Worker?
- 我: emmm.....不会(我只了解过SharedWorker是new一个实例, 然后用postMessgae通信)
- MDN在介绍Web Worker时有以下描述,
有许多不同类型的 worker:
- 专用 worker
- Shared worker
- Service Worker
- 故我理解的面试官应该指的不一样应该是专用Worker和Service Worker
- 当然, 答案不是很重要, 掌握好具体是什么, 什么场景使用,如何使用才最重要, 耶
一、什么是Web Worker
1. 先看MDN介绍:Web Worker 使得在一个独立于 Web 应用程序主执行线程的后台线程中运行脚本操作成为可能。这样做的好处是可以在独立线程中执行费时的处理任务,使主线程(通常是 UI 线程)的运行不会被阻塞/放慢
Web Worker
的运行不会影响主线程,不过在和主线程进行通信的时候依旧会受到主线程单线程的瓶颈制约
2. 限制
- 不能直接在
worker
线程中操作 DOM 元素(不能操作DOM的原因我想也很简单, 本来JS设计为单线程就有避免出现多个线程同时操作DOM的情况) - 不能使用
window
对象中的某些方法和属性(但是存在WorkerGlobalScope
)
3.worker
可以依次生成新的 worker
,只要这些 worker
与父页面托管在同一个origin
中
二、什么是专用Worker
- 定义: 单个脚本使用的
worker
(我的理解就是真正用于处理一些费时间任务的Worker
) - 上下文:
DedicatedWorkerGlobalScope
- 相关API:
Worker
: 构造函数, 可以通过new Worker('xxxxx/xxx.js')
来创建工作线程Worker.prototype.onmessage
: 接受另一个线程的回调函数, 其实就是监听发来的postMessage
Worker.prototype.postMessgae
: 给另一个线程发送数据Worker.prototype.terminate
: 终止线程
- 具体来个例子尝试一下
-
尝试两种情况:
- 只用单线程,计算三次斐波那契数列
export const commonDo = () => { const startTime = Date.now(); const res1 = timeConsumingCode(45); const res2 = timeConsumingCode(45); const res3 = timeConsumingCode(45); document.querySelector('#commonResult').innerText = res1 + res2 + res3; const endTime = Date.now(); document.querySelector('#commonTime').innerText = endTime - startTime; }
- 使用worker线程,辅助计算三次斐波那契数列
export const workDo = () => { let curTime = 0; let curSum = 0; const startTime = Date.now() // 这里开了三个worker线程去分别计算 const work1 = new Worker('./3.js'); const work2 = new Worker('./3.js'); const work3 = new Worker('./3.js'); const onmessageHanlde = (res) => { curSum += Number(res.data); curTime++; console.log(res); if (curTime === 3) { document.querySelector('#workerTime').innerText = Date.now() - startTime; document.querySelector('#workerResult').innerText = curSum; // 计算完后, 这里要终止线程的运行 work1.terminate(); work2.terminate(); work3.terminate(); } } // 通过onmessage的方法监听 work1.onmessage = onmessageHanlde work2.onmessage = onmessageHanlde work3.onmessage = onmessageHanlde }
可以看到当我们开启线程之后,
source
会显示有三个在运行, 当我们terminate
之后, 这三个又会消失
录制
performance
之后也能看到有对应的三个线程在执行
- 对应的耗时情况
- 对于第一种情况, 单线程计算的情况下耗时29896
- 对于第二种情况, 用worker辅助计算的情况下耗时10811
- 第二种总会略大于第一种的三分之一, 我理解在创建线程,以及线程通过postMessage通信都是存在耗时的
- Web Worker之间不能共享内存, 主页面和Worker之间的数据传递是通过拷贝而不是共享来完成的
- 在数据交换期间, 需要对数据序列化和反序列化
二、什么是Service Worker
- 先看介绍: Service worker 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API
Service Worker
主要包括注册,安装,激活, 运行几个阶段Service Worker
会常驻在浏览器, 即使注册他的页面已经关闭,主要手动关闭或者浏览器回收才会终止这个线程, 是服务于多个页面的- 在访问页面时会进入
activate
状态,在浏览器或者当前tab页关闭时会自动休眠,减少资源损耗
- 限制(仅针对于Service Worker)
- 出于安全考量,Service worker 只能由 HTTPS 承载(这个我想也很容易理解, 因为Service Worker是可以拦截网络请求的, 一旦暴露给中间人攻击那么很危险, 而HTTPS可以有效解决中间人攻击)
- 实践出真知
- 注册: Service Worker是通过
navigator.serviceWorker.register
来进行注册的, 当然,使用的时候一般需要判断navigator
是否存在serviceWorker
这个属性(基于兼容性的考虑) - 安装: 是在
ServiceWorker
注册成功之后,浏览器开始下载ServiceWorker
脚本的阶段, 我们可以通过install
事件去对它做一个监听
addEventListener('install', () => {
console.log('install........')
})
- 激活: 是在安装完
ServiceWorker
脚本后, 对他进行一个激活的行为, 我么可以通过activate
事件去做一个监听
addEventListener('activate', () => {
console.log('activate.....');
})
- 运行: 是运行阶段是在激活完成之后,
ServiceWorker
开始运行的阶段, 我们可以通过fetch
事件去做一个监听
addEventListener('fetch', () => {
console.log('fecth......');
})
我们也可以通过registration的相关字段去做判断
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("./2.js");
if (registration.installing) {
console.log("正在安装 Service worker...........");
} else if (registration.waiting) {
console.log("已安装 Service worker installed..........");
} else if (registration.active) {
console.log("激活 Service worker.........");
}
} catch (error) {
console.error(`注册失败:${error}`);
}
}
};
在理解实际运用场景之前, 我决定先从面试官提到的PWA入手
4. 什么是PWA
- Progressive Web Apps(渐进式增强 WEB APP): 是一种结合了传统网页和原生应用功能的Web应用程序。它具有以下特点和优势
- 渐进式增强
- 应用程序外观
- 响应式布局
- 离线访问:PWA具备离线访问的能力,即使在网络连接不可用的情况下,用户仍然可以继续访问应用程序
诶注意到离线访问能力, 这不就可以通过使用Service Worker技术,去拦截请求缓存应用程序的资源,从而实现离线访问和更快的加载速度嘛
5. 离线缓存
要实现离线缓存, 我想应该有四个阶段, 第一个阶段为安装激活Service Worker
(上面讲过了), 第二个阶段拦截请求对结果进行缓存, 第三阶段为请求时取出缓存直接使用缓存。
我们挨个突破, 首先要了解的是跟 缓存相关的API
- 通过
caches.open(CacheName)
去打开一个缓存对象 - 通过
Cache.put(req, res)
抓取一个请求及其响应,并将其添加到缓存中 - 通过
Cache.add(req)
抓取这个 URL,检索并把返回的 response 对象添加到给定的 Cache 对象 - 通过
Cache.addArr(url[])
抓取一个 URL 数组,检索并把返回的 response 对象添加到给定的 Cache 对象 - 通过
Cache.match(req, opts)
返回一个Promise
对象,resolve 的结果是跟Cache
对象匹配的第一个已经缓存的请求
这个 API 和浏览器的标准的缓存工作原理很相似,但它特定于你的域的。直到你清理它们之前,这些内容都会持久存在
先看第一阶段, 按照我们上述的去激活就OK了, 我们可以从application
中看到对应的Service Worker
的状态
其次开始我们的第二阶段,以下代码我们通过install
事件去监听了service Worker
的注册, 接着调用了e.waitUntil
确保我们能够在安装之前将对应的代码执行完成, 接着调用了caches.open()
打开了对应的缓存, 然后使用addAll
将我们所需要的缓存的url
(相对于Worker)加入
const cacheName = 'mouche_cache_v1';
addEventListener('install', (e) => {
e.waitUntil(
caches.open(cacheName).then((cache) => {
return cache.addAll([
'./1.html',
'./1.js',
'./3.js',
'./4.css',
])
})
)
})
- 成功缓存后我们也可以在
application
中找到缓存空间, 可以看到相关的内容已经缓存成功了
然后开启我们的第三阶段, 每次获取 service worker
控制的资源时,都会触发 fetch
事件,通过fetch
拦截请求之后, 判断是否缓存中有对应的url
, 有的话则直接取出结果, 调用 e
上的 respondWith()
方法来劫持我们的 HTTP 响应,没有的话则发起请求再做一次缓存(注意这里的不需要去等待缓存)
该匹配通过 URL 和各种标头进行
const putToCache = async (req, res) => {
const cache = await caches.open(cacheName);
await cache.put(req, res);
}
const findCache = async (req) => {
const resFromCache = await caches.match(req);
if (resFromCache) {
return resFromCache;
} else {
const resFromNetWork = await fetch(req);
// 这里必须克隆,因为请求和响应流仅可以读取一次
// 为了返回响应到浏览器,并将其放入缓存中
// 因此原始的资源会返回给浏览器,克隆的资源会发送到缓存。它们都只能被读取一次
putToCache(req, resFromNetWork.clone());
return resFromNetWork;
}
}
addEventListener('fetch', (e) => {
if (e.request) {
e.respondWith(findCache(e.request));
}
})
- 我们尝试离线, 可以看到,资源都能够正常地从Service Worker获取到
OK, 这样已经能够做到,缓存资源-取出缓存应用的过程了, 但是我们还没有考虑到更新缓存, 当我们被缓存的文件更改的时候, Service Worker
是不感知的, 那么我们在文件更新(发布新版本)的情况下则需要修改CacheName
, 让其存放到新的缓存中。
那么此时我们还需要对旧缓存进行一个清理,否则资源会越累积越多 那么我们一般是在
activate
阶段去清理旧缓存
在activate进行更新的原因是:当我们使用旧版本缓存展示的页面时,新版本Service Worker的会安装但是不会激活, 会等到没有使用旧版本的的页面时, 新的才会被激活, 故在active适合去删除旧版本的缓存
addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cName) => {
if (cName !== cacheName) {
return caches.delete(cName);
}
})
)
})
)
})
- 此时如图所示, 废弃的v1版本缓存已经被清除了
三、什么是Shared Worker
- 先看MDN的介绍:
SharedWorker
接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker
看介绍和名字就能知道了他是用了进行多个页面中进行通信的手段, 回顾一下, 此类型的手段已经有
- 通过BroadcastChannel开启一个频道用于同源的页面进行通信
- 通过localStroage存储和监听storage
- 通过轮询 + cookie存储
- 通过iframe/window.open打开的页面可以直接使用postMessage通信
- 通过websocket做中间商
- 限制:
- 如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源
- 使用:
- 来个简单的demo, 背景是我使用多个tab打开了同一页面,此时接收到后端的推送, 会弹出弹窗, 此时两个页面都会弹出, 那我自然是希望,我点掉一个页面的弹窗时,另一个页面对应的弹窗也可以自动消失, 此时就可以通过Shared Worker去实现
- 通过
new SharedWorker()
去开启一个Worker - 通过
worker.port.postMessage
向Shared Worker发送消息 - 通过
worker.port.onmessage
监听Worker发送过来的消息
const worker = new SharedWorker('./2.js');
btn.addEventListener('click', () => {
const newDom = document.createElement('div');
newDom.innerText = index;
newDom.setAttribute('class', 'child');
newDom.addEventListener('click', () => {
worker.port.postMessage(newDom.innerText); // 向Worker线程发送消息
})
rootDiv.appendChild(newDom);
index++;
})
worker.port.onmessage = (e) => {
const receviceData = e.data;
const targetDom = Array.from(rootDiv.children).filter((x) => x.innerText === receviceData);
targetDom && rootDiv.removeChild(targetDom[0]);
}
- 在Worker文件中, 维护一个port池,onconnect的触发时机为页面开启Worker, 此时将页面port加入我们的port池,给它开启message监听, 当有页面向Worker发消息, Worker向port池中的所有页面推送消息
const portPool = [];
onconnect = (e) => {
const port = e.ports[0];
portPool.push(port);
port.addEventListener('message', (e) => {
const workerResult = e.data;
portPool.forEach((port) => port.postMessage(workerResult));
})
port.start();
}
- 效果如下:无论点击哪个页面, 其他页面都会随之更新
四、 兼容性

答案不重要, 重要的是学会了这几种的学费了
转载自:https://juejin.cn/post/7283813865719644199