《JavaScript 高级程序设计》 第二十七章 工作者线程 学习记录

235 阅读34分钟
  • 单线程就意味着不能像多线程语言那样把工作委托给独立的线程或进程去做。
  • 单线程可以保证它与不同浏览器 API 兼容。
  • 工作者线程的价值所在:允许把主线程的工作转嫁给独立的实体,而不会改变现有的单线程模型。

1、工作者线程简介

  • JavaScript环境实际上是运行在托管操作系统中的虚拟环境
  • 在浏览器中每打开一个页面,就会分配一个它自己的环境
  • 每个页面都有自己的内存、事件循环、DOM等等。
  • 每个页面相当于一个沙盒,不会干扰其他页面。
  • 同时管理多个环境非常简单,这些环境都是并行执行的。
  • 使用工作者线程,浏览器可以在原始页面环境之外再分配一个完全独立的二级子环境。这个子环境不能与依赖单线程交互的API互操作,但可以与富环境并行执行代码。

1、工作者线程与线程

  • 相似之处
    • 工作者线程是以实际线程实现的
    • 工作者线程并行执行
    • 工作者线程可以共享某些内存
  • 区别之处
    • 工作者线程不共享全部内存
    • 工作者线程不一定在同一个进程里
    • 创建工作者线程的开销更大
  • 工作者线程相对比较重,不建议大量使用。
  • 工作者线程应该是长期运行的,启动成本比较高, 每个实例占用的内存也比较大。

2、工作者线程的类型

1、专用工作者线程

  • 简称为工作者线程、Web Worker 或 Worker,是一种实用的工具
  • 让脚本单独创建一个 JavaScript 线程,以执行委托的任务。
  • 只能被创建它的页面使用。

2、共享工作者线程

  • 可以被多个不同的上下文使用,包括不同的页面
  • 任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送 消息或从中接收消息。

3、服务工作者线程

  • 主要用途是拦截、重定向和修 改页面发出的请求,充当网络请求的仲裁者的角色

3、WorkerGlobalScope

  • 网页上window 对象可以向运行在其中的脚本暴露各种全局变量。
  • 在工作者线程内部,没有 window 的概念,全局对象是 WorkerGlobalScope 的实例,通过 self 关键字暴露出来

1、WorkerGlobalScope 属性和方法

  • self 上可用的属性是 window 对象上属性的严格子集

    • navigator:返回与工作者线程关联的 WorkerNavigator。

    • self:返回 WorkerGlobalScope 对象。

    • location:返回与工作者线程关联的 WorkerLocation。

    • performance:返回(只包含特定属性和方法的)Performance 对象。

    • console:返回与工作者线程关联的 Console 对象;对 API 没有限制。

    • caches:返回与工作者线程关联的 CacheStorage 对象;对 API 没有限制。

    • indexedDB:返回 IDBFactory 对象。

    • isSecureContext:返回布尔值,表示工作者线程上下文是否安全。

    • origin:返回 WorkerGlobalScope 的源。

  • self 对象上暴露的一些方法也是 window 上方法的子集

    • atob()

    • btoa()

    • clearInterval()

    • clearTimeout()

    • createImageBitmap()

    • fetch()

    • setInterval()

    • setTimeout()

  • 还增加了新的全局方法 importScripts(),只在工作者线程内可用

2、WokerClobalScope子类

  • 不是所有地方都实现了 WorkerGlobalScope。
  • 每种类型的工作者线程都使用了自己特定的全局对象,继承自 WorkerGlobalScope
    • 专用工作者线程使用 DedicatedWorkerGlobalScope
    • 共享工作者线程使用 SharedWorkerGlobalScope
    • 服务工作者线程使用 ServiceWorkerGlobalScope

2、专用工作者线程

  • 专用工作者线程是最简单的 Web 工作者线程,网页中的脚本可以创建专用工作者线程来执行在页面线程之外的其他任务
  • 可以与父页面交换信息、发送网络请求、执行文件输入/输出、进行密集计算、处理大量数据,以及实现其他不适合在页面执行线程里做的任务

1、专用工作者线程的基本概念

  • 把专用工作者线程称为后台脚本
  • JavaScript 线程的各个方面,包括生命周期管理、代码路径和输入/输出,都由初始化线程时提供的脚本来控制
  • 该脚本也可以再请求其他脚本, 但一个线程总是从一个脚本源开始

1、创建专用工作者线程

  • 创建专用工作者线程最常见的方式是加载 JavaScript 文件。
  • 把文件路径提供给 Worker 构造函数, 然后构造函数再在后台异步加载脚本并实例化工作者线程。
emptyWorker.js
// 空的 JS 工作者线程文件

main.js
console.log(location.href); // "https://example.com/"
const worker = new Worker(location.href + 'emptyWorker.js');
console.log(worker); // Worker {}
  • 概念涉及
    • emptyWorker.js 文件是从绝对路径加载的。根据应用程序的结构,使用绝对 URL 经常是多余的
    • 这个文件是在后台加载的,工作者线程的初始化完全独立于 main.js。
    • 工作者线程本身存在于一个独立的 JavaScript 环境中,因此 main.js 必须以 Worker 对象为代理实 现与工作者线程通信
    • 虽然相应的工作者线程可能还不存在,但该 Worker 对象已在原始环境中可用了
  • 相对路径
const worker = new Worker('./emptyWorker.js');
console.log(worker); // Worker {}

2、工作者线程安全限制

  • 脚本文件只能从与父页面相同的源加载。从其他源加载工作者线程的脚本文件会导致 错误
// 尝试基于 https://example.com/worker.js 创建工作者线程
const sameOriginWorker = new Worker('./worker.js');

// 尝试基于 https://untrusted.com/worker.js 创建工作者线程
const remoteOriginWorker = new Worker('https://untrusted.com/worker.js');
// Error: Uncaught DOMException: Failed to construct 'Worker':
// Script at https://untrusted.com/main.js cannot be accessed
// from origin https://example.com 
  • 不能使用非同源脚本创建工作者线程,并不影响执行其他源的脚本

  • 在工作者线程内部,使用 importScripts()可以加载其他源的脚本

  • 基于加载脚本创建的工作者线程不受文档的内容安全策略限制,因为工作者线程在与父文档不同的 上下文中运行

  • 如果工作者线程加载的脚本带有全局唯一标识符(与加载自一个二进制大文件一 样),就会受父文档内容安全策略的限制。

3、使用Worker对象

  • Worker()构造函数返回的 Worker 对象是与刚创建的专用工作者线程通信的连接点
  • 可用于在工作者线程和父上下文间传输信息,以及捕获专用工作者线程发出的事件
  • Worker对象支持下列事件处理程序属性
    • onerror:在工作者线程中发生 ErrorEvent 类型的错误事件时会调用指定给该属性的处理程序
      • 该事件会在工作者线程中抛出错误时发生
      • 该事件也可以通过 worker.addEventListener('error', handler)的形式处理
    • onmessage:在工作者线程中发生 MessageEvent 类型的消息事件时会调用指定给该属性的处 理程序
      • 该事件会在工作者线程向父上下文发送消息时发生
      • 该事件也可以通过使用 worker.addEventListener('message', handler)处理
    • onmessageerror:在工作者线程中发生 MessageEvent 类型的错误事件时会调用指定给该属 性的处理程序
      • 该事件会在工作者线程收到无法反序列化的消息时发生
      • 该事件也可以通过使用 worker.addEventListener('messageerror', handler)处理
  • Woker对象还支持以下方法
    • postMessage():用于通过异步消息事件向工作者线程发送信息
    • terminate():用于立即终止工作者线程。没有为工作者线程提供清理的机会,脚本会突然停止

4、 DedicatedWorkerGlobalScope

  • 专用工作者线程内部,全局作用域是 DedicatedWorkerGlobalScope 的实例
  • 这继承自 WorkerGlobalScope,所以包含它的所有属性和方法
  • 可以通过 self 关键字访问该全局 作用域
globalScopeWorker.js
console.log('inside worker:', self);

main.js
const worker = new Worker('./globalScopeWorker.js');
console.log('created worker:', worker);
// created worker: Worker {}
// inside worker: DedicatedWorkerGlobalScope {}
  • 这里两个独立的 JavaScript 线程都在向一个 console 对象发消息,该对象随后将消 息序列化并在浏览器控制台打印出来

  • 浏览器从两个不同的 JavaScript 线程收到消息,并按照自己认为合适的顺序输出这些消息。

  • DedicatedWorkerGlobalScope 在 WorkerGlobalScope 基础上增加了以下属性和方法

    • name:可以提供给 Worker 构造函数的一个可选的字符串标识符
    • postMessage():与 worker.postMessage()对应的方法,用于从工作者线程内部向父上下文发送消息
    • close():与 worker.terminate()对应的方法,用于立即终止工作者线程。没有为工作者线程提供清理的机会,脚本会突然停止
    • importScripts():用于向工作者线程中导入任意数量的脚本

2、专用工作者线程与隐式MessagePorts

  • 有一些相同接口处理程序和方法:onmessage、onmessageerror、close()和 postMessage()
  • 专用工作者线程隐式使用了 MessagePorts 在两个上下文之间通信
  • 父上下文中的 Worker 对象和 DedicatedWorkerGlobalScope 实际上融合了 MessagePort,并在自己的接口中分别暴露了相应的处理程序和方法
  • 不一致的地方,比如 start()和 close()约定。专用工作者线程会自动发送排队的消息,因 此 start()也就没有必要了。另外,close()在专用工作者线程的上下文中没有意义,因为这样关闭 MessagePort 会使工作者线程孤立
  • 在工作者线程内部调用 close()(或在外部调用 terminate())不仅会关闭 MessagePort,也会终止线程

3、专用工作者线程的声明周期

  • 调用 Worker()构造函数是一个专用工作者线程生命的起点
  • 会初始化对工作者线程 脚本的请求,并把 Worker 对象返回给父上下文
  • 父上下文中可以立即使用这个 Worker 对象,但 与之关联的工作者线程可能还没有创建,因为存在请求脚本的网格延迟和初始化延迟
  • 专用工作者线程可以非正式区分为处于下列三个状态:初始化(initializing)、活动(active) 和终止(terminated)。
  • 与活动的专用 工作者线程关联的 Worker 对象和与终止的专用工作者线程关联的 Worker 对象无法分别
  • 初始化时,虽然工作者线程脚本尚未执行,但可以先把要发送给工作者线程的消息加入队列
initializingWorker.js
self.addEventListener('message', ({data}) => console.log(data));

main.js
const worker = new Worker('./initializingWorker.js');

// Worker 可能仍处于初始化状态
// 但 postMessage()数据可以正常处理
worker.postMessage('foo');
worker.postMessage('bar');
worker.postMessage('baz');

// foo
// bar
// baz 
  • 创建之后,专用工作者线程就会伴随页面的整个生命期而存在,除非自我终止self.close() 或通过外部终止worker.terminate()
  • 即使线程脚本已运行完成,线程的环境仍会存在。只要工 作者线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉
  • 自我终止和外部终止最终都会执行相同的工作者线程终止例程
closeWorker.js
self.postMessage('foo');
self.close();
self.postMessage('bar');
setTimeout(() => self.postMessage('baz'), 0);

main.js
const worker = new Worker('./closeWorker.js');
worker.onmessage = ({data}) => console.log(data);

// foo
// bar
  • 虽然调用了 close(),但显然工作者线程的执行并没有立即终止。close()在这里会通知工作者线 程取消事件循环中的所有任务,并阻止继续添加新任务。
  • 工 作者线程不需要执行同步停止
  • 外部终止的例子:
terminateWorker.js
self.onmessage = ({data}) => console.log(data);

main.js
const worker = new Worker('./terminateWorker.js');

// 给 1000 毫秒让工作者线程初始化
setTimeout(() => {
 worker.postMessage('foo');
 worker.terminate();
 worker.postMessage('bar');
 setTimeout(() => worker.postMessage('baz'), 0);
}, 1000);
// foo 
  • 一旦调用了 terminate(),工作者线程的消息队列就会被清理并锁住
  • 在整个生命周期中,一个专用工作者线程只会关联一个网页
  • 除非明确终止,否则只要关联文档存在,专用工作者线程就会存在
  • 如果浏览器离开网页(通过导航或关闭标签页或关闭窗口),它会将与其关联的工作者线程标记为终止,它们的执行也会立即停止

4、配置Worker选项

  • Worker() 构造函数允许将可选的配置对象作为第二个参数
    • name 可以在工作者线程中通过 self.name 读取到的字符串标识符
    • type 表示加载脚本的运行方式
      • classic 将脚本作为常规脚本来执行
      • module 将脚本作为模块来执行
    • credentials type 为"module"时,指定如何获取与传输凭证数据相关的工作者线程模块脚本, 与 fetch()的凭证选项相同
      • omit
      • same-orign
      • include

5、在JavaScript行内创建工作者线程

  • 工作者线程需要基于脚本文件来创建,但这并不意味着该脚本必须是远程资源
  • 专用工作者线程也可以通过 Blob 对象 URL 在行内脚本创建
  • 可以更快速地初始化工作者线程,因为没有网络延迟
// 创建要执行的 JavaScript 代码字符串
const workerScript = `
 self.onmessage = ({data}) => console.log(data);
`;

// 基于脚本字符串生成 Blob 对象
const workerScriptBlob = new Blob([workerScript]);

// 基于 Blob 实例创建对象 URL
const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob);

// 基于对象 URL 创建专用工作者线程
const worker = new Worker(workerScriptBlobUrl);
worker.postMessage('blob worker script');
// blob worker script

—>

const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage =
({data}) => console.log(data);`])));
worker.postMessage('blob worker script');
// blob worker script 
  • 工作者线程也可以利用函数序列化来初始化行内脚本。这是因为函数的 toString()方法返回函数代码的字符串,而函数可以在父上下文中定义但在子上下文中执行
function fibonacci(n) {
	return n < 1 ? 0
		: n <= 2 ? 1
		: fibonacci(n - 1) + fibonacci(n - 2);
}

const workerScript = `
	self.postMessage(
		(${fibonacci.toString()})(9)
	);
`;

const worker = new Worker(URL.createObjectURL(new Blob([workerScript])));
worker.onmessage = ({data}) => console.log(data);
// 34 
  • 函数体内不能使用通过闭包获得的引用,也包括 全局变量

6、在工作者线程中动态执行脚本

  • 工作者线程中的脚本并非铁板一块,而是可以使用 importScripts()方法通过编程方式加载和执行任意脚本
  • 可用于全局 Worker 对象。这个方法会加载脚本并按照加载顺序同步执行
// main.js
const worker = new Worker('./worker.js');

// importing scripts
// scriptA executes
// scriptB executes
// scripts imported

// scriptA.js
console.log('scriptA executes');

// scriptB.js
console.log('scriptB executes');

// worker.js
console.log('importing scripts');

importScripts('./scriptA.js');
importScripts('./scriptB.js');

console.log('scripts imported'); 
  • importScripts()方法可以接收任意数量的脚本作为参数。
  • 浏览器下载它们的顺序没有限制,但 执行则会严格按照它们在参数列表的顺序进行
console.log('importing scripts');

importScripts('./scriptA.js', './scriptB.js');

console.log('scripts imported');
  • 脚本加载受到常规 CORS 的限制,但在工作者线程内部可以请求来自任何源的脚本
  • 这里的脚本导 入策略类似于使用生成的<script>标签动态加载脚本。在这种情况下,所有导入的脚本也会共享作用域
// main.js
const worker = new Worker('./worker.js', {name: 'foo'});
// importing scripts in foo with bar
// scriptA executes in foo with bar
// scriptB executes in foo with bar
// scripts imported

// scriptA.js
console.log(`scriptA executes in ${self.name} with ${globalToken}`);

// scriptB.js
console.log(`scriptB executes in ${self.name} with ${globalToken}`);

// worker.js
const globalToken = 'bar';

console.log(`importing scripts in ${self.name} with ${globalToken}`);

importScripts('./scriptA.js', './scriptB.js');

console.log('scripts imported'); 

7、委托任务到子工作者线程

  • 有时候需要在工作者线程中再创建子工作者线程,在有多个 CPU 核心的时候,使用多个子工作者线程可以实现并行计算
  • 使用多个子工作者线程前要考虑周全,确保并行计算的投入确实能够得到收益,毕竟同时运行多个子线程会有很大计算成本
  • 除了路径解析不同,创建子工作者线程与创建普通工作者线程是一样的,子工作者线程的脚本路径根据父工作者线程而不是相对于网页来解析
// main.js
const worker = new Worker('./js/worker.js');
// worker
// subworker

// js/worker.js
console.log('worker');
const worker = new Worker('./subworker.js');

// js/subworker.js
console.log('subworker');

8、处理工作者线程错误

  • 如果工作者线程脚本抛出了错误,该工作者线程沙盒可以阻止它打断父线程的执行
  • try/catch 块不会捕获到错误
// main.js
try {
	const worker = new Worker('./worker.js');
	console.log('no error');
} catch(e) {
	console.log('caught error');
}
// no error

// worker.js
throw Error('foo');
  • 相应的错误事件仍然会冒泡到工作者线程的全局上下文,可以通过在 Worker 对象上设 置错误事件侦听器访问到。
// main.js
const worker = new Worker('./worker.js');
worker.onerror = console.log;

// ErrorEvent {message: "Uncaught Error: foo"}

// worker.js
throw Error('foo');

9、与专用工作者线程通信

  • 与工作者线程的通信都是通过异步消息完成,这种消息可以有多种形式

1、使用postMessage()

  • 最常用最简单的形式。
// factorialWorker.js
function factorial(n) {
	let result = 1;
	while(n) { result *= n--; }
	return result;
}

self.onmessage = ({data}) => {
	self.postMessage(`${data}! = ${factorial(data)}`);
}; 

// main.js
const factorialWorker = new Worker('./factorialWorker.js');

factorialWorker.onmessage = ({data}) => console.log(data);

factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(10);

// 5! = 120
// 7! = 5040
// 10! = 3628800
  • 传递简单的消息,使用 postMessage()在主线程和工作者线程之间传递消息,与在两个窗口 间传递消息非常像
  • 主要区别是没有 targetOrigin 的限制,该限制是针对 Window.prototype. postMessage 的,对 WorkerGlobalScope.prototype.postMessageWorker.prototype. postMessage 没有影响
  • 原因很简单:工作者线程脚本的源被限制为主页的源,因此没有必要再去过滤了

2、使用MessageChannel

  • 无论主线程还是工作者线程,通过 postMessage()进行通信涉及调用全局对象上的方法,并定义 一个临时的传输协议。这个过程可以被 Channel Messaging API 取代

  • MessageChannel 实例有两个端口,分别代表两个通信端点,要让父页面和工作线程通过 MessageChannel 通信,需要把一个端口传到工作者线程中

// worker.js
// 在监听器中存储全局 messagePort
let messagePort = null;

function factorial(n) {
  let result = 1;
  while (n) {
    result *= n--;
  }
  return result;
}
// 在全局对象上添加消息处理程序
self.onmessage = ({ports}) => {
  // 只设置一次端口
  if (!messagePort) {
    // 初始化消息发送端口,
    // 给变量赋值并重置监听器
    messagePort = ports[0];
    self.onmessage = null;
    // 在全局对象上设置消息处理程序
    messagePort.onmessage = ({data}) => {
      // 收到消息后发送数据
      messagePort.postMessage(`${data}! = ${factorial(data)}`);
    };
  }
};


// main.js
const channel = new MessageChannel();
const factorialWorker = new Worker('./worker.js');

// 把`MessagePort`对象发送到工作者线程
// 工作者线程负责处理初始化信道
factorialWorker.postMessage(null, [channel.port1]);

// 通过信道实际发送数据
channel.port2.onmessage = ({data}) => console.log(data);

// 工作者线程通过信道响应
channel.port2.postMessage(5);

// 5! = 120 
  • 父页面通过 postMessage 与工作者线程共享 MessagePort
  • 使用数组语法是为 了在两个上下文间传递可转移对象
  • 工作者线程维护着对该端口的引用,并使用它代替通过全局对象传递消息
  • 消息的格式也需要临时约定:工作者线程收到的第一条消息包含端口,后续的消息才是数据
  • 使用 MessageChannel 实例与父页面通信很大程度上是多余的。这是因为全局 postMessage() 方法本质上与 channel.postMessage()执行的是同样的操作
  • MessageChannel 真正有用的地方是让两个工作者线程之间直接通信,这可以通过把端口传给另一个工作者线程实现。
// main.js
const channel = new MessageChannel();
const workerA = new Worker('./worker.js');
const workerB = new Worker('./worker.js');

workerA.postMessage('workerA', [channel.port1]);
workerB.postMessage('workerB', [channel.port2]);

workerA.onmessage = ({data}) => console.log(data);
workerB.onmessage = ({data}) => console.log(data);

workerA.postMessage(['page']);

// ['page', 'workerA', 'workerB']

workerB.postMessage(['page'])

// ['page', 'workerB', 'workerA'] 

// worker.js
let messagePort = null;
let contextIdentifier = null;

function addContextAndSend(data, destination) {
  // 添加标识符以标识当前工作者线程
  data.push(contextIdentifier);
  // 把数据发送到下一个目标
  destination.postMessage(data);
}
self.onmessage = ({data, ports}) => {
  // 如果消息里存在端口(ports)
  // 则初始化工作者线程
  if (ports.length) {
    // 记录标识符
    contextIdentifier = data;
    // 获取 MessagePort
    messagePort = ports[0];
    // 添加处理程序把接收的数据
    // 发回到父页面
    messagePort.onmessage = ({data}) => {
      addContextAndSend(data, self);
    }
  } else {
    addContextAndSend(data, messagePort);
  }
};

3、使用BroadcastChannel

  • 同源脚本能够通过 BroadcastChannel 相互之间发送和接收消息
  • 这种通道类型的设置比较简单, 不需要像 MessageChannel 那样转移乱糟糟的端口
// main.js
const channel = new BroadcastChannel('worker_channel');
const worker = new Worker('./worker.js');

channel.onmessage = ({data}) => {
	console.log(`heard ${data} on page`);
}

setTimeout(() => channel.postMessage('foo'), 1000);
// heard foo in worker
// heard bar on page


// worker.js
const channel = new BroadcastChannel('worker_channel');
channel.onmessage = ({data}) => {
	console.log(`heard ${data} in worker`);
	channel.postMessage('bar');
}

10、工作者线程数据传输

  • 使用工作者线程时,经常需要为它们提供某种形式的数据负载
  • 工作者线程是独立的上下文,因此 在上下文之间传输数据就会产生消耗
  • 在支持传统多线程模型的语言中,可以使用锁、互斥量,以及 volatile 变量。
  • 有三种在上下文间转移信息的方式:结构化克隆算法(structured clone algorithm)、可转移对象(transferable objects)和共享数组缓冲区(shared array buffers)

1、结构化克隆算法

  • 可用于在两个独立上下文间共享数据。该算法由浏览器在后台实现,不能直接调用

  • 结构化克隆算法支持的类型

    • 除 Symbol 之外的所有原始类型

    • Boolean 对象

    • String 对象

    • BDate

    • RegExp

    • Blob

    • File

    • FileList

    • ArrayBuffer

    • ArrayBufferView

    • ImageData

    • Array

    • Object

    • Map

    • Set

  • 需要注意的点

    • 复制之后,源上下文中对该对象的修改,不会传播到目标上下文中的对象
    • 结构化克隆算法可以识别对象中包含的循环引用,不会无穷遍历对象
    • 克隆 Error 对象、Function 对象或 DOM 节点会抛出错误
    • 结构化克隆算法并不总是创建完全一致的副本
    • 对象属性描述符、获取方法和设置方法不会克隆,必要时会使用默认值
    • 原型链不会克隆
    • RegExp.prototype.lastIndex 属性不会克隆
  • 结构化克隆算法在对象比较复杂时会存在计算性消耗

2、可转移对象

  • 使用可转移对象(transferable objects)可以把所有权从一个上下文转移到另一个上下文,在不太可 能在上下文间复制大量数据的情况下,这个功能特别有用
    • ArrayBuffer
    • MessagePort
    • ImageBitmap
    • OffscreenCanvas
  • postMessage()方法的第二个可选参数是数组,它指定应该将哪些对象转移到目标上下文
  • 在遍历消息负载对象时,浏览器根据转移对象数组检查对象引用,并对转移对象进行转移而不复制它们
  • 被转移的对象可以通过消息负载发送,消息负载本身会被复制,比如对象或数组
// 对 ArrayBuffer 的常规结构化克隆:

// main.js
const worker = new Worker('./worker.js');
// 创建 32 位缓冲区
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32

// worker.js
self.onmessage = ({data}) => {
	console.log(`worker's buffer size: ${data.byteLength}`); // 32
};


// 如果把 ArrayBuffer 指定为可转移对象,那么对缓冲区内存的引用就会从父上下文中抹去,然后分配给工作者线程

// main.js
const worker = new Worker('./worker.js');
// 创建 32 位缓冲区
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer, [arrayBuffer]);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0

// worker.js
self.onmessage = ({data}) => {
	console.log(`worker's buffer size: ${data.byteLength}`); // 32
};
  • 在其他类型的对象中嵌套可转移对象也完全没有问题。包装对象会被复制,而嵌套的对象会被转移
// main.js
const worker = new Worker('./worker.js');
// 创建 32 位缓冲区
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage({foo: {bar: arrayBuffer}}, [arrayBuffer]);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0
// worker.js
self.onmessage = ({data}) => {
	console.log(`worker's buffer size: ${data.foo.bar.byteLength}`); // 32
}; 

3、SharedArrayBuffer

  • 既不克隆,也不转移,SharedArrayBuffer 作为 ArrayBuffer 能够在不同浏览器上下文间共享
  • 在把 SharedArrayBuffer 传给 postMessage()时,浏览器只会传递原始缓冲区的引用。
  • 两 个不同的 JavaScript 上下文会分别维护对同一个内存块的引用。每个上下文都可以随意修改这个缓冲区, 就跟修改常规 ArrayBuffer 一样
// main.js
const worker = new Worker('./worker.js');
// 创建 1 字节缓冲区
const sharedArrayBuffer = new SharedArrayBuffer(1);
// 创建 1 字节缓冲区的视图
const view = new Uint8Array(sharedArrayBuffer);
// 父上下文赋值 1
view[0] = 1;
worker.onmessage = () => {
  console.log(`buffer value after worker modification: ${view[0]}`);
};
// 发送对 sharedArrayBuffer 的引用
worker.postMessage(sharedArrayBuffer);
// buffer value before worker modification: 1
// buffer value after worker modification: 2

// worker.js
self.onmessage = ({data}) => {
  const view = new Uint8Array(data);
  console.log(`buffer value before worker modification: ${view[0]}`);
  // 工作者线程为共享缓冲区赋值
  view[0] += 1;
  // 发送空消息,通知赋值完成
  self.postMessage(null);
};
  • 在两个并行线程中共享内存块有资源争用的风险,实际上会被当成易变(volatile)内存
// main.js
// 创建包含 4 个线程的线程池
const workers = [];
for (let i = 0; i < 4; ++i) {
  workers.push(new Worker('./worker.js'));
}

// 在最后一个工作者线程完成后打印最终值
let responseCount = 0;
for (const worker of workers) {
  worker.onmessage = () => {
    if (++responseCount == workers.length) {
      console.log(`Final buffer value: ${view[0]}`);
    }
  };
}

// 初始化 SharedArrayBuffer
const sharedArrayBuffer = new SharedArrayBuffer(4);
const view = new Uint32Array(sharedArrayBuffer);
view[0] = 1;

// 把 SharedArrayBuffer 发给每个线程
for (const worker of workers) {
  worker.postMessage(sharedArrayBuffer);
}

// (期待结果为 4000001。实际输出类似于:)
// Final buffer value: 2145106

// worker.js
self.onmessage = ({data}) => {
  const view = new Uint32Array(data);
  // 执行 100 万次加操作
  for (let i = 0; i < 1E6; ++i) {
    view[0] += 1;
  }
  self.postMessage(null);
};
  • 每个工作者线程都顺序执行了 100 万次加操作,每次都读取共享数组的索引、执行一次加操 作,然后再把值写回数组索引。在所有工作者线程读/写操作交织的过程中就会发生资源争用
  • 为解决该问题,可以使用 Atomics 对象让一个工作者线程获得 SharedArrayBuffer 实例的锁, 在执行完全部读/写/读操作后,再允许另一个工作者线程执行操作
// main.js
// 创建包含 4 个线程的线程池
const workers = [];
for (let i = 0; i < 4; ++i) {
  workers.push(new Worker('./worker.js'));
}

// 在最后一个工作者线程完成后打印最终值
let responseCount = 0;
for (const worker of workers) {
  worker.onmessage = () => {
    if (++responseCount == workers.length) {
      console.log(`Final buffer value: ${view[0]}`);
    }
  };
}

// 初始化 SharedArrayBuffer
const sharedArrayBuffer = new SharedArrayBuffer(4);
const view = new Uint32Array(sharedArrayBuffer);
view[0] = 1;

// 把 SharedArrayBuffer 发给每个线程
for (const worker of workers) {
  worker.postMessage(sharedArrayBuffer);
}

//(期待结果为 4000001)
// Final buffer value: 4000001


// worker.js
self.onmessage = ({data}) => {
  const view = new Uint32Array(data);
  // 执行 100 万次加操作
  for (let i = 0; i < 1E6; ++i) {
    Atomics.add(view, 0, 1);
  }
  self.postMessage(null);
};

11、线程池

  • 因为启用工作者线程代价很大,所以某些情况下可以考虑始终保持固定数量的线程活动,需要时就把任务分派给它们
  • 工作者线程在执行计算时,会被标记为忙碌状态。直到它通知线程池自己空闲了, 才准备好接收新任务。这些活动线程就称为“线程池”或“工作者线程池”。
  • 一种使用线程池的策略是每个线程都执行同样的任务,但具体执行什么任务由几个参数来控制
  • 通过使用特定于任务的线程池,可以分配固定数量的工作者线程,并根据需要为他们提供参数
  • 工作者线程会接收这些参数,执行耗时的计算,并把结果返回给线程池。然后线程池可以再将其他工作分派给工作者线程去执行
  • TaskWorker 类负责两件事:跟踪线程 是否正忙于工作,并管理进出线程的信息与事件。另外,传入给这个工作者线程的任务会封装到一个期 约中,然后正确地解决和拒绝。
class TaskWorker extends Worker {
  constructor(notifyAvailable, ...workerArgs) {
    super(...workerArgs)
    
    // 初始化为不可用状态
    this.available = false
    this.resolve = null
    this.reject = null
    
    // 线程池会传递回调
    // 以便工作者线程发出它需要新任务的信号
    this.notifyAvailable = notifyAvailable
    
    // 线程脚本在完全初始化之后
    // 会发送一条"ready"消息
    this.onmessage = () => this.setAvailable()
  }
  
  // 由线程池调用,以分派新任务
  dispatch({ resolve, reject, postMessageArgs }) {
    this.available = false
    
    this.onmessage = ({data}) => {
      resolve(data)
      this.setAvailable()
    }
    
    this.onerror = (e) => {
      reject(e)
      this.setAvailable()
    }
    
    this.postMessage(...postMessageArgs)
  }
  
  setAvailable() {
    this.available = true
    this.resolve = null
    this.reject = null
    this.notifyAvailable()
  }
}
  • 使用 TaskWorker 类的 WorkerPool 类。必须维护尚未分派给工作者线程的任务队列。两个事件可以表明应该分派一个新任务:新任务被添加到队列中,或者工作者线程完成了一个任务,应该再发送另一个任务。
class WorkerPool {
  constructor(poolSize, ...workerArgs) {
    this.taskQueue = []
    this.workers = []
    
    // 初始化线程池
    for(let i = 0; i < poolSize; i++) {
      this.worker.push(
      	new TaskWorker(() => this.dispatchIfAvailable(), ...workerArgs))
    }
  }
  
  // 把任务推入队列
  enqueue(...postMessageArgs) {
    return new Promise((resolve, reject) => {
      this.taskQueue.push({resolve, rejct, postMessageArgs})
      
      this.dispatchIfAvailable()
    })
  }
  
  // 把任务发送给下一个空闲的线程(如果有的话)
   dispatchIfAvailable() {
     if(!this.taskQueue.length) {
       return
     }
     for(const worker of this.workers) {
       if(worker.available) {
         let a = this.taskQueue.shift()
         worker.dispatch(a)
         break
       }
     }
   }

  // 终止所有工作者线程
  close() {
    for (const worker of this.workers) {
      worker.terminate()
    }
  }
}
  • 定义了这两个类之后,现在可以把任务分派到线程池,并在工作者线程可用时执行它们
  • 假设我们想计算 1000 万个浮点值之和。为节省转移成本,我们使用 SharedArrayBuffer。工 作者线程的脚本(worker.js)大致如下
self.onmessage = ({data}) => {
  let sum = 0;
  let view = new Float32Array(data.arrayBuffer)
  // 求和
  for (let i = data.startIdx; i < data.endIdx; ++i) {
    // 不需要原子操作,因为只需要读
    sum += view[i];
  }
  // 把结果发送给工作者线程
  self.postMessage(sum);
};

// 发送消息给 TaskWorker
// 通知工作者线程准备好接收任务了
self.postMessage('ready');
  • 利用线程池分派任务的代码可以这样写
Class TaskWorker {
  ...
]
Class WorkerPool {
  ...
}
const totalFloats = 1E8;
const numTasks = 20;
const floatsPerTask = totalFloats / numTasks;
const numWorkers = 4;
// 创建线程池
const pool = new WorkerPool(numWorkers, './worker.js');
// 填充浮点值数组
let arrayBuffer = new SharedArrayBuffer(4 * totalFloats);
let view = new Float32Array(arrayBuffer);
for (let i = 0; i < totalFloats; ++i) {
  view[i] = Math.random();
}
let partialSumPromises = [];
for (let i = 0; i < totalFloats; i += floatsPerTask) {
  partialSumPromises.push(
    pool.enqueue({
      startIdx: i,
      endIdx: i + floatsPerTask,
      arrayBuffer: arrayBuffer
    })
  );
}
// 等待所有期约完成,然后求和
Promise.all(partialSumPromises)
  .then((partialSums) => partialSums.reduce((x, y) => x + y))
  .then(console.log);
//(在这个例子中,和应该约等于 1E8/2)
// 49997075.47203197

3、共享工作者线程

  • 共享工作者线程或共享线程与专用工作者线程类似,但可以被多个可信任的执行上下文访问
  • 同源的两个标签页可以访问同一个共享工作者线程。SharedWorker 与 Worker 的消息接口稍有不同, 包括外部和内部
  • 适合开发者希望通过在多个上下文间共享线程减少计算性消耗的情形
  • 可以用一个 共享线程管理多个同源页面 WebSocket 消息的发送与接收。共享线程也可以用在同源上下文希望通过一 个线程通信的情形

1、共享工作者线程简介

  • 共享工作者线程可以看作是专用工作者线程的一个扩展。
  • 线程创建、线程选项、安全 限制和 importScripts()的行为都是相同的。
  • 与专用工作者线程一样,共享工作者线程也在独立执行上下文中运行,也只能与其他上下文异步通信

1、创建共享工作者线程

  • 创建共享工作者线程非常常用的方式是通过加载 JavaScript 文件创建
  • 需要给 SharedWorker 构造函数传入文件路径,该构造函数在后台异步加载脚本并实例化共享工作者线程
// emptySharedWorker.js
// 空的 JavaScript 线程文件

// main.js
console.log(location.href); // "https://example.com/"
const sharedWorker = new SharedWorker(location.href + 'emptySharedWorker.js');
console.log(sharedWorker); // SharedWorker {}
  • 可以修改为使用相对路径
  • 也可以在行内脚本中创建共享工作者线程,但这样做没什么意义。因为每个基于行内脚本字符串创 建的 Blob 都会被赋予自己唯一的浏览器内部 URL,所以行内脚本中创建的共享工作者线程始终是唯一 的

2、ShareWorker标识与独占

  • 虽然 Worker()构造函数始终会创建新实 例,而 SharedWorker()则只会在相同的标识不存在的情况下才创建新实例
  • 如果的确存在与标识匹配 的共享工作者线程,则只会与已有共享者线程建立新的连接
  • 共享工作者线程标识源自解析后的脚本 URL、工作者线程名称和文档源
// ---下面的脚本将实例化一个共享工作者线程并添加两个连接
// 实例化一个共享工作者线程
// - 全部基于同源调用构造函数
// - 所有脚本解析为相同的 URL
// - 所有线程都有相同的名称
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js'); 

// ---下面三个脚本字符串都解析到相同的 URL,所以也只会创建一个共享工作者线程
// 实例化一个共享工作者线程
// - 全部基于同源调用构造函数
// - 所有脚本解析为相同的 URL
// - 所有线程都有相同的名称
new SharedWorker('./sharedWorker.js');
new SharedWorker('sharedWorker.js');
new SharedWorker('https://www.example.com/sharedWorker.js'); 

// 因为可选的工作者线程名称也是共享工作者线程标识的一部分,所以不同的线程名称会强制浏览器创建多个共享工作者线程
// 实例化一个共享工作者线程
// - 全部基于同源调用构造函数
// - 所有脚本解析为相同的 URL
// - 一个线程名称为'foo',一个线程名称为'bar'
new SharedWorker('./sharedWorker.js', {name: 'foo'});
new SharedWorker('./sharedWorker.js', {name: 'foo'});
new SharedWorker('./sharedWorker.js', {name: 'bar'});
  • 共享线程,顾名思义,可以在不同标签页、不同窗口、不同内嵌框架或同源的其他工作者线程之间共享,如果在多个标签页运行,只会在第一次执行时创建一个共享工作者线程,后续执行会连接到该线程
// 实例化一个共享工作者线程
// - 全部基于同源调用构造函数
// - 所有脚本解析为相同的 URL
// - 所有线程都有相同的名称
new SharedWorker('./sharedWorker.js'); 
  • 初始化共享线程的脚本只会限制 URL,因此下面的代码会创建两个共享工作者线程
// 实例化一个共享工作者线程
// - 全部基于同源调用构造函数
// - '?'导致了两个不同的 URL
// - 所有线程都有相同的名称
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js?'); 

3、使用SharedWorker对象

  • SharedWorker()构造函数返回的 SharedWorker 对象被用作与新创建的共享工作者线程通信的 连接点。它可以用来通过 MessagePort 在共享工作者线程和父上下文间传递信息,也可以用来捕获共 享线程中发出的错误事件。
  • SharedWorker 对象支持以下属性
    • onerror 在共享线程发生错ErrorEvent类型的错误时间时会调用指定给该属性的处理程序,
      • 此事件会在共享线程抛出错误时发生
      • 此事件也可以通过使用 sharedWorker.addEventListener('error', handler)处理
    • port 专门用来跟共享线程通信的MessagePort

4、SharedWorkerGlobalScope

  • 在共享线程内部,全局作用域是 SharedWorkerGlobalScope 的实例
  • 与专用工作者线程一样,共享工 作者线程也可以通过 self 关键字访问该全局上下文
  • SharedWorkerGlobalScope 通过以下属性和方法扩展了 WorkerGlobalScope
    • name:可选的字符串标识符,可以传给 SharedWorker 构造函数
    • importScripts():用于向工作者线程中导入任意数量的脚本
    • close():与 worker.terminate()对应,用于立即终止工作者线程。没有给工作者线程提供 终止前清理的机会;脚本会突然停止
    • onconnect:与共享线程建立新连接时,应将其设置为处理程序。connect 事件包括 MessagePort 实例的 ports 数组,可用于把消息发送回父上下文
      • 在通过 worker.port.onmessage 或 worker.port.start()与共享线程建立连接时都会触发 connect 事件
      • connect 事件也可以通过使用 sharedWorker.addEventListener('connect', handler) 处理

2、理解共享工作者线程的声明周期

  • 共享工作者线程的生命周期具有与专用工作者线程相同的阶段的特性
  • 专用工作者 线程只跟一个页面绑定,而共享工作者线程只要还有一个上下文连接就会持续存在
  • 没有办法以编程方式终止共享线程。SharedWorker 对象上没有 terminate()方法。在共享线程端口上调用 close()时,只要还有一个端口连接到该线程就不会真的终止线程
  • 只要建立了 连接,浏览器会负责管理该连接。建立的连接会在页面的生命周期内持续存在,只有当页面销毁且没有连接时,浏览器才会终止共享线程

3、连接到共享工作者线程

  • 每次调用 SharedWorker()构造函数,无论是否创建了工作者线程,都会在共享线程内部触发 connect 事件
