ServiceWorker 是一个运行在浏览器背后的独立线程,它拥有访问网络的能力,可以用来实现缓存、消息推送、后台自动更新等功能,甚至可以用来实现一个完整的 Web 服务器。

因为 ServiceWorker 运行在浏览器背后,因为这个特性,它可以实现一些不需要服务器参与的功能,比如消息推送、后台自动更新等。

# 什么是 ServiceWorker

ServiceWorker 提供了一个一对一的代理服务器,它可以拦截浏览器的请求,然后根据自己的逻辑来处理这些请求,比如可以直接返回缓存的资源,或者从网络上获取资源,然后将资源缓存起来,再返回给浏览器。

既然作为一个服务器,那么它就拥有着对应的生命周期,它没有传统的服务器那么复杂,它只有两个生命周期,分别是安装和激活,这个状态可以通过 ServiceWorker.state 来获取。

相信大家都不喜欢干巴巴的文字,下面我们来看一下 ServiceWorker 是怎么使用的,然后看一下它的生命周期,慢慢介绍它的功能。

# ServiceWorker 的使用

# 注册 ServiceWorker

ServiceWorker 的注册是通过 navigator.serviceWorker.register 来完成的;

它接受两个参数:

  • 第一个参数是 ServiceWorker 的脚本地址
  • 第二个参数是一个配置对象,目前只有一个属性 scope ,用来指定 ServiceWorker 的作用域,它的默认值是 ServiceWorker 脚本所在目录。
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/service-worker.js', {
        scope: '/'
    }).then(function (registration) {
        // 注册成功
        console.log('ServiceWorker registration successful with scope: ', registration.scope);
    }).catch(function (err) {
        // 注册失败 :(
        console.log('ServiceWorker registration failed: ', err);
    });
}

上面的代码我们分四个部分讲解:

  • 第一部分是判断浏览器是否支持 ServiceWorker ,如果不支持,那么就提示或者做其他的处理。
  • 第二部分是注册 ServiceWorker ,调用 navigator.serviceWorker.register 方法,它会返回一个 Promise 对象。
  • 第三部分是 register 方法的第一个参数,它是 ServiceWorker 的脚本地址,这个地址是相对于当前页面的地址的。
  • 第四部分是 register 方法的第二个参数,它是一个配置对象,目前只有一个属性 scope ,用来指定 ServiceWorker 的作用域,它的默认值是 ServiceWorker 脚本所在目录。

这里需要注意的就是第三部分和第四部分,我们先来看一下 register 的函数签名,再来讲注意的地方。

/**
 * 注册 ServiceWorker
 * @param {string} scriptURL ServiceWorker 脚本地址
 * @param {Object} options 配置项
 * @param {string} options.scope ServiceWorker 作用域
 * @returns {Promise<ServiceWorkerRegistration>}
 */
register(scriptURL, options)

函数签名看着很简单,但是我们需要注意的是 scriptURLscope 的值,它们的值是相对于当前页面的地址的,而不是相对于 ServiceWorker 脚本的地址的。

scriptURL 其实也没什么好说的,同之前讲的 Worker 一样,就是我们的脚本地址;

scope 的值是用来指定 ServiceWorker 的作用域的,它的默认值是 ServiceWorker 脚本所在目录,也就是 scriptURL 的值,但是我们可以通过 scope 来指定它的作用域,它的作用域是一个目录,它的值是相对于当前页面的地址的,也就是说,它的值是相对于 scriptURL 的值的。

上面说的有点绕,我们直接上代码,上面已经有了注册的代码了,我们现在补充 service-worker.js 的代码,看一下 scope 的值是怎么指定的。

// service-worker.js
self.addEventListener('install', function (event) {
    console.log('install');
});
self.addEventListener('activate', function (event) {
    console.log('activate');
});
self.addEventListener('fetch', function (event) {
    console.log('fetch');
});

上面的代码我都写好了之后,我们将它们放到服务器上,然后访问你托管的地址,打开控制台,你会看到如下的输出:

image.png

可以看到上面有三个输出,首先我们看到的是 ServiceWorker 的生命周期,经过了安装和激活,然后看到了注册成功的提示;

将页面刷新再看看控制台:

image.png

可以看到并没有进行安装和激活,这是因为我们的 ServiceWorker 已经注册成功了,它会一直存在,除非我们手动的注销它,否则它不会再次进行安装和激活。

注意:我这里出现了 4 次 fetch ,这是因为我有插件的原因,插件请求了一些资源,所以会触发 fetch 事件, fetch 事件会在后面讲到。

# ServiceWorker 生命周期

