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