sharedWorker.js
let i = 0;
self.onconnect = () => console.log(`connected ${++i} times`);
main.js
for (let i = 0; i < 5; ++i) {
	new SharedWorker('./sharedWorker.js');
}
// connected 1 times
// connected 2 times
// connected 3 times
// connected 4 times
// connected 5 times 
// sharedWorker.js
const connectedPorts = new Set();
self.onconnect = ({ports}) => {
	connectedPorts.add(ports[0]);
	console.log(`${connectedPorts.size} unique connected ports`);
};

// main.js
for (let i = 0; i < 5; ++i) {
	new SharedWorker('./sharedWorker.js');
}
// 1 unique connected ports
// 2 unique connected ports
// 3 unique connected ports
// 4 unique connected ports
// 5 unique connected ports 
  • 共享线程与父上下文的启动和关闭不是对称的。每个新 SharedWorker 连接都会触发 一个事件,但没有事件对应断开 SharedWorker 实例的连接
  • 一个解决方案是在 beforeunload 事件即将销毁页面时, 明确发送卸载消息,让共享线程有机会清除死端口

4、服务工作者线程

  • 是一种类似浏览器中代理服务器的线程,可以拦截外出请求和缓 存响应
  • 可以让网页在没有网络连接的情况下正常使用,因为部分或全部页面可以从服务工作者线程 缓存中提供服务
  • 主要任务:
    • 充当网络请求的缓存层
    • 启用推送通知
  • 服务工作者线程就是用于把网页变成像原生应用程序一样的工具

1、服务工作者线程基础

  • 共性 在独立上下文中运行,只能通过异步消息通信。

1、ServiceWorkerContainer

  • 服务工作者线程与专用工作者线程或共享工作者线程的一个区别是没有全局构造函数
  • 是通过 ServiceWorkerContainer 来管理的,它的实例保存在 navigator.serviceWorker 属性中。
  • 该对象是个顶级接口,通过它可以让浏览器创建、更新、销毁或者与服务工作者线程交互
console.log(navigator.serviceWorker);
// ServiceWorkerContainer { ... }

2、创建服务工作者线程

  • 服务工作者线程同样是在还不存在时创建新实例,在存在时连接到已有实例
  • ServiceWorkerContainer 没有通过全局构造函数创建,而是暴露了 register()方法,该方法 以与 Worker()或 SharedWorker()构造函数相同的方式传递脚本 URL
// emptyServiceWorker.js
// 空服务脚本

// main.js
navigator.serviceWorker.register('./emptyServiceWorker.js');
  • register()方法返回一个期约,该期约解决为 ServiceWorkerRegistration 对象,或在注册失败时拒绝
// emptyServiceWorker.js
// 空服务脚本

// main.js
// 注册成功,成功回调(解决)
navigator.serviceWorker.register('./emptyServiceWorker.js')
	.then(console.log, console.error);
// ServiceWorkerRegistration { ... }
// 使用不存在的文件注册,失败回调(拒绝)
navigator.serviceWorker.register('./doesNotExist.js')
	.then(console.log, console.error); 
// TypeError: Failed to register a ServiceWorker:
// A bad HTTP response code (404) was received when fetching the script.
  • 在第一次调用 register()激活服务工作者线程后, 后续在同一个页面使用相同 URL 对 register()的调用实际上什么也不会执行
  • 即使浏览器未 全局支持服务工作者线程,服务工作者线程本身对页面也应该是不可见的
  • 注册服务工作者线程的一种非常常见的模式是基于特性检测
if ('serviceWorker' in navigator) {
	window.addEventListener('load', () => {
		navigator.serviceWorker.register('./serviceWorker.js');
	});
} 

3、使用ServiceWorkerContainer对象

  • 是浏览器对服务工作者线程生态的顶部封装。它为管理服务工作者线程状态和生命周期提供了便利,始终可以在客户端上下文中访问
console.log(navigator.serviceWorker);
// ServiceWorkerContainer { ... } 
  • 支持以下事件处理程序
    • oncontrollerchange:在 ServiceWorkerContainer 触发 controllerchange 事件时会 调用指定的事件处理程序。
      • 此事件在获得新激活的 ServiceWorkerRegistration 时触发
      • 此事件也可以使用 addEventListener 处理
    • onerror:在关联的服务工作者线程触发ErrorEvent 错误事件时会调用指定的事件处理程序
      • 此事件在关联的服务工作者线程内部抛出错误时触发
      • 此事件也可以使用 addEventListener 处理
    • onmessage:在服务工作者线程触发发 MessageEvent 事件时会调用指定的事件处理程序
      • 此事件在服务脚本向父上下文发送消息时触发
      • 此事件也可以使用 addEventListener 处理
  • 支持下列属性
    • ready:返回期约,解决为激活的ServiceWorkerRegistration 对象。该期约不会拒绝。
    • controller:返回与当前页面关联的激活的ServiceWorker 对象,如果没有激活的服务工作 者线程则返回 null
  • 支持下列方法
    • register():使用接收的 url 和 options 对象创建或更新 ServiceWorkerRegistration
    • getRegistration():返回期约,解决为与提供的作用域匹配的 ServiceWorkerRegistration 对象;如果没有匹配的服务工作者线程则返回 undefined
    • getRegistrations():返回期约,解决为与 ServiceWorkerContainer 关联的 ServiceWorkerRegistration 对象的数组;如果没有关联的服务工作者线程则返回空数组
    • startMessage():开始传送通过 Client.postMessage()派发的消息

4、使用 ServiceWorkerRegistration 对象

  • ServiceWorkerRegistration 对象表示注册成功的服务工作者线程。该对象可以在 register() 返回的解决期约的处理程序中访问到。
  • 调用 navigator.serviceWorker.register()之后返回的期约会将注册成功的 ServiceWorkerRegistration 对象(注册对象)发送给处理函数
navigator.serviceWorker.register('./serviceWorker.js')
  .then((registrationA) => {
    console.log(registrationA);
    navigator.serviceWorker.register('./serviceWorker2.js')
      .then((registrationB) => {
        console.log(registrationA === registrationB);
      });
  });
  • 支持以下事件处理程序
    • onupdatefound:在服务工作者线程触发 updatefound 事件时会调用指定的事件处理程序
      • 此事件会在服务工作者线程开始安装新版本时触发,表现为 ServiceWorkerRegistration. installing 收到一个新的服务工作者线程。
      • 此事件也可以使用 addEventListener 处理
  • 支持以下通用属性
    • scope:返回服务工作者线程作用域的完整 URL 路径。该值源自接收服务脚本的路径和在 register()中提供的作用域
    • navigationPreload:返回与注册对象关联的 NavigationPreloadManager 实例
    • pushManager:返回与注册对象关联的 pushManager 实例。
  • 还支持以下属性,可用于判断服务工作者线程处于生命周期的什 么阶段
    • installing:如果有则返回状态为 installing(安装)的服务工作者线程,否则为 null
    • waiting:如果有则返回状态为 waiting(等待)的服务工作者线程,否则为 null
    • active:如果有则返回状态 activating 或 active(活动)的服务工作者线程,否则为 null。
  • 支持下列方法
    • getNotifications():返回期约,解决为 Notification 对象的数组
    • showNotifications():显示通知,可以配置 title 和 options 参数
    • update():直接从服务器重新请求服务脚本,如果新脚本不同,则重新初始化
    • unregister():取消服务工作者线程的注册。该方法会在服务工作者线程执行完再取消注册

5、使用ServiceWorker对象

  • 获得方式
    • 通过 ServiceWorkerContainer 对象的 controller 属性
    • 通过 ServiceWorkerRegistration 的 active 属性
  • 该对象继承 Worker 原型,因此包括其 所有属性和方法,但没有 terminate()方法
  • 支持以下事件处理程序
    • onstatechange:ServiceWorker 发生 statechange 事件时会调用指定的事件处理程序
      • 此事件会在 ServiceWorker.state 变化时发生
      • 此事件也可以使用 addEventListener 处理
  • 支持以下属性
    • scriptURL 解析后注册服务工作者线程的 URL
    • state:表示服务工作者线程状态的字符串,可能的值如下
      • installing
      • installed
      • activating
      • activated
      • redundant

6、服务工作者线程的安全限制

  • 服务工作者线程也受加载脚本对应源的常规限制
  • 服务工作者线程 API 只能在安全上下文(HTTPS)下使用

