走进 service worker

简介

什么是 service worker?

简单来说,service worker = worker + 离线缓存。

  1. 首先,什么是 worker
    Web Workers API - Web API 接口 | MDN
    Worker 是浏览器中的 javascript 多线程解决方案,可以将复杂的运算交给后台进行处理。
    浏览器的 js 线程本是单线程的,但是通过 worker,可以将部分运算转移给浏览器原生,从而减轻 js 线程本身的压力。
    worker 和主线程可以通过 message 进行消息传递,如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 主线程 main.js
    var worker = new Worker('worker.js');
    worker.onmessage = function() {
    // 接收子线程消息
    }
    worker.postMessage({
    type: "start",
    value: 1234
    })
1
2
3
4
5
6
7
8
// worker.js
onmessage = function(event) {
// 接收主线程消息
}
postMessage({
type: "done",
value: 1234
})

由于 worker 活在另一个全局中,它也有一些限制:

Worker 的其实有很大用处,不过由于工作内容大多为业务内容,相对来说比较轻量,用不到十分复杂的运算,所以 worker 的使用也并不多见。更多应用可见使用 Web Workers - Web API 接口 | MDN

  1. 关于离线缓存
    所谓离线缓存,就是即使用户断网,也能够正常访问网页内容的一种体验。

在 service worker 之前,我们用 AppCache 实现一些资源的缓存:
应用缓存初级使用指南 - HTML5 Rocks
AppCache 一般长这样:

1
2
3
<html manifest="example.appcache">
...
</html>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CACHE MANIFEST
# v1 2011-08-14
# This is another comment
index.html
cache.html
style.css
image1.png

# Use from network if available
NETWORK:
network.html

# Fallback content
FALLBACK:
/ fallback.html

我刚入行的时候,也见过这个 manifest 文件,不过没有深入了解或应用。现在看到 service worker,才发现这种方案已经被废弃。

为什么app cache没有得到大规模应用?它有哪些硬伤吗? - 知乎
Application Cache is a Douchebag · An A List Apart Article | 中文翻译:Application Cache 就是个坑 | Zoom’s Blog

  1. service worker
    有了 AppCache这一前车之鉴,sw 在被设计时就规避了许多陷阱。
    sw 基于 worker,这决定了它的生命周期独立于页面,我们需要它的时候,它会重启,不需要它的时候,它自己就终止,可以说是一个完美备胎。
    另一方面,它是一个可编程的网络代理。通过监听页面内所有的网络请求,并加以处理、缓存等操作,从而实现了自由、灵活的离线体验。通过一些缓存策略,甚至可以实现资源预加载、页面无等待体验。

依赖

sw 的能力如此之大,权限如此之高,因此需要绝对安全。

  • 它需要再 https 或 localhost 环境中才能正常运行
  • 缓存机制则依赖 Cache API 实现
  • 请求则依赖 HTML5 fetch API
  • 异步则依赖 Promise 实现
  • 除此之外,还要考虑其兼容性

基本概念

生命周期

sw 的生命周期非常简单,可以分为:首次安装、更新 worker 两个流程。

  • 首次安装时

    installing(触发install事件) -> activating(触发activate事件) -> activated

installactivate事件中都可以额外做一些缓存操作,当然,我们更倾向于在前者中缓存必要的静态资源,这是最早且最佳的时机。
两个事件中都可以调用event.waitUntil()这一方法,传入的参数需要是一个promise,待promise对象被resolve之后,才算是这一步真正被完成,然后才进行下一个步骤。上文所说的缓存静态资源的操作,我们通常就是通过这个方法来执行。
待 sw 真正完成了激活(即到了 activated 这一步),才可以开始监听其他功能性事件,包括 fetch、push、async,这些才是真正的重头戏。

  • 更新 worker

    installing(触发install事件) -> waiting -> activating(触发activate事件) -> activated

更新 worker 比安装多了一个步骤——waiting。这一步骤主要是等待上一个 worker 执行完自己的使命,这样的话,我们需要在接收到新 worker 后,再重新加载一次页面,才能真正执行新 worker 的内容。当然,此时执行时,我可以可以将上一次 install 过程中缓存的内容秒展示出来,实现了所谓的预缓存。
但是,这种延后生效的策略难免有些副作用,而且在一定程度上让人难以理解。所以可以使用install事件中的event.waitingUntil()方法来跳过 waiting 这一步骤。同时在activate事件中调用self.clients.claim()方法来获取作用域下其他窗口的控制权(即使没有激活该窗口)。这样,一旦 install 完成,直接就会替代久的 worker,执行新 worker 的内容。

