记Web Workers的学习实践总结

1,191 阅读10分钟

Web Workers 简介

Web Workers 是一种技术手段,通过JavaScript 提供的API,用于提高在某些特定情况下的Web应用程序的性能以及用户体验。

那这些特定情况是什么呢?

JavaScript语言采用的是单线程模型,意思是所有的任务都在一个线程上完成,当其中有一些费时的任务时,后面的任务都会被阻塞/放慢,这就造成了用户感知到的卡顿。通过使用Web Workers可以允许Web应用程序在JS主线程之外单独开一个worker线程来执行一些JS脚本,两个线程可以同时执行,互不干扰,当worker线程的脚本执行完成后再通过通信机制告诉主线程结果,从而减轻主线程的负担。我们通常利用worker线程处理一些费时任务,这样就能保证主线程(通常是UI线程)不会被阻塞/放慢。除此之外,Web Workers还可以用于解决Web应用的离线应用问题,详情后面会说。

你可以在worker线程中运行任意的代码,但也有一些限制:

  1. 同源策略限制

分配给worker线程的脚本文件必须是一个网络路径的文件(http开头),且必须与主线程的脚本文件同源。

  1. 上下文限制

创建的worker线程将运行在与当前的Window不同的全局上下文中。这个全局上下文是一个对象,标准情况下是DedicatedWorkerGlobalScope,共享的workersSharedWorkerGlobalScope,因此,在worker线程中无法访问DOM元素,无法使用某些window下的属性和方法,但大部分的方法还是可以访问的。

  1. 通信限制

主线程与worker线程之间通过postMessage()进行发送数据,并通过onmessage事件处理器进行接收,worker线程执行完毕后,可以通过close()进行关闭,主线程可以通过terminate()进行关闭。

  1. 脚本限制

不能使用alert()confirm()


workers分为多个种类,不同的种类运用场景不同,作用也不同,常用的有以下三种:

  • Delicated Workers:专用worker,通常用来处理一些耗时任务。
  • Shared Workers:可被多个不同的窗体的脚本运行,但需要在同一主域。和专用worker的作用差不多,只不过该线程可以共享。
  • Services Workers:作为web应用程序、浏览器和网络之间的代理服务,通常用来做一些离线缓存,以解决web离线应用的问题。

除此之外,还有比如chrome worker,音频worker等,有兴趣的同学可以下来深入了解。

接下来分别介绍下以上三种不同的worker,以及实践应用。

Delicated Worker

主要用来处理一些耗时任务,缓解主线程压力。

我用vue3写了一个demo,先来看下效果:

1.gif

如图所示,当主线程被占用的时候,界面将卡住,这个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');
  }
}

来看下效果:

2.gif

有没有一种性能提升很明显的感觉😏。

以上代码中为了强调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:被更新替换。

基本步骤

  1. 页面index.html通过 serviceWorkerContainer.register(scriptUrl, scope) 注册service worker。
  2. 如果注册成功,service-worker.js 就在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊类型的 worker 上下文运行环境。
  3. 注册成功后会触发的service-worker.jsinstall 事件,此时可以使用IndexDBCache API预先缓存资源。
  4. 当 install 事件的处理程序执行完毕后,可以认为 service worker 安装完成了。
  5. 安装完成后,会接收到一个激活事件( activate )。 onactivate主要用途是清理先前版本的 service worker 脚本中使用的过期资源,防止本地存储爆炸。
  6. service worker 现在可以控制页面了,但仅是在 register() 成功后打开的页面。也就是说,如果页面在 activate 事件被触发之前就可能加载完网络资源,则这些网络资源不会被 service worker 控制。所以,页面需要重新加载让 service worker 获得完全的控制。
  7. 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:

3.gif

首先通过navigator.serviceWorker.register注册service workerregister(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:

ca10c9d69ab4a7561e62f525879d0c7c.png

随后,在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文件,有时候并不会生效,有两个解决方案:

  1. 在控制台勾选service worker的更新状态:

a82be18e8273ba0556933205e36114fd.png

  1. 取消之前service worker的注册:

2e0a0bffedd5f81dc1025d4af07c6a67.png

通信

单向通信

  1. 主线程 -> 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
}

双向通信

  1. 建立 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:'已接收'});
  })
}
  1. 建立同一命名的 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模式
  • 性能增强,比如预取用户可能需要的资源,比如相册中的后面数张图片

最后

以上仅作为本人学习的一个总结,如果不对的地方欢迎留言指正,将不甚感激,谢谢。

参考资料: