「读书笔记」第四版JavaScript高级程序设计(第二十七章)

前言

也不完全是笔记,也做了一些自己的补充

javascript是单线程的吗?

javascript是单线程的,但是javascript可以把工作嫁接给独立的线程。同时不影响单线程模型(不能操作DOM)。

每打开一个网页就相当于一个沙盒,每一个页面都有自己独立的内容。工作者线程相当于一个完全独立的二级子环境。在子环境中不能与依赖单线程模型API交互(DOM操作),但是可以与父环境并行执行代码。

工作者线程与线程的区别

  1. 工作者线程的底层实现原理就是线程
  2. 工作者线程可以并发
  3. 工作者线程与主线程之间可以使用SharedArrayBuffer实现共享内存,在js中可以使用Atomics实现并发控制。
  4. 工作者线程不共享全部的内存
  5. 工作者线程与主线程可能不在一个进程中
  6. 创建工作者线程的开销也很大(工作者线程应该用于一些长期运行的任务)

工作者线程的类型

  1. ServiceWorker 服务工作者线程
  2. SharedWorker 共享工作者线程
  3. WebWorker,Worker,专用工作者线程

WorkerGlobalScope

在工作者线程中,没有window对象,全局对象是WorkerGlobalScope的子类的实例

  • 专用工作者线程全局对象是DedicatedWorkerGlobalScope子类的实例
  • 共享工作者线程全局对象是SharedWorkerGlobalScope的实例
  • 服务工作者是ServiceWorkerGlobalScope的实例

专用工作者线程 Worker or WebWorker

创建专用工作者线程




    
    
    Document


    

专用工作者线程的安全限制

工作者线程的脚本文件,只能和父级同源。(但是在工作者线程中,可以使用importScripts加载其他源的脚本)

使用Worker对象

创建的Worker对象在工作线程终止前,是不会被垃圾回收机制回收的
  • onerror, 父上下文监听工作者线程的错误
  • onmessage, 父上下文监听工作者线程发送的消息
  • onmessageerror, 父上下文监听工作者线程发送消息产生的错误(比如消息无法被反序列化)
  • postMessage(),父上下文向工作者线程发送消息
  • terminate(),终止工作者线程

DedicatedWorkerGlobalScope

工作者线程中全局作用域是DedicatedWorkerGlobalScope对象的实例,可以通过self关键字访问全局对象

  • self.postMessage, 向父上下文发送消息
  • self.close, 关闭线程
  • self.importScripts, 引入其他脚本

专用工作者线程的生命周期

生命周期分为初始化,活动,终止。但父上下文是无法区分工作者线程的状态。调用Worker后,虽然worker对象可用,但是worker不一定初始化完毕。可能存在延迟。如果不调用close或者terminate,工作者线程会一直存在,垃圾回收机制也不会回收worker对象。但是调用close和terminate是有一些区别的。如果工作者线程关联的网页被关闭,工作者线程也会被终止。

  1. 在工作者线程的内部,调用close,工作者线程不会立即结束,而且在本次宏任务执行完成后结束。
  2. 在父上下文调用terminate,工作者线程会立即结束。
// 专用工作者线程
self.postMessage('a')
self.close()
self.postMessage('b')

// 父上下文
const worker = new Worker('./worker.js')
worker.onmessage = ({ data }) => {
  console.log('data:', data);
}

// consloe
// data: a
// data: b

// 工作者线程
self.onmessage = ({data}) => console.log(data);

// 父上下文
const worker = new Worker(location.href  + '/worker.js')
// 定时器等待线程初始化完成
setTimeout(() => {
  worker.postMessage('a')
  worker.terminate()
  worker.postMessage('b')
}, 1000);

// consloe
// a

行内创建工作者线程

专用工作者线程可以通过Blob对象的URL在行内创建,而不需要远程的js文件。


const workerStr = `
  self.onmessage = ({data}) => {
    console.log('data:', data);
  }
`;
const workerBlob = new Blob([workerStr]);
const workerBlobUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerBlobUrl);

// data: abc
worker.postMessage('abc');

父上下文的函数,也可以传递给专用工作者线程,并且在专用工作者线程中执行。但是父上下文的函数中不能使用闭包的变量,以及全局对象。

const fn = () => '父上下文的函数';
// 将fn转为字符串的形式,然后自执行
const workerStr = `
  self.postMessage(
    (${fn.toString()})()
  )
`
const workerBlob = new Blob([workerStr]);
const workerBlobUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerBlobUrl);
worker.onmessage = ({ data }) => {
  // 父上下文的函数
  console.log(data)
}
const a = 'Hi'
// error, Uncaught ReferenceError: a is not defined
const fn = () => `${a}, 父上下文的函数`;
const workerStr = `
  self.postMessage(
    (${fn.toString()})()
  )
`
const workerBlob = new Blob([workerStr]);
const workerBlobUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerBlobUrl);
worker.onmessage = ({ data }) => {
  // 父上下文的函数
  console.log(data)
}

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

工作者线程中,可以使用importScripts加载执行脚本。importScripts加载的js会按照顺序执行。所有导入的脚本会共享作用域,importScripts不会同源的限制。

我经过测试,在父上下文中使用 onerror 监听错误,是可以捕获到importScripts加载的非同源脚本的错误,并且有具体的错误信息。

// 父上下文
const worker = new Worker('http://127.0.0.1:8080/worker.js')
window.onerror = (error) => {
  // Uncaught ReferenceError: a is not defined
  console.log(error)
}

// 工作者线程
importScripts('http://127.0.0.1:8081/worker.js')

// 工作者线程中importScripts加载的脚本
const fn = () => {
  console.log(a)
}
setTimeout(() => fn(), 3000);

多个工作者线程

工作线程还可以继续创建工作者线程。但是多个工作者线程会带来额外的开销。并且顶级工作者线程,和子工作者线程,必须和父上下文在同一个源中

工作者线程的错误

try……catch, 无法捕获到线程中的错误,但是在父上下文中,可以使用onerror事件捕获到

专用工作者线程的通信

postMessage

之前已经在demo中给出例子,这里不再赘述

MessageChannel

MessageChannel API有两个端口,如果父上下文需要实现与工作线程的通讯, 父上下文需要将端口传到工作者线程中

// 父上下文
const channel = new MessageChannel()
const worker = new Worker('http://127.0.0.1:8080/worker.js')

// 将端口2发送给工作者线程中
worker.postMessage(null, [channel.port2]);

setTimeout(() => {
  // 通过MessageChannel发送消息
  channel.port1.postMessage('我是父上下文')
}, 2000)
// 工作线程
let channelPort = null

self.onmessage = ({ ports }) => {
  if (!channelPort) {
    channelPort = ports[0]
    self.onmessage = null
    // 通过channelPort监听消息
    channelPort.onmessage = ({ data }) => {
      console.log('父上下文的数据:', data);
    }
  }
}

BroadcastChannel

同源脚本可以使用BroadcastChannel进行通讯,使用BroadcastChannel必须注意的是,如果父上下文在工作线程初始化完成之前,就发送消息,工作线程初始化完成后,是接受不到消息的。消息不会存在消息队列中。

// 父上下文
const channel = new BroadcastChannel('worker')
const worker = new Worker('http://127.0.0.1:8080/worker.js')
// 等待工作线程初始化完毕
setTimeout(() => {
  channel.postMessage('消息')
}, 2000)
// 工作线程
const channel = new BroadcastChannel('worker')
channel.onmessage = ({ data }) => {
  console.log(data)
}

Channel Messaging API

Channel Messaging API 可以用在 "文档主体与iframe","两个iframe之间","使用SharedWorker的两个文档",或者两个"worker"之间进行通许。

专用工作者线程的数据传输

结构化克隆算法

使用postMessage发送数据的时候,浏览器后台会对数据(除了Symbol之外的类型)进行拷贝。虽然结构化克隆算法对循环引用的问题做了兼容处理,但是对于复杂对象结构化克隆算法有性能损耗。

可转移对象

将数据的所有权。由父级上下文转让给工作线程。或者由工作线程转让给父级上下文。转移后,数据就会在之前的上下文中被抹去。postMessage的第二个参数,是可选参数,是一个数组,数组的数据需要被转让所有权的数据。


// 父上下文
const worker = new Worker('http://127.0.0.1:8080/worker.js')
const buffer = new ArrayBuffer(30)
// 30
console.log('发送之前:', buffer.byteLength)
// 等待工作线程初始化完毕
setTimeout(() => {
  worker.postMessage(buffer, [buffer])
  // 0
  console.log('发送之后:', buffer.byteLength)
}, 2000)

// 工作线程
self.onmessage = ({ data }) => {
  // 30
  console.log('工作线程接受之后', data.byteLength);
}

关于postMessage的第二个参数

之前可以使用 worker.postMessage(null, [channel.port2]) 发送channel接口的时候。工作线程的onmessage事件的参数,会接收ports,但是换成其他数据是接收不到的。postMessage应该是对channel的数据做了特殊的处理。

SharedArrayBuffer

SharedArrayBuffer可以在父上下文和工作线程中共享,SharedArrayBuffer和ArrayBuffer的api相同,不能直接被操作需要视图。

// 父上下文
const worker = new Worker('http://127.0.0.1:8080/worker.js')
const sharedBuffer = new SharedArrayBuffer(10)
const view = new Int8Array(sharedBuffer);
view[0] = 1;
// 1
console.log('发送之前:', view[0]);
worker.postMessage(sharedBuffer)
setTimeout(() => {
  // 打印出,2
  console.log('发送之后:', view[0])
}, 2000)
// 工作者线程
self.onmessage = ({ data }) => {
  const view = new Int8Array(data);
  view[0] = '2'
}

并行线程共享资源,会有资源征用的隐患。可以使用Atomics解决,Atomics与SharedArrayBuffer可以查看第二十章的笔记。

线程池

开启新的工作者线程开销很大,可开启保持固定数量的线程。线程在忙碌时不接受新任务,线程空闲后接收新任务。这些长期开启的线程,被称为线程池。

线程池中线程的数量,可以参考电脑cpu的线程数, navigator.hardwareConcurrency, 将cpu的线程数设置线程池的上限。

下面的是数中的封装,我在github中也没有找到太热门的库封装的线程池可用,https://github.com/andywer/th... 已经是5年前更新的了。

共享工作者线程 SharedWorker

注意:

  1. Safari不支持SharedWorker
  2. SharedWorke中的console不一定能打印在父上下文的控制台中。(我在Chrome中实验了一下,共享工作者线程中的console,确实不会出现在页面的控制台中)

共享工作者线程和创建,安全限制和专用工作者线程都是相同的,共享工作者线程,可以看作是专用工作者线程的扩展。

SharedWorker可以被多个同源的上下文(同源的网页标签)访问。SharedWorker的消息接口和专用工作者线程也略有不同。

SharedWorker,没办法使用行内的worker, 因为通过URL.createObjectURL, 是浏览器内部的URL, 无法在其他标签页使用。

SharedWorker的唯一标示

worker每次new都会返回一个新的worker实例,SharedWorker只会在不存在相同标示的情况下返回新的实例。SharedWorker的标示可以是worker文件的路径, 文档源。

// 只会实例化一个共享工作者线程
new SharedWorker('http://127.0.0.1:8080/worker.js')
new SharedWorker('http://127.0.0.1:8080/worker.js')

但是如果我们给相同源的SharedWorker,不同的标识,浏览器会任务它们是不同的共享工作者线程

// 实例化二个共享工作者线程
new SharedWorker('http://127.0.0.1:8080/worker.js', { name: 'a' })
new SharedWorker('http://127.0.0.1:8080/worker.js', { name: 'a' })

在不同页面,只要标示相同,创建的SharedWorker都是同一个链接

SharedWorker对象的属性

  • onerror 监听共享工作者线程对象上的抛出的错误
  • port 与共享工作者线程通讯的接口,SharedWorker会隐式的创建,用于与父上下文通信

共享工作者线程中的全局对象是SharedWorkerGlobalScope的实例,全局实例上的属性和方法

  • onconnect,当共享工作者线程建立链接时会触发, 参数包含ports数组,port可以把消息穿回给父上下文。sharedWorker.port.onmessage, sharedWorker.port.start(), 都会触发onconnect事件。
  • close
  • importScripts
  • name

共享工作者线程的生命周期

专用工作者线程只和一个页面绑定,而共享工作者线程只要还有一个上下文链接,它就不会被回收。共享工作者对象无法通过terminate关闭,因为共享工作者线程没有terminate方法,浏览器会负责管理共享工作者线程的链接。

链接共享工作者线程

发生connect事件时,SharedWorker构造函数会隐式的创建MessageChannel,并把其中一个port转移给共享工作者线程的ports数组中。

但是共享线程与父上下文的启动关闭不是对称的。每次打开会建立链接,connect事件中的ports数组中port数量会加1,但是页面被关闭,SharedWorker无法感知。

比如很多页面链接了SharedWorker,现在一部分现在关闭了,SharedWorker并不知道那些页面关闭,所以ports数组中,存在被关闭的页面的port,这些死端口会污染ports数组。

书中给出的方法是,可以在页面销毁之前的beforeunload事件中,通知SharedWorker清除死端口。

SharedWorke示例

示例一

// 父页面
const worker = new SharedWorker('http://127.0.0.1:8080/worker.js')
worker.port.onmessage = ({ data }) => {
    // 打印 data: 2
    console.log('data:', data);
}
worker.port.postMessage([1, 1]);
// 共享工作者线程
const connectedPorts = new Set();
self.onconnect = ({ports}) => {
    if (!connectedPorts.has(ports[0])) {
        connectedPorts.add(ports[0])
        ports[0].onmessage = ({ data }) => {
            ports[0].postMessage(data[0] + data[1])
        }
    }  
};

示例二

分享线程生成id,标示接口,并发送给页面。在页面beforeunload,将id发送给分享工作者线程中,分享工作者线程清除死端口。
// 父页面1
const worker = new SharedWorker('http://127.0.0.1:8080/worker.js')
let portId = null
worker.port.onmessage = ({ data }) => {
    if (typeof data === 'string' && data.indexOf('uid:') > -1) {
        // 记录接口的id
        portId = data.split(':')[1];
    } else {
        console.log('接口的数量:', data);
    }
}