7、ServiceWorkerGlobalScope

  • 全局上下文是 ServiceWorkerGlobalScope 的实例
  • ServiceWorkerGlobalScope 通过以下属性和方法扩展了 WorkerGlobalScope
    • caches:返回服务工作者线程的 CacheStorage 对象
    • clients:返回服务工作者线程的Clients 接口,用于访问底层 Client 对象
    • registration:返回服务工作者线程的 ServiceWorkerRegistration 对象
    • skipWaiting():强制服务工作者线程进入活动状态;需要跟 Clients.claim()一起使用
    • fetch():在服务工作者线程内发送常规网络请求
  • 服务工作者线程的全局作用域可以监听以下事件
    • 服务工作者线程状态
      • install:在服务工作者线程进入安装状态时触发,也可以在self.onintall 属性上指定该事件的处理程序
        • 这是服务工作者线程接收的第一个事件,在线程一开始执行时就会触发
        • 每个服务工作者线程只会调用一次
      • activate:在服务工作者线程进入 激 活 或 已激活 状态时触发,也可以在 self.onactive 属性上指定该事件 的处理程序
        • 此事件在服务工作者线程准备好处理功能性事件和控制客户端时触发
        • 此事件并不代表服务工作者线程在控制客户端,只表明具有控制客户端的条件
    • Fetch API
      • fetch:在服务工作者线程截获来自主页面的 fetch()请求时触发,也可以在 self.onfetch 属性 上指定该事件的处理程序
    • Message API
      • message:在服务工作者线程通过 postMesssage()获取数据时触发。也可以在 self.onmessage 属性上指定该事件的处理程序
    • Notification API
      • notificationclick:在系统告诉浏览器用户点击了 ServiceWorkerRegistration.showNotification()生成的通知时触发。也可以在 self.onnotificationclick 属性上指定该事件的 处理程序
      • notificationclose:在系统告诉浏览器用户关闭或取消显示了 ServiceWorkerRegistration. showNotification()生成的通知时触发。也可以在 self.onnotificationclose 属性上指 定该事件的处理程序
    • Push API
      • push:在服务工作者线程接收到推送消息时触发。也可以在 self.onpush 属性上指定该事件的处理程序
      • pushsubscriptionchange:在应用控制外的因素(非 JavaScript 显式操作)导致推送订阅状 态变化时触发。也可以在 self.onpushsubscriptionchange 属性上指定该事件的处理程序

8、服务工作者线程作用域限制

  • 服务工作者线程只能拦截其作用域内的客户端发送的请求。作用域是相对于获取服务脚本的路径定义的
  • 通过根目录获取服务脚本对应的默认根作用域
navigator.serviceWorker.register('/serviceWorker.js')	.then((serviceWorkerRegistration) => {
	console.log(serviceWorkerRegistration.scope);
	// https://example.com/
});
// 以下请求都会被拦截:
// fetch('/foo.js');
// fetch('/foo/fooScript.js');
// fetch('/baz/bazScript.js');
  • 通过根目录获取服务脚本但指定了同一目录作用域
navigator.serviceWorker.register('/serviceWorker.js', {scope: './'})
.then((serviceWorkerRegistration) => {
	console.log(serviceWorkerRegistration.scope);
	// https://example.com/
});
// 以下请求都会被拦截:
// fetch('/foo.js');
// fetch('/foo/fooScript.js');
// fetch('/baz/bazScript.js');
  • 通过根目录获取服务脚本但限定了目录作用域
navigator.serviceWorker.register('/serviceWorker.js', {scope: './foo'})
.then((serviceWorkerRegistration) => {
	console.log(serviceWorkerRegistration.scope);
	// https://example.com/foo/
});
// 以下请求都会被拦截:
// fetch('/foo/fooScript.js');
// 以下请求都不会被拦截:
// fetch('/foo.js');
// fetch('/baz/bazScript.js');
  • 通过嵌套的二级目录获取服务脚本对应的同一目录作用域
navigator.serviceWorker.register('/foo/serviceWorker.js')
.then((serviceWorkerRegistration) => {
	console.log(serviceWorkerRegistration.scope);
	// https://example.com/foo/
});
// 以下请求都会被拦截:
// fetch('/foo/fooScript.js');
// 以下请求都不会被拦截:
// fetch('/foo.js');
// fetch('/baz/bazScript.js');
  • 服务工作者线程的作用域实际上遵循了目录权限模型,即只能相对于服务脚本所在路径缩小作用域
navigator.serviceWorker.register('/foo/serviceWorker.js', {scope: '/'});
// Error: The path of the provided scope 'https://example.com/'
// is not under the max scope allowed 'https://example.com/foo/' 

2、服务工作者线程缓存

  • 在服务工作者线程之前,网页缺少缓存网络请求的稳健机制。浏览器一直使用 HTTP 缓存,但 HTTP 缓存并没有对 JavaScript 暴露编程接口,且其行为是受 JavaScript 运行时外部控制的
  • 服务工作者线程的一个主要能力是可以通过编程方式实现真正的网络请求缓存机制。与 HTTP 缓存 或 CPU 缓存不同,服务工作者线程缓存非常简单
    • 服务工作者线程缓存不自动缓存任何请求
    • 服务工作者线程缓存没有到期失效的概念
    • 服务工作者线程缓存必须手动更新和删除
    • 缓存版本必须手动管理
    • 唯一的浏览器强制逐出策略基于服务工作者线程缓存占用的空间

1、CacheStorage 对象

  • CacheStorage 对象是映射到 Cache 对象的字符串键/值存储

  • CacheStorage 提供的 API 类似于 异步 Map

  • CacheStorage 的接口通过全局对象的 caches 属性暴露出来

  • CacheStorage 中的每个缓存可以通过给 caches.open()传入相应字符串键取得

  • Cache 对象是通过期约返回的

    caches.open('v1').then(console.log); 
    
  • Map 类似,CacheStorage 也有 has()、delete()和 keys()方法。这些方法与 Map 上对应方 法类似,但都基于期约

// 打开新缓存 v1
// 检查缓存 v1 是否存在
// 检查不存在的缓存 v2
caches.open('v1')
  .then(() => caches.has('v1'))
  .then(console.log) // true
  .then(() => caches.has('v2'))
  .then(console.log); // false

// 打开新缓存 v1
// 检查缓存 v1 是否存在
// 删除缓存 v1
// 再次检查缓存 v1 是否存在
caches.open('v1')
  .then(() => caches.has('v1'))
  .then(console.log) // true
	.then(() => caches.delete('v1'))
	.then(() => caches.has('v1'))
	.then(console.log); // false

// 打开缓存 v1、v3 和 v2
// 检查当前缓存的键
// 注意:缓存键按创建顺序输出
caches.open('v1')
  .then(() => caches.open('v3'))
  .then(() => caches.open('v2'))
  .then(() => caches.keys())
  .then(console.log); // ["v1", "v3", "v2"]
  • acheStorage 接口还有一个 match()方法,可以根据 Request 对象搜索 CacheStorage 中的所有 Cache 对象。
  • 搜索顺序是 CacheStorage.keys()的顺序
// 创建一个请求键和两个响应值
const request = new Request('');
const response1 = new Response('v1');
const response2 = new Response('v2');

// 用同一个键创建两个缓存对象,最终会先找到 v1
// 因为它排在 caches.keys()输出的前面
caches.open('v1')
  .then((v1cache) => v1cache.put(request, response1))
  .then(() => caches.open('v2'))
  .then((v2cache) => v2cache.put(request, response2))
  .then(() => caches.match(request))
  .then((response) => response.text())
  .then(console.log); // v1
  • CacheStorage.match()可以接收一个 options 配置对象

