深入了解 Service Worker ,看这篇就够了

深入了解 Service Worker ,看这篇就够了

这是一个特殊的 worker

浏览器一般有三类 web Worker:

  1. Dedicated Worker:专用的 worker,只能被创建它的 JS 访问,创建它的页面关闭,它的生命周期就结束了。
  2. Shared Worker:共享的 worker,可以被同一域名下的 JS 访问,关联的页面都关闭时,它的生命周期就结束了。
  3. ServiceWorker:是事件驱动的 worker,生命周期与页面无关,关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动,

这三者有什么区别呢?众所周知,JShted 的执行线程,同一时刻内只会有一段代码在执行。Web worker 目的是为 JS 是单线程的,即一个浏览器进程中只有一个 JS 创造多线程环境,允许主线程将一些任务分配给子线程。Web Worker 一般是用于在后台执行一些耗时较长的 JS,避免影响 UI 线程的响应速度。

Dedicated worker 或 shared worker 最主要的能力,一是后台运行 JS,不影响 UI 线程,二是使用消息机制实现并行,可以监听 onmessage 事件。所以 dedicated worker 和 shared worker 专注于解决“耗时的 JS 执行影响 UI 响应”的问题,而 service worker 则是为解决“Web App 的用户体验不如 Native App”的普遍问题而提供的一系列技术集合,必然部分处理逻辑会牵扯到 UI 线程,从而在启动 service worker 的时候,UI 线程的繁忙也会影响其启动性能。

显然 service worker 的使命更加远大,虽然规范把它定义为 web worker,但它已不是一个普通的 worker了。

每一部分的作用

Google 官方入门文档提到,它能提供丰富的离线体验,周期的后台同步,消息推送通知,拦截和处理网络请求,以及管理资源缓存。这每个能力各自都有什么作用呢?

1. 丰富的离线体验

首先,一提到 service worker,很多人都会想到离线访问,而且不少文章都会提到,service worker 能提供丰富的离线体验,但实际情况来说,需要离线访问的场景很少,毕竟 web 最大的特点在于可传播性,所以 service worker 的离线体验主要还是在于解决页面加载的可靠性,让用户能够完整地打开整个页面,比如页面的白屏时间过长,网络不稳定造成的加载中断导致页面不可用。

有实际意义的离线,一般不是指断开网络能访问,而是指在用户想访问之前,能提前把资源加载回来。离线并不是一直都断开网络,而是在网络连接良好的情况下,能把需要的资源都加载回来。一些比较糟糕的做法是在 WIFI 网络下把整个 App 客户端的资源都拉下来,这样其实很多资源是用户不需要的,浪费了用户的网络和存储。Service worker 提供了更好更丰富的离线技术,Push / Fetch / Cache 这些技术的结合,能够提供非常完美的离线体验。比如,在小程序页面发版时,推送消息给客户端,客户端唤起页面的 service worker,去将需要用到的资源提前加载回来。

2. 消息推送通知

Service worker 的消息推送,其实是提供了一种服务器与页面交互的技术。消息推送在 Native App 或 Hybird App 已经比较常见。很多 Hybird App 里面其实还会有一些 H5 页面,在没有实现 service worker 消息推送之前,消息是推送不到页面的。消息能推送到页面,意味着页面提前知道要发生的一些事情,把这些事情做好,比如,提前准备好页面需要的资源。Push 的推送服务器,Chromium 默认使用 GCM / FCM,在国内都不能访问,无法使用。浏览器厂商自己搭建 Push 服务器,成本也不低,目前国内还未有浏览器厂商支持标准的Push 服务。从 API 的使用规范来看,消息推送与通知弹窗的关联比较密切,基本上使用的业务场景仅限制在消息通知范围。

3. 管理资源缓存