window.addEventListener('beforeunload', (event) => {
    worker.port.postMessage(`删除:${portId}`);
});
// 父页面2
const worker = new SharedWorker('http://127.0.0.1:8080/worker.js')
let portId = null
worker.port.onmessage = ({ data }) => {
    if (typeof data === 'string' && data.indexOf('uid:') > -1) {
        // 记录接口的id
        portId = data.split(':')[1];
    } else {
        console.log('接口的数量:', data);
    }
}

window.addEventListener('beforeunload', (event) => {
    worker.port.postMessage(`删除:${portId}`);
});
// 分享工作者线程
const uuid = () => {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
      var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
    });
}
// 记录接口的map
const connectedPortsMap = new Map();

self.onconnect = ({ports}) => {
    if (!connectedPortsMap.has(ports[0])) {
        const uid = uuid();
        connectedPortsMap.set(uid, ports[0])
        // 向页面发送接口的id,这个id用于删除接口
        ports[0].postMessage(`uid:${uid}`);
        ports[0].onmessage = ({ data }) => {
            if (typeof data === 'string' && data.indexOf('删除:') > -1) {
                const portId = data.split(':')[1];
                // 删除死接口
                connectedPortsMap.delete(portId);
            }
        }
    }  
};

setInterval(() => {
    // 发送接口的数量
    connectedPortsMap.forEach((value) => {
        value.postMessage(connectedPortsMap.size)
    })
}, 3000)

服务工作者线程

感觉这个章节翻译的有点差,很多话读的很别扭,不流畅。而且很多章节都没有给出示例代码,我很多章节都手敲了一遍例子代码,放在文章李

是浏览器中的代理服务器线程,可以拦截请求或者缓存响应,页面可以在无网的环境下使用。与共享工作者线程类似,多个页面共享一个服务工作者线程。服务工作者线程中,服务工作者线程可以使用Notifications API、Push API、Background Sync API。为了使用Push API服务工作者线程可以在浏览器或者标签页关闭后,继续等待推送的事件。

服务工作者线程,常用于网络请求的缓存层和启用推送通知。服务工作者线程,可以把Web应用的体验变为原生应用程序一样。

Notification API 基础

浏览器用于显示桌面通知的API, 下面是例子

// 检查是否允许发送通知
// 如果已经允许直接发送通知
if (Notification.permission === "granted") {
  let notification = new Notification('西尔莎罗南', {
    body: '西尔莎罗南'
  });
} else if (Notification.permission !== "denied") {
  // 如果还没有允许发送通知,我们请求用户允许
  Notification.requestPermission().then(function (permission) {
    // 如果用户接受权限,我们就可以发起一条消息
    if (permission === "granted") {
      let notification = new Notification('西尔莎罗南', {
        body: '西尔莎罗南'
      });
    }
  })
}

Push API 基础

Push API实现了Web接受服务器推送消息的能力。Push API具体的实施代码,可以看我的这个例子, 实现了一个简单的推送。

过程,客户端生成订阅信息,发送给服务端保存。服务端端可以根据需要,在合适的时候,使用订阅信息向客户端发送推送。

https://github.com/peoplesing...

Background Sync API 基础

服务工作者线程,用于定期更新数据的API。

本来想实验以一下这个API,但是注册定时任务时,提示“DOMException: Permission denied.”错误,暂时没有解决。

ServiceWorkerContainer

服务工作者线程没有全局的构造函数,通过 navigator.serviceWorker 创建,销毁,服务工作者线程

创建服务工作者线程

与共享工作者线程一样,在没有时创建新的链接,如果线程已存在,链接到已存在的线程上。

// 创建服务工作者线程
navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js');

registerf返回一个Promise对象,在同一页面首次调用register后,后续调用register没有任何返回。

navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js').then(() => {
  console.info('注册成功')
}).catch(() => {
  console.error('注册失败')
})

如果服务工作者线程用于管理缓存,服务工作线程应该在页面中提前注册。否则,服务工作者线程应该在load事件中完成注册。

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js').then(() => {
      console.info('注册成功')
    }).catch(() => {
      console.error('注册失败')
    })
  });
}

使用ServiceWorkerContainer对象 (容器对象)

ServiceWorkerContainer对象是浏览器对服务工作者线程的顶部封装,ServiceWorkerContainer可以在客户端中通过navigator.serviceWorker访问

  • navigator.serviceWorker.oncontrollerchange, 在注册新的服务工作者线程时触发(准确的说是在注册新版本的服务工作者线程,并接管页面时触发)
const btn = document.getElementById('btn')

btn.onclick = () => {
    navigator.serviceWorker.register('./sw2.js')
}

navigator.serviceWorker.oncontrollerchange = () => {
    console.log('触发controllerchange事件')
    // sw2
    console.log(navigator.serviceWorker.controller)
}

navigator.serviceWorker.register('./worker.js')
// sw1
console.log('hehe')
// sw2
self.addEventListener('install', async () => {
    // 强制进入已激活的状态
    self.skipWaiting();
})