事件

install 和 activate 是生命周期相关的事件;
message 为 worker 的基本事件,用于 worker 与主线程通信;
Fetch、sync、push 则是功能性事件。fetch 主要用于缓存,sync 用于后台更新资源,push 则用于推送通知。后两者尚未非常成熟,暂待观望。

Service Worker 生命周期 | Lavas

简单例子看看流程

放一些代码,结合上文体验更佳

  • 入口(注册 sw.js)
    一般会在页面 load 后加载注册 sw.js,主要是为了不干扰页面首次加载。sw.js 文件名和路径一旦确定以后千万不可再修改,否则会很麻烦。
    一般来说,sw.js 会放在根目录下,这样一来,sw 的作用域就在整个域名,如果想要将范围缩小到某个具体的文件夹下,可以添加 scope参数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
    // Registration was successful
    console.log('ServiceWorker registration successful with scope: ', registration.scope);
    }).catch(function(err) {
    // registration failed :(
    console.log('ServiceWorker registration failed: ', err);
    });
    });
    }
  • install 事件
    此处在 install 中做了一些静态资源的缓存,使用的是 cache api。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    var CACHE_NAME = 'my-site-cache-v1';
    var urlsToCache = [
    '/',
    '/styles/main.css',
    '/script/main.js'
    ];

    self.addEventListener('install', function(event) {
    // Perform install steps
    event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function(cache) {
    console.log('Opened cache');
    return cache.addAll(urlsToCache);
    })
    );
    });
  • activate 事件
    下面这段代码演示的是 sw 更新时,在 activate 事件中清理相关缓存,也使用了相关的 cache api。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    self.addEventListener('activate', function(event) {

    var cacheWhitelist = ['pages-cache-v1', 'blog-posts-cache-v1'];

    event.waitUntil(
    caches.keys().then(function(cacheNames) {
    return Promise.all(
    cacheNames.map(function(cacheName) {
    if (cacheWhitelist.indexOf(cacheName) === -1) {
    return caches.delete(cacheName);
    }
    })
    );
    })
    );
    });
  • 网络代理
    下面的代码中,我们会监听 fetch 事件,并将缓存中已存在的相关响应返回,否则会请求线上数据。
    这是最简单的缓存处理,这里只做例子,让读者了解 fetch 事件大概在做什么事情,实际应用时会更加复杂。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    self.addEventListener('fetch', function(event) {
    event.respondWith(
    caches.match(event.request)
    .then(function(response) {
    // Cache hit - return response
    if (response) {
    return response;
    }
    return fetch(event.request);
    }
    )
    );
    });

上述例子皆来自于 google 开发者文档此篇中的例子也非常生动,对于理解 sw 的生命周期有很大帮助。

如何调试

Application - service worker

可以看到 sw 相关的状态。
由于 sw 更新时会有 waiting 阶段,导致接收到新 sw 后需要再刷新一次才能真正生效,对调试来说非常不方便,可以通过Update on reload
Bypass the network可以无视缓存,总是请求线上内容。

Network

在 network 中查看经过 sw 的请求和从缓存中直接读取的请求,可以看到从缓存中读取的时间只需要7ms,跟实际的请求相比,节约了非常多时间。

Application - Cache Storage

Application 下有多个地方可以管理缓存

缓存策略

这部分内容来自于 google 开发者文档的离线指南,介绍了 service worker 中相关的缓存策略,以及业务内的缓存时机等。这里只搬了常用的且与 service worker 相关的一些内容,并加上了自己的理解。

安装时缓存

适用于页面静态资源 js、css、字体、图片、模板等