上面我们已经成功的注册了 ServiceWorker ,那么它的生命周期我们肯定是需要关注一下的,它的生命周期有三个阶段,分别是安装、激活和运行。

# 安装

安装阶段是在 ServiceWorker 注册成功之后,浏览器开始下载 ServiceWorker 脚本的阶段;

这个阶段是一个异步的过程,我们可以在 install 事件中监听它,它的回调函数会接收到一个 event 对象;

我们可以通过 event.waitUntil 来监听它的完成状态,当它完成之后,我们需要调用 event.waitUntil 的参数,这个参数是一个 Promise 对象,当这个 Promise 对象完成之后,浏览器才会进入下一个阶段。

self.addEventListener('install', function (event) {
    console.log('install');
    event.waitUntil(
        // 这里可以做一些缓存的操作
    );
});

注意: event.waitUntil 不要乱用,它会阻塞浏览器的安装,如果你的 Promise 对象一直没有完成,那么浏览器就会一直处于安装的状态,这样会影响到浏览器的正常使用。

# 激活

激活阶段是在安装完成之后,浏览器开始激活 ServiceWorker 的阶段;

这个阶段也是一个异步的过程,我们可以在 activate 事件中监听它,它的回调函数会接收到一个 event 对象;

self.addEventListener('activate', function (event) {
    console.log('activate');
    event.waitUntil(
        // 这里可以做一些清理缓存的操作
    );
});

不同于安装阶段,激活阶段不需要等待 event.waitUntil 的传递的 Permise 对象完成,它会立即进入下一个阶段。

但是永远不要传递一个可能一直处于 pending 状态的 Promise 对象,否则会导致 ServiceWorker 一直处在某一个状态而无法响应,导致浏览器卡死。

# 运行

运行阶段是在激活完成之后, ServiceWorker 开始运行的阶段;

这个阶段是一个长期存在的过程,我们可以在 fetch 事件中监听它,它的回调函数会接收到一个 event 对象;

self.addEventListener('fetch', function (event) {
    console.log('fetch');
});

任何请求拦截都是在这个阶段进行的,我们可以在这个阶段中对请求进行拦截,然后返回我们自己的响应。

# ServiceWorker 请求拦截

上面我们已经成功的注册了 ServiceWorker ,并且它已经进入了运行阶段,那么我们就可以在这个阶段中对请求进行拦截了。

在上面我贴的图可以看到, ServiceWorker 连插件的请求都拦截了,这是因为 ServiceWorker 的优先级是最高的,它会拦截所有的请求,包括插件的请求。

我的插件请求了是一些 css 文件,也就是说 ServiceWorker 拦截了这些请求,然后返回了自己的响应,这个响应就是我们在 ServiceWorker 中缓存的资源。

插件的请求咱们不用管,现在来看看我们的 ServiceWorker 到底能拦截多少种类型的请求:

html复制代码<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="index.css">
</head>
<body>
<!-- 加载外部 js,axios -->
<script src="axios.js"></script>
<script>
    // 注册 service worker
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/service-worker.js', {
            scope: '/'
        }).then(function (registration) {
            // 注册成功
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
        }).catch(function (err) {
            // 注册失败 :(
            console.log('ServiceWorker registration failed: ', err);
        });
    }
    // 使用 axios 发送请求
    axios.get('/').then(function (response) {
        console.log('axios 成功');
    });
    // 使用 XMLHttpRequest 发送请求
    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/');
    xhr.send();
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
            console.log('XMLHttpRequest 成功');
        }
    }
    // 使用 fetch 发送请求
    fetch('/').then(function (response) {
        console.log('fetch 成功');
    });
</script>
</body>
</html>

上面的代码中我发送了五个请求,分别是请求 axios.jsaxios 发送请求, XMLHttpRequest 发送请求, fetch 发送请求,最头部还有一个 css 请求;

image.png

css 的内容自己随意发挥,我这里就不贴了。

可以看到, ServiceWorker 只进入了 7 次 fetch 事件,也就是说只拦截了 7 次请求,我们可以通过 event.request.url 来查看请求的地址。

self.addEventListener('fetch', function (event) {
    console.log('fetch', event.request.url);
});

通过打印请求地址,发现 axios.js 没有进入 fetch 事件,但是并不影响我们的结果。

关于静态资源为什么没有进入 fetch 事件,我这里没有查到相关资料,但是其实确实是进入了 fetch 事件。

# ServiceWorker 监听事件

上面因为我们只监听了 fetch 事件,所以只有 fetch 请求被拦截了,那么我们可以监听哪些事件呢?

从最开始的生命周期的两个事件, installactivate ,到后面的 fetch 网络请求的,还有其他什么事件呢?

现在就来看看 ServiceWorker 的事件列表:

  • install :安装事件,当 ServiceWorker 安装成功后,就会触发这个事件,这个事件只会触发一次。
  • activate :激活事件,当 ServiceWorker 激活成功后,就会触发这个事件,这个事件只会触发一次。
  • fetch :网络请求事件,当页面发起网络请求时,就会触发这个事件。
  • push :推送事件,当页面发起推送请求时,就会触发这个事件。
  • sync :同步事件,当页面发起同步请求时,就会触发这个事件。
  • message :消息事件,当页面发起消息请求时,就会触发这个事件。
  • messageerror :消息错误事件,当页面发起消息错误请求时,就会触发这个事件。
  • error :错误事件,当页面发起错误请求时,就会触发这个事件。

可以看到最后三个是我们的老伙伴了, messagemessageerrorerror ,它在这个基础上还增加了两个事件, pushsync

翻了很多资料, ServiceWorker 还可以监听 notification 事件,但是目前我还没有找到相关的资料,后续找到了我会单独写一篇文章来讲解。

messagemessageerrorerror 这三个事件,我们在上一篇文章中已经讲解过了,就是主线程和 Worker 之间的通信,文末有链接,可以去看看。

pushsync 这两个事件,今天这里不详解,后续我会单独写一篇文章来讲解。

# ServiceWorker 缓存

缓存是我们日常开发中经常会用到的一个功能, ServiceWorker 也提供了缓存的功能,我们可以通过 ServiceWorker 来缓存我们的静态资源,这样就可以离线访问我们的页面了。

ServiceWorker 的缓存是基于 CacheStorage 的,它是一个 Promise 对象,我们可以通过 caches 来获取它;

caches.open('my-cache').then(function (cache) {
    // 这里可以做一些缓存的操作
});

CacheStorage 提供了一些方法,我们可以通过这些方法来对缓存进行操作;

# 添加缓存

我们可以通过 cache.put 来添加缓存,它接收两个参数,第一个参数是 Request 对象,第二个参数是 Response 对象;

caches.open('my-cache').then(function (cache) {
    cache.put(new Request('/'), new Response('Hello World'));
});

# 获取缓存

我们可以通过 cache.match 来获取缓存,它接收一个参数,这个参数可以是 Request 对象,也可以是 URL 字符串;

caches.open('my-cache').then(function (cache) {
    cache.match('/').then(function (response) {
        console.log(response);
    });
});

# 删除缓存

我们可以通过 cache.delete 来删除缓存,它接收一个参数,这个参数可以是 Request 对象,也可以是 URL 字符串;

caches.open('my-cache').then(function (cache) {
    cache.delete('/').then(function () {
        console.log('删除成功');
    });
});

# 清空缓存

我们可以通过 cache.keys 来获取缓存的 key ,然后通过 cache.delete 来删除缓存;

caches.open('my-cache').then(function (cache) {
    cache.keys().then(function (keys) {
        keys.forEach(function (key) {
            cache.delete(key);
        });
    });
});

# ServiceWorker 缓存策略

ServiceWorker 的缓存策略是基于 fetch 事件的,我们可以在 fetch 事件中监听请求,然后对请求进行拦截,然后返回我们自己的响应;

self.addEventListener('fetch', function (event) {
    event.respondWith(
        caches.match(event.request).then(function (response) {
            if (response) {
                return response;
            }
            return fetch(event.request);
        })
    );
});

上面的代码是一个最简单的缓存策略,它会先从缓存中获取请求,如果缓存中没有请求,那么就会从网络中获取请求;

# 缓存资源

文章开始我们介绍了 ServiceWorker 的生命周期,然后又详解了 fetch 事件,最后又讲了一堆缓存的东西,这些都是为我们接下来的内容做铺垫,接下来我们缓存一些静态资源,然后离线访问我们的页面;

还是上面 fetch 事件的例子,我们请求了 6 个资源,其中 1 是 index.html ,1 是 axios.js ,1 个是 index.css ,剩余的 3 个都是请求的 '/',也是我们的 index.html

但是上面的例子中我们什么都没做,所以我们的页面是没有缓存的,我们可以通过 cache.addAll 来缓存一些资源;

通常我们会在 install 事件中缓存一些资源,因为 install 事件只会触发一次,并且会阻塞 activate 事件,所以我们可以在 install 事件中缓存一些资源,然后在 activate 事件中删除一些旧的资源;