self.addEventListener('activate', async () => {
    // 强制接管客户端
    self.clients.claim();
})
  • onerror,服务工作者线程,发生错误时触发
  • onmessage,监听服务工作者线程发送的消息,收到消息时触发
  • ready,返回Promise,内容是已经激活的ServiceWorkerRegistration对象
  • controller,返回当前页面的serviceWorker对象,如果没有激活的服务工作者线程返回null
  • register(), 创建更新ServiceWorkerRegistration对象
  • getRegistration(scope), 返回Promise, 内容与与ServiceWorkerContainer关联,并且与scope(路径)匹配的ServiceWorkerRegistration对象。
  • getRegistrations(), 返回Promise, 内容是与ServiceWorkerContainer关联的所有ServiceWorkerRegistration对象
  • startMessage(), 开始接受服务工作者线程通过postMessage派发的消息,如果不使用startMessage,Client.postMessage()派发的消息会等待DOMContentLoaded事件之后被调度。startMessage可以尽早的调度。(使用ServiceWorkerContainer.onmessage时,消息会自动发送,不需要startMessage)

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    let sw1;
    let sw2;
    navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js').then(sw => {
      console.log(sw);
      sw1 = sw;
    }) 
    navigator.serviceWorker.ready.then((sw) => {
      console.log(sw);
      sw2 = sw;
    })
    setTimeout(() => {
      // true
      console.log(sw1 === sw2)
    }, 1000)
  });
}

使用ServiceWorkerRegistration对象 (注册对象)

ServiceWorkerRegistration,表示成功注册的服务工作者线程。可以通过register返回的Promise中访问到。在同一页面调用register,如果URL相同,返回的都是同一个ServiceWorkerRegistration对象。

navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js').then(sw => {
  // ServiceWorkerRegistration对象
  console.log(sw);
}) 
  • onupdatefound, 线程安装中的状态触发
  • scope, 返回服务工作者线程的路径
  • navigationPreload,返回与注册对象关联的NavigationPreloadManager对象
  • pushManager,返回注册对象关联的pushManager对象(主要用于注册消息,方便服务端推送消息)
  • installing,返回状态为installing的服务工作者线程
  • waiting,返回状态为waiting的服务工作者线程
  • active,如果有则返回状态 activating(激活中) 或 active(活动)的服务工作者线程
  • showNotifications,显示通知,可以配置title和body
  • update,更新ServiceWorker
  • unregister,取消ServiceWorker

使用ServiceWorker对象

如何获取ServiceWorker对象?有两种以下的途径

  1. ServiceWorkerRegistration对象的active属性
  2. ServiceWorkerContainer对象的controller属性

ServiceWorker对象继承Work,但是不包含terminate方法

  • onstatechange,ServiceWorker.state变化时触发
  • scriptURL,返回注册的工作者线程的完整URL,相对路径会被解析为完整的路径
  • state,返回工作者线程的状态,installing,installed,activating,activated,redundant

ServiceWorker的安全限制

  1. 受到同源的限制
  2. 只能用于HTTPS的协议,127.0.0.1或者localhost下可以使用HTTP协议。

如果在非HTTPS的协议下,navigator.serviceWorker是undefined。window.isSecureContext可以判断当前上下文是否安全。

ServiceWorkerGlobalScope

服务工作者线程内部,全局对象是ServiceWorkerGlobalScope的实例。ServiceWorkerGlobalScope继承WorkerGlobalScope,因此拥有它的属性和方法。线程内部通过self访问全局的上下文。ServiceWorkerGlobalScope的实例的做了以下的扩展。

  • caches,返回CacheStorage对象
  • clients,返回工作者线程的Clients接口
  • registration,返回服务工作者线程的注册对象ServiceWorkerRegistration
  • skipWaiting,强制工作者线程进入活动的状态
  • fetch,在 服务工作者线程中,用于发起网络请求的方法

专用工作者线程,和共享工作者线程只有一个onmessage事件作为输入,但服务工作者线程可以接受多种事件

  • self.onintall, 服务工作者线程进入安装的时触发(activating之前的状态)
  • self.onactive, 服务工作者线程进入激活状态时触发 (activating之后的状态)
  • self.onfetch, 服务工作者线程可以拦截页面的fetch请求
  • self.onmessage, 服务工作者线程接收postMessage发送的消息时触发
  • self.onnotificationclick,用户点击 ServiceWorkerRegistration.showNotification(),生成的通知触发
  • self.onnotificationclose,用户关闭 ServiceWorkerRegistration.showNotification(),生成的通知触发
  • self.onpush,接受到服务端推送的消息时触发

服务工作者线程作用域的限制