在 install 时进行静态资源的缓存。
相比于生命周期的其他阶段,install是最早的,尽早缓存静态资源自然是再好不过。
另一方面,install 一般在页面 load 后,不影响页面资源的正常加载。
但是要注意,这一策略是缓存成功后才进入下一阶段(通过event.waitUntil方法),一旦缓存失败,安装也会失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('mysite-static-v3').then(function(cache) {
return cache.addAll([
'/css/whatever-v3.css',
'/css/imgs/sprites-v6.png',
'/css/fonts/whatever-v8.woff',
'/js/all-min-v4.js'
// etc
]);
})
);
});

安装时缓存 - 非依赖

所谓非依赖就是指,即使缓存失败,也不会影响安装。适用于缓存一些次要的大型资源,例如游戏中的一些次要资源。

代码如下:
可以看到主要资源和次要资源的缓存相关代码的区别就在于有没有写在return中。

1
2
3
4
5
6
7
8
9
10
11
12
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('mygame-core-v1').then(function(cache) {
cache.addAll(
// 缓存次要资源
);
return cache.addAll(
// 缓存主要资源
);
})
);
});

离线优先(缓存、回退到网络)

即先使用缓存内容,无缓存时请求网络
对于一些不会更新的静态资源,比如自带 hash 的js、css、图片、字体等,完全可以采用此种方式。
不过需要注意清理缓存。

代码如下:

1
2
3
4
5
6
7
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});

网络优先(网络回退到缓存)

请求不到网络内容时展示缓存。
在线用户可以获取到最新的内容,但是离线用户获取到的版本较老(需要搭配缓存的更新)。但是这种策略的缺点在于,如果用户网速较慢,最终获取的结果却仍然是一个旧资源,体验很差。

代码如下:

1
2
3
4
5
6
7
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(function() {
return caches.match(event.request);
})
);
});

stale-while-revalidate

stale 是”旧”的意思,revalidate 则意为“可信的”,即获取到的资源是旧的,但是确实可信的。
当用户发出请求时,首先会拿到缓存的内容,并立刻向线上请求最新资源并更新缓存。
这种策略适合于请求频繁的资源,只有这样,用户上一次获取的内容才能足够“可信”。
但这种缓存策略决不能用于跟用户交互相关的资源,例如点赞、评论、投票等等,或者一些非 get 操作,会导致操作“无效”、有误等问题。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.open('mysite-dynamic').then(function(cache) {
return cache.match(event.request).then(function(response) {
var fetchPromise = fetch(event.request).then(function(networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
return response || fetchPromise;
})
})
);
});

缓存然后访问网络

这个策略跟上文的”缓存回退到网络”策略在字面上有些类似,但实际上完全不同。从行为来说,它更类似于上文的“stale-while-revalidate”策略,都是先展示缓存内容,然后再获取线上资源。不同的是,前者会通过提示用户更新来展示,后者则是在下一次请求中备用。
社交网络信息流、以及一些内容型网站,经常会用这种策略来保持当前阅读进度的同时,提示用户内容有更新。
代码比较复杂,请查阅原文档

常规回退

没有缓存且网络不可用时,使用某固定资源兜底(该固定资源需要在 sw 安装时作为依赖项缓存)。
这个策略的主要用于 offline、裂图时的兜底样式,从而提高用户体验。

代码如下:

1
2
3
4
5
6
7
8
9
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
}).catch(function() {
return caches.match('/offline.html');
})
);
});

上述的策略都是一些基础策略,通过上述策略,其实可以了解到 cache 相关的常规操作,通过相互搭配、添加其他内容,来实现适合业务的最佳做法。

关于 push 和 sync

这是除了 fetch 以外,sw 能监听的另外两个功能性事件。

这部分内容非常庞大,就不放在这里讨论了。

相关的库

直接使用 sw 原生操作无疑是繁琐的:为不同的资源手动编写不同的策略,还要考虑异步等等。尤其是 SPA 应用大行其道、网页框架层出不穷的今天,我们用构建工具完善我们的开发流程,页面构建不再像原来那样简单原始。
因此,如果要搭配 sw 的配置,我们不可能每次修改页面都手动修改资源文件。
google 官方也深谙其道,出了 Workbox 这个库,封装了常用的方法、缓存策略,还提供了一些更高级的玩法。
Workbox 有 cli 方式,也有 webpack 的插件,和构建融为一体,使用上非常方便。
神奇的 Workbox 3.0 - 让你的 Web 站点轻松做到离线可访问 - 前端 - 掘金


更多阅读

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