浏览器提供了很多存储相关的 H5 API,比如 application cache、localStorage,但都不是非常好用,主要是给予页端的控制权太少,限制太多,页端不能完全控制每一个资源请求的存储逻辑,或多或少会有一些趟不过的坑。Service worker Cache API 的出现彻底改变了这一局面,赋予了页端强大的灵活性,更大的存储空间。如何灵活地控制缓存,可以参考 Google 官方文章 《The Offline Cookbook》。

4. 网络请求

在 Fetch 出现之前,页面 JS 一般通过 XHR 发起网络资源请求,但 XHR 有一定的局限性,比如,它不像普通请求那样支持 Request 和 Response 对象,也不支持 streaming response,一些跨域的场景也限制较多。而现在,Fetch API 支持 Request 和 Response 对象,也支持 streaming response,Foreign Fetch 还具备跨域的能力。

一般来说,基于 webview 的客户端拦截网络请求,都会基于 WebViewClient 的标准的 shouldInterceptRequest 接口。那么 service worker 的请求在 webview 还能不能拦截呢?WebViewClient 的标准的 shouldInterceptRequest 接口是拦截不了 service worker 的请求了,但 Chrome 49.0 提供了新的 ServiceWorkerController 可以拦截所有 service worker 的请求。另外,页端 JS 可以监听 Fetch 事件,通过 FetchEvent.respondWith 返回符合期望的 Response,即页端也能拦截 Response。

尴尬的处境

Service worker 的理想看起来很美好,现实却很骨感,为什么这么说呢?GCM / FCM 服务被墙不说,强大的 Background Sync 功能也需要依赖 Google Play,而国内 Android 手机厂商自带的 ROM 基本上都把 Google Play 干掉了,并且还被墙了,略尴尬。比这更尴尬的是,Apple iOS 团队对 Service Worker 的态度很不明朗,现在是,将来可能也是,所以现在很多特性在 iOS 上都不支持。

启动分析

Service worker 线程的整个启动流程可划分为五个步骤:

1. 触发启动流程

一般来说,我们在访问一个含有 service worker 的 HTML 文档时,在发起主文档请求之前,它会先派发一个 Fetch 事件,这个事件会触发该页面 service worker 的启动流程。

2. 分派进程(多进程模式)/ 线程(单进程模式)

Service worker 启动之前,它必须先向浏览器 UI 线程申请分派一个线程,再回到 IO 线程继续执行 service worker 线程的启动流程:

content::EmbeddedWorkerInstance::Start
--> content::EmbeddedWorkerInstance::RunProcessAllocated
--> ServiceWorkerProcessManager::AllocateWorkerProcess // from IO thread
--> ServiceWorkerProcessManager::AllocateWorkerProcess // PostTask to UI thread
--> ServiceWorkerProcessManager::AllocateWorkerProcess // from UI thread
--> content::EmbeddedWorkerInstance::ProcessAllocated // from IO thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from IO thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // PostTask to UI thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from UI thread
--> content::EmbeddedWorkerInstance::SendStartWorker // from IO thread
--> content::EmbeddedWorkerRegistry::SendStartWorker
--> content::EmbeddedWorkerDispatcher::OnStartWorker

这个过程中,由于频繁的 IO 与 UI 的线程切换,导致 service worker 启动过程中,存在一定的性能开销。

3. 加载 service worker js 文件

分派了 service worker 线程之后,就会继续执行 service worker js 文件的加载流程:

content::EmbeddedWorkerDispatcher::OnStartWorker
--> blink::WebEmbeddedWorkerImpl::startWorkerContext
--> blink::WebEmbeddedWorkerImpl::loadShadowPage // 加载一个与 Service Worker js 文件相同 URL 的空白影子文档
--> blink::FrameLoader::load // 触发空白文档的加载
--> ... ...
--> blink::WebEmbeddedWorkerImpl::didFinishDocumentLoad
--> blink::WebEmbeddedWorkerImpl::Loader::load // 触发 Service Worker js 文件的加载
--> content::ResourceDispatcherHostImpl::BeginRequest
--> content::ServiceWorkerReadFromCacheJob::Start
--> content::ServiceWorkerReadFromCacheJob::OnReadComplete
--> ResourceLoader::didFinishLoading // 完成 Service Worker js 文件的加载