// worker.js在根目录线下
navigator.serviceWorker.register('http://127.0.0.1:8080/worker.js')
// http://127.0.0.1:8080下的所有请求都会被拦截
fetch('http://127.0.0.1:8080/foo.js');
fetch('http://127.0.0.1:8080/foo/fooScript.js');
fetch('http://127.0.0.1:8080/baz/bazScript.js');

// worker.js在foo目录下
navigator.serviceWorker.register('http://127.0.0.1:8080/foo/worker.js'})
// foo目录下的请求会被拦截
fetch('/foo/fooScript.js')
// 其他路径的请求不会被拦截
fetch('/foo.js')
fetch('/baz/bazScript.js')

如果想排除某个路径下的请求,可以使用末尾带斜杠的路径

// foo路径下的请求,都不会被拦截
navigator.serviceWorker.register(
  'http://127.0.0.1:8080/worker.js',
  {
    scope: '/foo/'
  }
)

服务工作者线程缓存

  1. 服务工作者线程的不会自动缓存任何请求
  2. 服务工作线程不会到期自动失效
  3. 缓存必须手动的更新和删除
  4. 缓存版本必须手动管理
  5. 缓存策略为LRU,当缓存的数据超过浏览器的限制时

CacheStorage

通过 self.caches 访问 CacheStorage 对象。CacheStorage时字符串和Cache对象的映射。CacheStorage在页面或者其他工作者线程中,都可以访问使用。

// 访问缓存,如果没有缓存则会创建
self.caches.open(key)

CacheStorage也拥有类似Map的API,比如has,delete,keys(),但是它们都是返回Promise的

match,matchAll

分别返回匹配的第一个Response

(async () => {
    const request = new Request('https://www.foo.com')
    const response1 = new Response('fooResponse1')
    const response2 = new Response('fooResponse2')
    const v1 = await caches.open('v1')
    await v1.put(request, response1)
    const v2 = await caches.open('v2')
    await v2.put(request, response2)
    const matchCatch = await caches.match(request)
    const matchCatchText = await matchCatch.text()
    // true
    console.log(matchCatchText === 'fooResponse1')
})();  

Cache

CacheStorage对象是字符串和Cache对象的映射。Cache对象则是Request对象或者URL字符串,和Response对象之间的映射。

  • put, put(Request, Response)添加缓存,返回Promise
  • add(request), 使用add发送fetch请求,会缓存响应
  • addAll(request[]), 会对数组中的每一项调用add。

Cache也拥有delete(), keys()等方法,这些方法都是返回Promise的

(async () => {
    const request1 = new Request('https://www.foo.com');
    const response1 = new Response('fooResponse');
    const cache = await caches.open('v1')
    await cache.put(request1, response1)
    const keys = await cache.keys()
    // [Request]
    console.log(keys)
})()
  • matchAll,返回匹配的Response数组
  • match,返回匹配的Response对象

(async () => {
    const request1 = new Request('https://www.foo.com?a=1&b=2')
    const request2 = new Request('https://www.bar.com?a=1&b=2', {
        method: 'GET'
    })
    const response1 = new Response('fooResponse')
    const response2 = new Response('barResponse')
    const v3 = await caches.open('v3')
    await v3.put(request1, response1)
    await v3.put(request2, response2)
    const matchResponse = await v3.match(new Request('https://www.foo.com'), {
        ignoreMethod: true, // 忽略匹配GET或者POST方法
        ignoreSearch: true, // 忽略匹配查询字符串
    });
    const matchResponseText = await matchResponse.text()
    // fooResponse
    console.log(matchResponseText)
})(); 

catch对象的key,value使用的是Request, Response对象的clone方法创建的副本

(async () => {
    const request = new Request('https://www.foo.com');
    const response = new Response('fooResponse');
    const cache = await caches.open('v1')
    await cache.put(request, response)
    const keys = await cache.keys()
    // false
    console.log(keys[0] === request)
})();  

最大存储空间

获取存储空间,以及目前以用的空间

navigator.storage.estimate()

Client

  • id,客户端的全局唯一标示
  • type,客户端的类型
  • url,客户端的URL
  • postMessage,向单个客户端发送消息
  • claim,强制工作者线程控制作用域下所有的客户端。当一个ServiceWorker被初始注册时,页面在下次加载之前不会使用它。 claim() 方法会立即控制这些页面。

关于服务工作者线程控制客户端的问题

一开始注册服务工作者时,页面将在下一次加载之前才使用它。有两种方法可以提前控制页面

// 页面
navigator.serviceWorker.register('./worker.js').then((registration) => {
  setTimeout(() => {
    fetch('/aa')
  }, 2000)
}).catch(() => {
  console.log('注册失败')
});
// sw
self.addEventListener('fetch', () => {
  // sw没有控制客户端,所以无法拦截fetch请求,抛出错误
  throw new Error('呵呵')
})

第一种解决方法, 使用claim强制获得控制权,但是可能会造成版本资源不一致


self.addEventListener('activate', async () => {
    self.clients.claim();
})

