在大部分场景预加载是页面性能优化的利器,而对于类似首页这种承担用户第一次访问的页面却无法使用预加载,这时候我们可以用 Stale-While-Revalidate 加速页面访问,策略分 3 步

  1. 在收到页面请求时首先检查缓存,如果命中缓存就直接从缓存中返回给用户
  2. 将缓存返回用户的同时,在后台异步发起网络请求,尝试获取资源的最新版本
  3. 获取成功后更新缓存,下次使用

而这一切的幕后功臣便是 Service Worker,作为一个后台代理在网络与缓存之间搭建桥梁,提供了丰富的缓存管理和资源控制能力,从而实现这一高效策略

为了实现这一策略,需要首先了解一下 Service Worker 的核心 API

Service 基础概念

Service Worker 基础概念可以在这里了解

此处为语雀内容卡片,点击链接查看:www.yuque.com/sunluyong/f…

拦截修改 Response 对象

使用 event.respondWith 可以在 fetch 事件中拦截网络请求并提供自定义响应,一旦调用浏览器会等待提供的 Promise 解析,并将其结果作为响应返回给发起请求的代码

 1self.addEventListener('fetch', event => {
 2  event.respondWith(
 3    
 4  );
 5});

比如实现拦截特定请求,可以首先尝试从缓存中获取资源,如果缓存命中则返回缓存内容,否则从网络获取资源并缓存

 1self.addEventListener('fetch', event => {
 2  
 3  const url = new URL(event.request.url);
 4  if (!url.pathname.startsWith('/page/')) return;
 5  
 6  event.respondWith(
 7    caches.match(event.request) 
 8      .then(cachedResponse => {
 9        if (cachedResponse) {
10          return cachedResponse; 
11        }
12        
13        return fetch(event.request);
14      })
15  );
16});

必须在 fetch 事件监听器内部的第一时间调用 event.respondWith,否则浏览器将继续使用默认的网络请求处理方式

clone Response 对象缓存

在 Service Worker 中处理网络请求和缓存时,经常会遇到需要 clone 响应对象

 1const responseToCache = networkResponse.clone();

这是由于 Response 对象是一个可读流,而流具有以下特性

  • 单次消费:Streams 在被消费后就会关闭,不能重新读取
  • 节省资源:适合处理大型数据,如视频流、文件下载等

当读取 Response 的 body 返回给浏览器后,Stream 会被读取并关闭,之后无法再次读取用于缓存。通过 clone Response 对象,可以创建一个独立的副本,确保每个副本的 Stream 都可单独消费

 1fetch(event.request).then(networkResponse => {
 2  
 3  const responseToCache = networkResponse.clone();
 4
 5  
 6  event.respondWith(networkResponse);
 7
 8  
 9  caches.open(CACHE_NAME).then(cache => {
10    cache.put(event.request, responseToCache);
11  });
12});

event.waitUntil 确保异步任务完成

Service Worker 事件都是异步的,浏览器可能在这些异步操作完成之前终止 Service Worker,导致关键任务(如缓存资源或清理旧缓存)无法正确完成

通过调用 event.waitUntil(promise),可以告诉浏览器要“等待”某个 Promise 完成之后,才认为事件处理完成,这确保了浏览器不会在关键异步操作完成之前终止 Service Worker

比如在激活阶段,通常需要清理旧的缓存

 1self.addEventListener('activate', event => {
 2  console.log('[Service Worker] Activate Event');
 3  const cacheWhitelist = ['my-cache-v2'];
 4
 5  caches.keys().then(cacheNames => {
 6    return Promise.all(
 7      cacheNames.map(cacheName => {
 8        if (!cacheWhitelist.includes(cacheName)) {
 9          console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
10          return caches.delete(cacheName);
11        }
12      })
13    );
14  })
15});

浏览器可能在异步缓存清理任务完成之前终止激活过程,导致旧缓存可能未被正确删除,使用 event.waitUntil 可以确保所有清理操作完成

 1self.addEventListener('activate', event => {
 2  console.log('[Service Worker] Activate Event');
 3  const cacheWhitelist = ['my-cache-v2'];
 4  
 5  event.waitUntil(
 6    caches.keys().then(cacheNames => {
 7      return Promise.all(
 8        cacheNames.map(cacheName => {
 9          if (!cacheWhitelist.includes(cacheName)) {
10            console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
11            return caches.delete(cacheName);
12          }
13        })
14      );
15    })
16  );
17});

Stale-While-Revalidate 实现

1. 创建目录结构

 1.
 2├── app
 3│   └── index.js
 4├── package.json
 5└── public
 6    ├── favicon.ico
 7    ├── index.html
 8    └── sw.js

因为 Service Worker 需要服务端配合,为了简单使用 express 演示

 1npm install --save express

2. 提供 web 服务

修改 app/index.js,public 目录对外服务,为了演示缓存更新效果,添加了一个带有页面版本号的自定义响应头 x-page-version

 1const express = require('express');
 2const path = require('path');
 3
 4const app = express();
 5const port = 3000;
 6
 7app.use(express.static(path.join(__dirname, '../public'), {
 8  setHeaders: (res) => {
 9    
10    res.set('x-page-version', Math.ceil(Date.now() / 5000));
11  }
12}));
13
14
15app.listen(port, () => {
16  console.log(`Example app listening at http://localhost:${port}`);
17});