2、Cache 对象

  • CacheStorage 通过字符串映射到 Cache 对象。Cache 对象跟 CacheStorage 一样,类似于异步 的 Map。Cache 键可以是 URL 字符串,也可以是 Request 对象。这些键会映射到 Response 对象
  • 服务工作者线程缓存只考虑缓存 HTTP 的 GET 请求。这样是合理的,因为 GET 请求的响应通常不 会随时间而改变。另一方面,默认情况下,Cache 不允许使用 POST、PUT 和 DELETE 等请求方法。这 些方法意味着与服务器动态交换信息,因此不适合客户端缓存
  • 填充 Cache,可能使用以下三个方法
    • put(request, response):在键(Request 对象或 URL 字符串)和值(Response 对象) 同时存在时用于添加缓存项。该方法返回期约,在添加成功后会解决
    • add(request):在只有 Request 对象或 URL 时使用此方法发送 fetch()请求,并缓存响应。 该方法返回期约,期约在添加成功后会解决
    • addAll(requests):在希望填充全部缓存时使用,比如在服务工作者线程初始化时也初始化 缓存。该方法接收 URL 或 Request 对象的数组。addAll()会对请求数组中的每一项分别调用 add()。该方法返回期约,期约在所有缓存内容添加成功后会解决
  • 与 Map 类似,Cache 也有 delete()和 keys()方法。这些方法与 Map 上对应方法类似,但都基于期约
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse');
caches.open('v1')
	.then((cache) => {
  	cache.put(request1, response1)
			.then(() => cache.keys())
			.then(console.log) // [Request]
			.then(() => cache.delete(request1))
			.then(() => cache.keys())
			.then(console.log); // []
}); 
  • 检索 Cache
    • matchAll(request, options):返回期约,期约解决为匹配缓存中 Response 对象的数组
      • 此方法对结构类似的缓存执行批量操作,比如删除所有缓存在/images 目录下的值
      • 可以通过 options 对象配置请求匹配方式
    • match(request, options):返回期约,期约解决为匹配缓存中的 Response 对象;如果没命中缓存则返回 undefined
      • 本质上相当于 matchAll(request, options)[0]
      • 可以通过 options 对象配置请求匹配方式
  • 缓存是否命中取决于 URL 字符串和/或 Request 对象 URL 是否匹配,URL 字符串和 Request 对象是可互换的,因为匹配时会提取 Request 对象的 URL
const request1 = 'https://www.foo.com';
const request2 = new Request('https://www.bar.com');
const response1 = new Response('fooResponse');
const response2 = new Response('barResponse');
caches.open('v1').then((cache) => {
	cache.put(request1, response1)
		.then(() => cache.put(request2, response2))
		.then(() => cache.match(new Request('https://www.foo.com')))
		.then((response) => response.text())
		.then(console.log) // fooResponse
		.then(() => cache.match('https://www.bar.com'))
		.then((response) => response.text())
		.then(console.log); // barResponse
});
  • Cache 对象使用 Request 和 Response 对象的 clone()方法创建副本,并把它们存储为键/值对
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse');
caches.open('v1')
	.then((cache) => {
		cache.put(request1, response1)
			.then(() => cache.keys())
			.then((keys) => console.log(keys[0] === request1)) // false
			.then(() => cache.match(request1))
			.then((response) => console.log(response === response1)); // false
});
  • Cache.match()、Cache.matchAll()和 CacheStorage.match()都支持可选的 options 对象, 它允许通过设置以下属性来配置 URL 匹配的行为
    • cacheName:只有 CacheStorage.matchAll()支持。设置为字符串时,只会匹配 Cache 键为 指定字符串的缓存值
    • ignoreSearch:设置为 true 时,在匹配 URL 时忽略查询字符串,包括请求查询和缓存键
    • ignoreMethod:设置为 true 时,在匹配 URL 时忽略请求查询的 HTTP 方法
    • ignoreVary:匹配的时候考虑 HTTP 的 Vary 头部,该头部指定哪个请求头部导致服务器响应不同的值。ignoreVary 设置为 true 时,在匹配 URL 时忽略 Vary 头部

3、最大存储空间

  • StorageEstimate API 可以近似地获悉有多少空间可用(以字节为单位),以及当前使用了多少空间

3、服务工作者线程客户端

  • 服务工作者线程会使用 Client 对象跟踪关联的窗口、工作线程或服务工作者线程
  • 服务工作者线 程可以通过 Clients 接口访问这些 Client 对象
  • 该接口暴露在全局上下文的 self.clients 属性上。
  • Client 对象支持以下属性和方法
    • id:返回客户端的全局唯一标识符,id 可用于通过 Client.get()获取客户端的引用
    • type:返回表示客户端类型的字符串。type 可能的值是 window、worker 或 sharedworker
    • url:返回客户端的 URL
    • postMessage():用于向单个客户端发送消息
  • Clients 接口支持通过 get()或 matchAll()访问 Client 对象。这两个方法都通过期约返回结果。 matchAll()也可以接收 options 对象,该对象支持以下属性
    • includeUncontrolled在设置为 true 时,返回结果包含不受当前服务工作者线程控制的客 户端。默认为 false。
    • type:可以设置为 window、worker 或 sharedworker,对返回结果进行过滤。默认为 all, 返回所有类型的客户端
  • Clients 接口也支持以下方法
    • openWindow(url):在新窗口中打开指定 URL,实际上会给当前服务工作者线程添加一个新 Client
    • claim():强制性设置当前服务工作者线程以控制其作用域中的所有客户端
      • claim()可用于不希望等待页面重新加载而让服务工作者线程开始管理页面

4、服务工作者线程与一致性

  • 代码一致性
  • 数据一致性

5、理解服务工作者线程的声明周期

  • 了 6 种服务工作者线程可能存在的状态:已解析(parsed)、安装中 (installing)、已安装(installed)、激活中(activating)、已激活(activated)和已失效(redundant)

6、控制反转与服务工作者线程持久化

  • 虽然专用工作者线程和共享工作者线程是有状态的,但服务工作者线程是无状态的
  • 服务工作者线程不应该依赖工作者线程的全局状态
  • 服务工作者线程中的绝大多数代 码应该在事件处理程序中定义

7、通过updateViaCache管理服务文件缓存

  • 正常情况下,浏览器加载的所有 JavaScript 资源会按照它们的 Cache-Control 头部纳入 HTTP缓 存管理
  • 为了尽可能传播更新后的服务脚本,常见的解决方案是在响应服务脚本时设置 Cache-Control: max-age=0 头部。这样浏览器就能始终取得最新的脚本文件。
  • 可以通过 updateViaCache 属性设置客户端对待服 务脚本的方式。该属性可以在注册服务工作者线程时定义,可以是如下三个字符串值
    • imports:默认值。顶级服务脚本永远不会被缓存,但通过importScripts()在服务工作者线 程内部导入的文件会按照 Cache-Control 头部设置纳入 HTTP 缓存管理
    • all:服务脚本没有任何特殊待遇。所有文件都会按照 Cache-Control 头部设置纳入 HTTP 缓存管理
    • none:顶级服务脚本和通过 importScripts()在服务工作者线程内部导入的文件永远都不会被缓存

8、强制性服务工作者线程操作

9、服务工作者线程消息

  • 实现通信的最简单方式是向活动工作者线程发送一条消息,然后使用事件对象发送回应。发送 给服务工作者线程的消息可以在全局作用域处理,而发送回客户端的消息则可以在 ServiceWorkerContext 对象上处理:
// ServiceWorker.js
self.onmessage = ({
  data,
  source
}) => {
  console.log('service worker heard:', data);
  source.postMessage('bar');
};

// main.js
navigator.serviceWorker.onmessage = ({
  data
}) => {
  console.log('client heard:', data);
};
navigator.serviceWorker.register('./serviceWorker.js')
  .then((registration) => {
    if (registration.active) {
      registration.active.postMessage('foo');
    }
  });
// service worker heard: foo
// client heard: bar
  • 也可以简单地使用 serviceWorker.controller 属性
// ServiceWorker.js
self.onmessage = ({data, source}) => {
	console.log('service worker heard:', data);
	source.postMessage('bar');
};

// main.js
navigator.serviceWorker.onmessage = ({data}) => {
	console.log('client heard:', data);
};
navigator.serviceWorker.register('./serviceWorker.js')
	.then(() => {
		if (navigator.serviceWorker.controller) {
			navigator.serviceWorker.controller.postMessage('foo');
		}
	});
// service worker heard: foo
// client heard: bar 
  • 如果服务工作者线程需要率先发送消息,可以像下面这样获得客户端的引用
// ServiceWorker.js
self.onmessage = ({
  data
}) => {
  console.log('service worker heard:', data);
};
self.onactivate = () => {
  self.clients.matchAll({
      includeUncontrolled: true
    })
    .then((clientMatches) => clientMatches[0].postMessage('foo'));
};

// main.js
navigator.serviceWorker.onmessage = ({
  data,
  source
}) => {
  console.log('client heard:', data);
  source.postMessage('bar');
};
navigator.serviceWorker.register('./serviceWorker.js')
// client heard: foo
// service worker heard: bar 

10、 拦截 fetch 事件

11、推送通知