self.addEventListener('fetch', () => {
    // 可以抛出错误
    throw new Error('呵呵')
})

第二种解决方法,刷新页面

navigator.serviceWorker.register('./worker.js').then((registration) => {
    setTimeout(() => {
        fetch('/aa')
    }, 3000)
    registration.addEventListener('updatefound', () => {
        const sw = registration.installing;
        sw.onstatechange = () => {
            console.log('sw.state', sw.state)
            if (sw.state === 'activated') {
                console.log('刷新页面')
                // 刷新页面后可以抛出错误
                window.location.reload();
            }
        }     
    })
}).catch(() => {
    console.log('注册失败')
});

服务工作者线程的一致性

服务工作者线程,最重要的就是保持一致性(不会存在a页面使用v1版本的服务工作者线程,b页面使用v2版本的服务工作者线程)。

  1. 代码一致性,服务工作者线程在所有标签页都会同一个版本的代码
  2. 数据一致性,

    • 服务工作者线程提早失败:语法错误,资源加载失败,都会导致服务工作者线程加载是比
    • 服务工作者线程激进更新:当加载的服务工作者线程,或者服务工作者线程内部依赖的资源,有任何差异,都会导致安装新版本的服务工作者线程(新安装的工作者线程会进入installed态)
    • 未激活服务工作者线程消极活动,在使用register安装服务工作者线程后,服务工作者线程会安装,但不会被激活(除非所有受到之前版本的控制的标签页被关闭,或者调用self.skipWaiting方法)
    • 活动的服务工作者线程粘连,只要至少有一个客户端与关联到活动的服务工作者线程,浏览器 就会在该源的所有页面中使用它。对于新版本的服务工作者线程会一直等待。

生命周期

  1. 已解析(parsed)
  2. 安装中 (installing)
  3. 已安装(installed)
  4. 激活中(activating)
  5. 已激活(activated)
  6. 已失效(redundant)

已解析 parsed

调用 navigator.serviceWorker.register() 会进入已解析的状态,但是该状态没有事件,也没有对应的ServiceWorker.state的值。

安装中 installing

在客户端可以通过检查registration.installing是否被设置为了ServiceWorker实例,判断是否在安装中的状态。当服务工作者线程到达安装中的状态时,会触发onupdatefound事件。

navigator.serviceWorker.register('./sw1.js').then((registration) => {
    registration.onupdatefound = () => {
        console.log('我已经达到了installing安装中的状态')
    }
    console.log(registration.installing)
});

在服务工作者线程的内部,可以通过监听install事件,确定安装中的状态。

在install事件中,可以用来填充缓存,可以使用waitUntil的方法,waitUntil方法接受一个Promise,只有Promise返回resolve时,服务工作者线程的状态才会向下一个状态过渡。

self.addEventListener('install', (event) => {
    event.waitUntil(async () => {
        const v1 = await caches.open('v1')
        // 缓存资源完成后,才过渡到下一个状态
        v1.addAll([
            'xxxx.js',
            'xxx.css'
        ])
    })
})

已安装 installed

在客户端可以通过检查registration.waiting是否被设置为了ServiceWorker实例,判断是否是已安装的状态。如果浏览器中没有之前版本的的ServiceWorker,新安装的ServiceWorker会直接跳过这个状态,进入激活中的状态。否则将会等待。

navigator.serviceWorker.register('./worker.js').then((registration) => {
    console.log(registration.waiting)
});

如果有已安装的ServiceWorker,可以使用self.skipWaiting,强制工作者线程进入活动的状态

激活中状态 activating

如果浏览器中没有之前版本的ServiceWorker,则新的服务工作者线程会直接进入这个状态。如果有其他服务者线程,可以通过下面的方法,使新的服务者线程进入激活中的状态

  1. self.skipWaiting(), 强制进入激活中的状态
  2. 原有的服务工作者线程客户端数量变为0(标签页都被关闭)在下一次导航事件新工作者线程进入激活中的状态。
const btn = document.getElementById('btn');

navigator.serviceWorker.register('./sw1.js').then((registration) => {
    // 第一次加载没有活动的(之前版本)服务工作者进程, waiting直接跳过所以为null
    console.log('waiting:', registration.waiting);
    // 当前激活的是sw1的服务工作者线程
    console.log('active:', registration.active);
});

btn.onclick = () => {
    navigator.serviceWorker.register('./sw2.js').then((registration) => {
        // 加载新版本的服务工作者线程,触发更新加载
        // 因为之前已经有在活动的服务工作者线程了,waiting状态的是sw2的线程
        console.log('waiting:', registration.waiting);
        // 激活状态的是sw1的线程
        console.log('active:', registration.active);
    })
}

image.png

在客户端中可以大致通过判断registration.active是否为ServiceWorker的实例判断。(active为ServiceWorker的实例,可能是是激活状态或者激活中的状态)

在服务工作者线程中,可以通过添加activate事件处理来判断,该事件处理程序常用于删除之前的缓存