这个过程中,它会先加载一个空白影子文档,再去加载 service worker js 文件,也就是说,会走两次完整的加载流程。

4. 启动 Service Worker 线程

Service worker js 文件加载完成之后,就会触发 service worker 线程的启动流程。这个过程中,主要包括创建 ServiceWorkerGlobalScope,初始化上下文( WorkerScriptController::initializeContextIfNeeded )和执行 JS 代码( WorkerScriptController::evaluate )。

5. 回调通知启动完成

Service worker 线程启动完成之后,回调通知 ServiceWorkerVersion。至此,service worker 线程启动完成。

WebEmbeddedWorkerImpl::startWorkerThread // 启动 Service Worker 线程
--> new ServiceWorkerThread::ServiceWorkerThread
--> content::ServiceWorkerDispatcherHost::OnWorkerStarted
--> content::EmbeddedWorkerRegistry::OnWorkerStarted
--> content::EmbeddedWorkerInstance::OnStarted
--> content::ServiceWorkerVersion::OnStarted // 启动 Service Worker 线程完成

从上面 5 个步骤可以看到,service worker 的启动流程极其复杂,这么复杂的启动流程,会带来怎样的性能消耗呢?我们通过本地测试 Chromium 57 内核版本,初步得出几个结论:

  • 分派 service worker 进程/线程的过程中,有频繁的不同类型线程转换,IO --> UI --> IO --> UI --> IO,这个过程中 UI 线程如果非常繁忙,耗时将会非常大,甚至可以超过 200ms。
  • 加载 service worker js 文件,首次加载需要创建 https 连接并等待服务器响应,耗时可以超过 700ms,但在非首次的场景下,可以从缓存读取,一般能在 50ms 以内完成。
  • 手机锁屏开屏的场景下,浏览器大部分内存都会被清除,会极大的影响缓存读取以及对象创建的时间,比如创建 v8 isolate,一般能在 10ms 完成,但锁屏之后要 80ms 才能完成。

Google 官方文档《Speed up Service Worker with Navigation Preloads》提到:

The bootup time depends on the device and conditions. It's usually around 50ms. On mobile it's more like 250ms. In extreme cases (slow devices, CPU in distress) it can be over 500ms. However, since the service worker stays awake for a browser-determined time between events, you only get this delay occasionally, such as when the user navigates to your site from a fresh tab, or another site.

Service worker 的启动时间与用户设备条件有关,在 PC 上一般为 50ms,手机上大概为 250ms。在极端的场景下,如低端手机且 CPU 压力较大时,可能会超出 500ms。Chromium 团队已尝试使用多种方式来减少 service worker 的启动时间, 比如:

生命周期与状态

1. 生命周期

Google 官方文档 《The Service Worker Lifecycle》 提到:

Service worker 生命周期的目的:

  • 实现离线优先。
  • 允许新服务工作线程自行做好运行准备,无需中断当前的服务工作线程。
  • 确保整个过程中作用域页面由同一个服务工作线程(或者没有服务工作线程)控制。
  • 确保每次只运行网站的一个版本。

整个生命周期的运作方式,官方文档已经说得很清楚,这里不再多说,我们来看看状态管理的机制是怎样的。

2. 状态管理

Service worker 在浏览器内核有两类状态,一类是 service worker 线程的运行状态,另一类是 service worker 脚本版本的状态。

1) Service worker 线程的运行状态, 一般对应 service worker 线程的状态,这类状态只保存在内存中。

  • STOPPED:已停止,EmbeddedWorkerInstance::OnStopped 时设置。
  • STARTING:正在启动,EmbeddedWorkerInstance::Start 时设置。
  • RUNNING:正在运行,EmbeddedWorkerInstance::OnStarted 时设置。
  • STOPPING:正在停止,EmbeddedWorkerInstance::Stop --> EmbeddedWorkerRegistry::StopWorker 返回 status 为 SERVICE_WORKER_OK 时设置。

2) Service worker 脚本版本(即注册函数中指定的 service worker js 文件)的状态,这类状态中的 INSTALLED 和 ACTIVATED 可以被持久化存储。

  • NEW:浏览器内核的 ServiceWorkerVersion 已创建,属于一个初始值。
  • INSTALLING:Install 事件被派发和处理,一般在 service worker 线程启动后,即 ServiceWorkerVersion::StartWorker 返回 status 为 SERVICE_WORKER_OK 时设置。
  • INSTALLED:Install 事件已处理完成,准备进入 ACTIVATING 状态。一般在注册信息已存储到数据库,即 ServiceWorkerStorage::StoreRegistration 返回 status 为 SERVICE_WORKER_OK 时设置。
  • ACTIVATING:Activate 事件被派发和处理。一般在当前 scope 下没有 active ServiceWorker 或 INSTALLED 状态的 service worker 调用了 skipWaiting,service worker 就会从 INSTALLED 状态转为 ACTIVATING 状态。
  • ACTIVATED:Activate 事件已处理完成,已正式开始控制页面,可处理各类功能事件。一般在 activate 事件处理完成后就会转为 ACTIVATED 状态,此时 service worker 就可以控制页面行为,可以处理功能事件,比如 fetch、push。
  • REDUNDANT:ServiceWorkerVersion 已失效,一般是因为执行了 unregister 操作或已被新 service worker 更新替换。

需要注意的是:

  • Service worker 规范中提到的 "service workers may be started and killed many times a second",指的是 service worker 线程随时可以被 Started 和 Killed。在关联文档未关闭时,Service worker 线程可以处于 Stopped 状态。在全部关联文档都已关闭时,service worker 线程也可以处于 Running 状态。
  • Service worker 脚本版本的状态,也是独立于文档生命周期的,与 service worker 线程的运行状态无关,service worker 线程关闭时,service worker 脚本版本也可处于 ACTIVATED 状态。
  • Service worker 脚本版本的状态,INSTALLED 和 ACTIVATED 是稳定的状态,service worker 线程启动之后一般是进入这两种状态之一。INSTALLING 和 ACTIVATING 是中间状态,一般只会在 service worker 新注册或更新时触发一次,刷新页面一般不会触发。INSTALLING 成功就转入 INSTALLED,失败就转入 REDUNDANT。ACTIVATING 成功就转入 ACTIVATED,失败就转入 REDUNDANT。
  • 如果 service worker 脚本版本处于 ACTIVATED 状态,功能事件处理完之后,service worker 线程会被 Stop,当再次有功能事件时,service worker 线程又会被 Start,Start 完成后 service worker 就可以立即进入 ACTIVATED 状态。

浏览器内核会管理三种 service worker 脚本版本:

  • installing_version:处于 INSTALLING 状态的版本
  • waiting_version:处于 INSTALLED 状态的版本
  • active_version:处于 ACTIVATED 状态的版本

installing_version 一般是在 service worker 线程启动后,即 ServiceWorkerVersion::StartWorker 返回 status 为 SERVICE_WORKER_OK 时,处于此版本状态,这是一个中间版本,在正确安装完成后会转入 waiting_version。

waiting_version 一般在注册信息已存储到数据库,即 ServiceWorkerStorage::StoreRegistration 返回 status 为 SERVICE_WORKER_OK 时,处于此版本状态。或者在再次打开 service worker 页面时,检查到 service worker 脚本版本的状态为 INSTALLED,也会进入此版本状态。waiting_version 的存在确保了当前 scope 下只有一个 active service worker。

active_version 一般在 activate 事件处理完成后,就会处于此版本状态,同一 scope 下只有一个 active Service Worker。需要特别注意的是,当前页面已有 active worker 控制,刷新页面时,新版本 Waiting(Installed) 状态的 service worker 并不能转入 active 状态。

Service worker 可以从 waiting_version 转入 active_version 的条件:

  • 当前 scope 下没有 active service worker 在运行。
  • 页面 JS 调用 self.skipWaiting 跳过 waiting 状态。
  • 用户关闭页面,释放了当前处于 active 状态的 service worker。
  • 浏览器周期性检测,发现 active service worker 处于 idle 状态,就会释放当前处于 active 状态的 service worker。

3. 更新机制

Service worker 注册函数中指定的 scriptURL(比如 serviceworker.js),一般有两种更新方式:

1) 强制更新

距离上一次更新检查已超过 24 小时,会忽略浏览器缓存,强制到服务器更新一次。

2) 检查更新(Soft Update

一般在下面情况会检查更新,

  • 第一次访问 scope 里的页面。
  • 距离上一次更新检查已超过 24 小时。
  • 有功能性事件发生,比如 push、sync。
  • 在 service worker URL 发生变化时调用了 register 方法。
  • Service worker JS 资源文件的缓存时间已超出其头部的 max-age 设置的时间(注:max-age 大于 24 小时,会使用 24 小时作为其值)。
  • Service worker JS 资源文件的代码只要有一个字节发生了变化,就会触发更新,包括其引入的脚本发生了变化。

我们看看浏览器内核是怎样实现周期性的检查更新的,service worker schedule update 代码如下:

ServiceWorkerControlleeRequestHandler::~ServiceWorkerControlleeRequestHandler
// Navigation triggers an update to occur shortly after the page and its initial subresources load。
--> ServiceWorkerVersion::ScheduleUpdate // if (is_main_resource_load_)
--> ServiceWorkerVersion::StartUpdate

从上述代码流程可以看到,service worker 页面主文档加载完成时,就会触发 active_version 的一次检查更新,如果距离上一次脚本更新的时间超过了 24 小时,就会设置 LOAD_BYPASS_CACHE 的标记,忽略浏览器缓存,直接从网络加载。

上一次脚本更新的时间,一般在 service worker 安装完成时会更新为当前时间,或者检查到脚本超过 24 小时都没有发生变化也会更新为当前时间,这样就能保证 service worker 在安装完成之后,每隔 24 小时,至少会更新一次。

4. 线程退出

Service worker 线程一般在什么情况下会被停止呢?


  • Service worker JS 资源文件有任何异常,都会导致 service worker 线程退出。包括但不限于如 JS 文件存在语法错误、service worker 安装失败或激活失败、service worker JS 执行时出现未捕获的异常。
  • Service worker 功能事件处理完成,处于空闲状态,Service Worker 线程会自动退出。
  • Service worker JS 执行时间过长,service worker 线程会自动退出。比如 service worker JS 执行时间超过 30 秒,或 Fetch 请求在 5 分钟内还未完成。
  • 浏览器会周期性检查各个 service worker 线程是否可以退出,一般在启动 service worker 线程时会检查一次。
  • 为了方便开发者调试,Chromium 进行了特殊处理,在连上 devtools 之后,service worker 线程不会退出。

5. 消息通信机制

我们知道,在 worker 中无法直接操作 DOM,service worker 也不例外,那么它如何与其控制的页面(至少一个)进行通信呢?接下来我们来看 service worker 与其控制的页面之间的通信机制到底是怎样的。

单向通信

1) 页面使用 ServiceWorker.postMessage 发送消息给 service worker。

function oneWayCommunication() {
  if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage({
      command: 'oneWayCommunication',
      message: 'Hi, SW'
    });
  }
}

2) Service worker 监听 onmessage 事件,即可获取到页面发过来的消息。

self.addEventListener('message', function(event) {
  const data = event.data;
  if (data.command === 'oneWayCommunication') {
    console.log(`Message from the Page : ${data.message}`);
  } 
});

单向通信模式下,页面可以向 service worker 发送消息,但是 service worker 不能回复消息响应给页面。

双向通信

1) 页面建立 MessageChannel,使用 MessageChannel.port1 监听来自 service worker 的消息。使用 ServiceWorker.postMessage 发送消息给 service worker,并且将MessageChannel.port2 也一起传递给 service worker。