self.addEventListener('install', function (event) {
    event.waitUntil(
        caches.open('my-cache').then(function (cache) {
            return cache.addAll([
                '/',
                '/index.css',
                '/axios.js',
                '/index.html'
            ]);
        })
    );
});

上面的代码中我们缓存了刚才提到的所有资源,缓存了之后当然是使用缓存的资源了,所以我们可以在 fetch 事件中返回缓存的资源;

注意:上面缓存的所有资源一定都是确定的存在的,不能出现除状态码为 200 以外的其他状态码,否则缓存会失败;

self.addEventListener('fetch', function (event) {
    event.respondWith(
        caches.match(event.request).then(function (response) {
            if (response) {
                return response;
            }
            return fetch(event.request);
        })
    );
});

上面的代码中我们使用 caches.match 来匹配请求,如果匹配到了,那么就返回缓存的资源,如果没有匹配到,那么就从网络中获取资源,这也就是我们刚才提到的缓存策略:缓存优先

image.png

看看上面的图,当我们第一次访问页面的时候,我们的页面是没有缓存的,所以我们的页面是从网络中获取的,当我们刷新页面的时候,我们的页面是从缓存中获取的,可以看到来源是 ServiceWorker

# 缓存更新

上面我们已经缓存了我们需要资源,但是我们的资源是不会更新的,现在你可以修改一下 index.css ,然后刷新页面,不管怎么刷新,你的页面都不会更新,这是因为我们的资源是缓存的,所以我们需要更新我们的缓存;

通常情况下,我们会在 activate 事件中删除旧的缓存,然后在 install 事件中缓存新的资源;

self.addEventListener('activate', function (event) {
    event.waitUntil(
        caches.keys().then(function (cacheNames) {
            return Promise.all(
                cacheNames.map(function (cacheName) {
                    if (cacheName !== 'my-cache') {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

如果你想看缓存在哪里,可以在 Application 中的 Cache Storage 中查看:

image.png

现在你可以修改 index.css ,然后按 ctrl + F5 刷新页面,然后再看看 Cache Storage 中的 my-cache ,你会发现 index.css 已经更新了;

我的开发者面板是中文的,新版的谷歌浏览器已经支持中文了,你可以在开发者面板中,通过右上角 齿轮 图标进入修改。

# 实战

现在我们已经知道了如何缓存资源,上面也提到了通过 fetch/xhr 等网络请求也可以被 ServiceWorker 拦截,那么我还是基于之前文章的百万级数据渲染的例子来实现一下缓存,这样我们就可以实现离线访问了;

首先我们需要在 install 事件中缓存我们的数据:

self.addEventListener('install', function (event) {
    event.waitUntil(
        caches.open('my-cache').then(function (cache) {
            return cache.addAll([
                '/',
                '/index.html',
                '/index.css',
                '/index.js',
                '/getData'
            ]);
        })
    );
});

我们在之前的基础上,添加了 /getData 的一个地址,这里我使用了 node.jsexpress 框架,然后在 /getData 中返回我们的数据,下面是我的 node.js 代码:

import express from 'express';
import path from "path"
const app = express();
app.use(express.json())
app.use(express.urlencoded({extended: false}))
const __dirname = path.resolve();
app.use('/', express.static(__dirname + '/public'));
app.listen(1701, async () => {
    console.log('服务启动成功:http://localhost:1701');
})
app.all('/getData', (req, res) => {
    // 百万级数据
    const data = [];
    for (var i = 0; i < 1000000; i++) {
        data.push({
            name: 'name' + i,
            age: i
        });
    }
    res.send(data);
})
process.on('uncaughtException', (e)=>{
    console.error(e); // Error: uncaughtException
});

如果你不想使用 node.js ,那么可以使用一个静态的 json 文件来代替也是一样的。

然后请求这个接口,正好我也引入了 axios ,所以我就直接使用 axios 来请求了:

axios.get('/getData').then(res => {
    console.log(res.data);
})

当我们刷新页面后,我们的数据就已经成功缓存了,然后我们将浏览器设置为离线模式,然后刷新页面,我们就可以看到我们的数据了,这样我们就实现了离线访问了;

image.png

# 总结

ServiceWorker 是一个非常强大的功能,它可以帮助我们实现很多功能,比如缓存、离线访问、消息推送等等;

本章我们主要介绍了 ServiceWorker 的基本使用,以及如何缓存资源,最后我们实现了一个离线访问的功能;

ServiceWorker 还有很多其他的功能都等着我们去探索,比如消息推送、后台同步等等,这些功能我们后面会陆续介绍;

转自:https://juejin.cn/post/7165893682132959245