const CATCH_KEY = 'v1'

self.addEventListener('activate', async (event) => {
  const keys = await caches.keys();
  keys.forEach((key) => {
    if (key !== CATCH_KEY) {
      caches.delete(key)
    }
  });
})

注意:activate事件发生,并不意味着页面受控。可以使用clients.claim()控制不受控的客户端。

已激活的状态 activated

在客户端中可以大致通过判断registration.active是否为ServiceWorker的实例判断。(active为ServiceWorker的实例,可以是激活状态或者激活中的状态)

或者可以通过查看registration.controller属性,controller属性返回已激活ServiceWorker的实例。当新的服务工作者线程控制客户端时,会触发navigator.serviceWorker.oncontrollerchange事件

或者navigator.serviceWorker.ready返回的Promise为resolve时,工作者线程也是已激活的状态。

const btn = document.getElementById('btn');

navigator.serviceWorker.register('./sw1.js').then((registration) => {
    // 已激活的线程sw1
    console.log('activated', navigator.serviceWorker.controller)
});

btn.onclick = () => {
    navigator.serviceWorker.register('./sw2.js').then((registration) => {
        // 在等待的线程sw2
        console.log('waiting', registration.waiting)
        // 已激活的线程sw1
        console.log('activated', navigator.serviceWorker.controller)
    })
}

已失效的状态 redundant

服务工作者会被浏览器销毁并回收资源

更新服务工作者线程

下面操作会触发更新检查:

  1. 使用navigator.serviceWorker.register(),加载不同URL, 会检查
  2. 发生了push,fetch事件。并且至少24小时没有更新检查。
  3. 浏览器导航到了到服务工作者线程作用域中的一个页面。

如果更新检查发现差异,浏览器会使用新脚本初始化新的工作者线程,新的工作者线程将会达到installed的状态。然后会等待。除非使用self.skipWaiting(), 强制进入激活中的状态。或者原有的服务工作者线程客户端数量变为0(标签页都被关闭)在下一次导航事件新工作者线程进入激活中的状态。

刷新页面不会让更新服务工作者线程进入激活状态并取代已有的服务工作者线程。比如,有个打开的页面,其中有一个服务工作者线程正在控制它,而一个更新服务工作者线程正在已安装状态中等待。客户端在页面刷新期间会发生重叠,即旧页面还没有卸载,新页面已加载了。因此,现有的服务工作者线程永远不会让出控制权,毕竟至少还有一个客户端在它的控制之下。为此,取代现有服务工作者线程唯一的方式就是关闭所有受控页面。

updateViaCache

使用updateViaCache可以控制,服务工作者线程的缓存

  • none,服务工作者线程的脚本以及importScripts引入都不会缓存
  • all,所有文件都会被http缓存
  • imports,服务工作者线程的脚本不会被缓存,importScripts的文件会被http缓存

navigator.serviceWorker.register('/serviceWorker.js', {
  updateViaCache: 'none'
});

服务工作者线程与页面通信

// 页面
navigator.serviceWorker.onmessage = ({ data }) => {
    console.log('服务者线程发送的消息:', data);
}

navigator.serviceWorker.register('./worker.js').then((registration) => {
    console.log('注册成功')
}).catch(() => {
    console.log('注册失败')
});
// sw
self.addEventListener('install', async () => {
    self.skipWaiting();
});

self.addEventListener('activate', async () => {
    self.clients.claim();
    const allClients = await clients.matchAll({
        includeUncontrolled: true
    });
    let homeClient = null;
    for (const client of allClients) {
        const url = new URL(client.url);
        if (url.pathname === '/') {
            homeClient = client;
            break;
        }
    }
    homeClient.postMessage('Hello')
});

拦截fetch请求

self.onfetch = (fetchEvent) => {
  fetchEvent.respondWith(fetch(fetchEvent.request));
};

fetchEvent.respondWith 接受Promise,返回Respose对象,

从缓存中返回


self.onfetch = (fetchEvent) => {
  fetchEvent.respondWith(caches.match(fetchEvent.request));
};

从网络返回,缓存作为后备

self.onfetch = (fetchEvent) => {
  fetchEvent.respondWith(fetch(fetchEvent.request).catch(() => {
    return caches.match(fetchEvent.request)
  }));
};

从缓存返回,网络作为后备


self.onfetch = (fetchEvent) => {
  fetchEvent.respondWith(
    caches.match(fetchEvent.request).then((response) => {
      return response || fetch(fetchEvent.request).then(async (res) => {
        // 网络返回成功后,将网络返回的资源,缓存到本地
        const catch = await catchs.open('CatchName')
        await catch.put(fetchEvent.request, res)
        return res;
      })
    })
  );
};

通用后备

在服务者线程加载时就应该缓存资源,在缓存,和网络都失效时候,返回通用的后备。

参考

你可能感兴趣的