function twoWayCommunication() {
  if (navigator.serviceWorker.controller) {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = function(event) {
      console.log(`Response from the SW : ${event.data.message}`);
    }
    navigator.serviceWorker.controller.postMessage({
      command: 'twoWayCommunication',
      message: 'Hi, SW'
    }, [messageChannel.port2]);
  }
}

2. Service worker 监听 onmessage 事件,即可获取到页面发过来的消息。同时,它可使用页面传递过来的 MessageChannel.port2(即 event.ports[0])的 postMessage 方法回复消息给页面。

self.addEventListener('message', function(event) {
  const data = event.data;
  if (data.command === 'twoWayCommunication') {
    event.ports[0].postMessage({
      message: 'Hi, Page'
    });
  }
});

广播通信

1) 页面使用 ServiceWorker.postMessage 发送消息给 service worker,要求它向所有 Client 广播消息。同时,注册 onmessage 事件以监听来自 service worker 的广播消息。

function registerBroadcastReceiver() {
  navigator.serviceWorker.onmessage = function(event) {
    const data = event.data;
    if (data.command === 'broadcastOnRequest') {
      console.log(`Broadcasted message from the ServiceWorker : ${data.message}`);
    }
  };
}

function requestBroadcast() {
  registerBroadcastReceiver();
  if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage({
      command: 'broadcast'
    });
  }
}

2) Service worker 监听 onmessage 事件,获取到页面发过来的广播请求。Service worker 遍历所有的 Client,并使用 Client.postMessage 发送消息给每一个 Client,从而实现消息广播。

self.addEventListener('message', function(event) {
  const data = event.data;
  if (data.command === 'broadcast') {
    self.clients.matchAll().then(function(clients) {
      clients.forEach(function(client) {
        client.postMessage({
          command: 'broadcastOnRequest',
          message: 'This is a broadcast on request from the SW'
        });
      })
    })
  }
});

存在的问题

这里我们重点探讨下 MessageChannel,理解它的原理和可能存在的问题。原理我们描述一下:

  • 页面实例化 MessageChannel 对象,浏览器内核在创建 MessageChannel 的过程中,同时会创建两个 MessagePort,一个用于监听来自 service worker 的消息,另外一个传递给 service worker,service worker 可使用它来回复消息。页面使用 ServiceWorker.postMessage 向 service worker 发送消息,而 service worker 使用 port2 回复消息。
  • Service worker 的 StopWorker 会触发 MessagePort::close, MessageChannel 会关闭,MessagePort 在 close 之后就不能收发消息了,而且 service worker 再次重启之后也无法重建原来的 Messagechannel,最新的 Chromium 版本存在同样的问题。这就意味着,在 service worker stop之后,整个双向通信的通道就完全不能使用了。按照 service worker 规范的说明,浏览器可以在任意需要的时候关闭和重启 service worker,这也等同于 service worker 与其控制页面建立的 MessageChannel 随时会断掉,而且无法重建。