3. Service Worker 实现

首先是最基础的安装、激活,代码量并不大,主要是添加了很多 log 方便观测 Service Worker 执行过程

 1const CACHE_NAME = 'HOMEPAGE_CACHE_v1'; 
 2
 3
 4const urlsToCache = [
 5  '/',
 6];
 7
 8
 9self.addEventListener('install', (event) => {
10  console.log('[Service Worker] Install Event');
11  event.waitUntil(
12    caches.open(CACHE_NAME).then((cache) => {
13      console.log('[Service Worker] Caching pre-defined resources');
14      return cache.addAll(urlsToCache);
15    }).catch((error) => {
16      console.error('[Service Worker] Failed to cache resources during install:', error);
17    })
18  );
19});
20
21
22self.addEventListener('activate', (event) => {
23  console.log('[Service Worker] Activate Event');
24  const cacheWhitelist = [CACHE_NAME];
25  event.waitUntil(
26    caches.keys().then((cacheNames) => {
27      return Promise.all(
28        cacheNames.map((cacheName) => {
29          if (!cacheWhitelist.includes(cacheName)) {
30            console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
31            return caches.delete(cacheName);
32          }
33        })
34      );
35    }).then(() => self.clients.claim()) 
36  );
37});

4. 劫持页面请求

 1
 2self.addEventListener('fetch', (event) => {
 3  const requestUrl = new URL(event.request.url);
 4  
 5  if (!urlsToCache.includes(requestUrl.pathname)) return;
 6
 7  
 8  event.respondWith(
 9    caches.match(event.request).then((cachedResponse) => {
10      if (cachedResponse) {
11        
12        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);
13
14        
15        event.waitUntil(
16          fetch(event.request).then((networkResponse) => {
17            if (networkResponse && networkResponse.status === 200) {
18              return caches.open(CACHE_NAME).then((cache) => {
19                
20                cache.put(event.request, networkResponse.clone());
21                console.log(`[Service Worker] Fetched and cached (background): ${event.request.url}`);
22              });
23            }
24          }).catch((error) => {
25            console.error(`[Service Worker] Background fetch failed for: ${event.request.url}`, error);
26          })
27        );
28
29        return cachedResponse; 
30      }
31
32      
33      return fetch(event.request).catch((error) => {
34        console.error(`[Service Worker] Fetch failed for: ${event.request.url}`, error);
35      });
36    })
37  );
38});

这样就基本实现了 Stale-While-Revalidate

5. 注册 Service Worker

在主线程激活 Service Worker

 1if ("serviceWorker" in navigator) {
 2  navigator.serviceWorker.register("/sw.js").then(registration => {
 3    console.log(`Service Worker registered with scope: ${registration.scope}`);
 4  }).catch(error => {
 5    console.log(`Service Worker registration failed: ${error}`);
 6  });
 7}

更进一步

可以对上面 demo 改进一下,当获取到最新版本页面后和缓存对比,如果发现页面版本已更新,可以给主线程发送通知,让页面重新发请求,获取最新版本的缓存

更新 fetch 事件处理

 1
 2self.addEventListener('fetch', (event) => {
 3  const requestUrl = new URL(event.request.url);
 4  
 5  if (!urlsToCache.includes(requestUrl.pathname)) return;
 6
 7  
 8  event.respondWith(
 9    caches.match(event.request).then((cachedResponse) => {
10      if (cachedResponse) {
11        
12        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);
13
14        
15        event.waitUntil(
16          fetch(event.request).then((networkResponse) => {
17            if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
18              
19              const cachedVersion = cachedResponse.headers.get('x-page-version');
20              
21              const networkVersion = networkResponse.headers.get('x-page-version');
22              console.log(`[Service Worker] Cached Version: ${cachedVersion}`);
23              console.log(`[Service Worker] Network Version: ${networkVersion}`);
24              
25              if (networkVersion !== cachedVersion) {
26                return caches.open(CACHE_NAME).then((cache) => {
27                  cache.put(event.request, networkResponse.clone());
28                  console.log(`[Service Worker] Fetched and cached (background): ${event.request.url}`);
29
30                  
31                  return sendMessage({
32                    version: networkVersion,
33                    action: 'update',
34                    url: event.request.url,
35                  });
36                });
37              }
38            }
39          }).catch((error) => {
40            console.error(`[Service Worker] Background fetch failed for: ${event.request.url}`, error);
41          })
42        );
43
44        return cachedResponse; 
45      }
46
47      
48      return fetch(event.request).catch((error) => {
49        console.error(`[Service Worker] Fetch failed for: ${event.request.url}`, error);
50      });
51    })
52  );
53});
54
55
56function sendMessage(data) {
57  return self.clients.matchAll().then((clients) => {
58    clients.forEach((client) => {
59      client.postMessage(data);
60    });
61  });
62}

更新主线程,添加接收来自 Service Worker 消息事件

 1navigator.serviceWorker.addEventListener("message", event => {
 2  console.log('Received a message from Service Worker:', event.data);
 3  if (event.data.action === "update") {
 4    if (event.data.url === window.location.href ) {
 5      console.log('load lasted version');
 6      location.href = event.data.url;
 7    }
 8  }
 9});

这就是 alibaba.com 秒开的秘籍

个人笔记记录 2021 ~ 2025