解决方案有两种思路:

  • 思路一:从上面分析可以看到,service worker 的 stop 方法会破坏 MessageChannel 的通信通道,那么如果 service worker 不会 Stop,即在页面不关闭时保持不退出呢?理论上 MessageChannel 也可以继续保持正常,这是一个解决思路,但这种思路与规范约定的 service worker 的生命周期存在冲突。
  • 思路二:service worker 的 stop 会破坏 MessageChannel,那么如果我们每次发送消息都新建 MessageChannel 呢?理论上也是可行的,且 Google 官方的 DEMO (《Service Worker postMessage() Sample》 Service Worker postMessage() Sample 就是使用了这种方式。它实现一个 sendMessage 方法,通过该方法与 service worker 进行通信,其中每次调用该方法都会创建新的 MessageChannel。缺点是每次消息通信都需要新建 MessageChannel 实例,这样它与单向通信相比,优势就不明显了。
function sendMessage(message) {
  // This wraps the message posting/response in a promise, which will resolve if the response doesn't
  // contain an error, and reject with the error if it does. If you'd prefer, it's possible to call
  // controller.postMessage() and set up the onmessage handler independently of a promise, but this is
  // a convenient wrapper.
  return new Promise(function(resolve, reject) {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = function(event) {
      if (event.data.error) {
        reject(event.data.error);
      } else {
        resolve(event.data);
      }
    };
    // This sends the message data as well as transferring messageChannel.port2 to the service worker.
    // The service worker can then use the transferred port to reply via postMessage(), which
    // will in turn trigger the onmessage handler on messageChannel.port1.
    // See https://html.spec.whatwg.org/multipage/workers.html#dom-worker-postmessage
    navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
  });
}

异常处理机制

1. 线程退出时机

Service worker 规范中提到:“Service workers may be started by user agents without an attached document and may be killed by the user agent at nearly any time”,即 Service Worker 线程可能在任意时间被浏览器停止,即使关联的文档还未关闭 service worker 线程也有可能已被停止。这种设计主要是为了降低 Service Worker 对资源(比如浏览器内存、手机电量等)的消耗。所以,Service Worker 线程一般在什么情况下会被停止?(WTF )

  • Service worker JS 有任何异常,都会导致 service worker 线程退出。包括但不限于 JS 文件存在语法错误、service worker 安装失败或激活失败、service worker JS 执行时出现未被捕获的异常。
  • Service worker 功能事件处理完成,处于空闲状态,service worker 线程会自动退出。
  • Service worker JS 执行时间过长,service worker 线程会自动退出。比如 service worker JS 执行时间超过 30 秒,或 Fetch 请求在 5 分钟内还未完成。
  • 浏览器会周期性检查各个 service worker 线程是否可以退出, 一般在启动 service worker 线程时会检查一次。
  • 为了方便开发者调试, Chromium 进行了特殊处理, 在连上 devtools 之后,service worker 线程不会退出。Keep a serviceworker alive when devtools is attached - chromium - Monorail

所以,service worker 线程退出时会带来什么坑呢?

  • Service worker JS 里面不能使用全局变量,如果需要全局状态,必须自己进行持久化,比如使用 IndexedDB API。
  • Service worker 注册过程中出现异常,无法连上 devtools,无法从 devtools 获取异常信息。

2. 异常案例

Service worker 线程在启动或执行代码的过程中,一般会有下面几类异常:

1) Service worker JS 文件存在语法错误,如 Uncaught SyntaxError: Unexpected token function,这种情况,一般在启动 WorkerThread 的时候,initialize 初始化时,会调用 ScriptController::evaluate 去执行 service worker 的 JS 代码,检查到语法错误时,会引起 service worker 注册失败。

2) Service worker 安装或激活的事件回调函数执行代码存在异常,引起 service worker 线程退出。ScriptPromise 本身会捕获异常,它仅仅返回 Rejected/Fulfilled,并不会再将 JS 异常往上抛,很多时候前端开发同学仅仅能看到 Promise Rejected 了,但并不清楚是什么原因。同样,WaitUntilObserver 也一样,它也只返回 Rejected/Fulfilled,没有进一步将 JS 异常往上抛,很多时候前端开发同学仅仅能看到 WaitUntil Rejected 了,也并不清楚是什么原因。

3) 功能事件处理出错, 如 Fetch ResponseWith 出错。举个例子,下面 service worker js 文件的 fetch 事件处理函数中,如果 strategies.networkFallbackToCache 执行出错了,会出现什么问题?

self.addEventListener('fetch', function(e) {
  return e.respondWith(strategies.networkFallbackToCache(e.request));
});

这种情况,respondWith 会 Rejected,但并不会抛异常, 表现为资源请求失败了,很可能造成页面白屏或者排版显示异常。这类问题非常难跟进, 只能是一步一步的修改页面,然后使用devtools 等工具进行调试。

文章被以下专栏收录