初识service-worker

发布于 | 分类于 前端/前端业务|本文包含AIGC内容

在整理资料时看到了PWA和service worker,虽然这些概念很早之前就有耳闻,但一直没有机会做PWA的业务,也没有将service worker应用到项目中,本文整理学习一下。

参考

Service Worker概念 ​

Service Worker 是一种运行在浏览器后台的脚本,独立于网页运行,旨在实现对网络请求的拦截与控制、缓存资源以及离线体验。它是 Progressive Web App (PWA) 的核心技术之一。

Service Worker 是现代 Web 应用优化的重要工具,它为离线体验和性能提升提供了强大的支持。适用于需要提升加载速度或提供离线功能的项目。

Service Worker 不仅是一个独立于主线程的的一个工作线程,并且还是一个可以在离线环境下运行的工作线程

可以解决的问题

  • 在弱网和断网环境下还可以展示出数据
  • 缓存接口,减轻服务端压力
  • 资源预加载,精细控制缓存等

使用demo ​

首先需要注册worker,可以理解为一个特殊的js文件,运行在特殊的上下文中 ,无法调用DOM API,可以调用Service worker的特定API

html
<script>
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/worker.js')
            .then(registration => {
                console.log('Service Worker registered with scope:', registration.scope);
            })
            .catch(error => {
                console.error('Service Worker registration failed:', error);
            });
    }
</script>

对应的worker.js代码,展示了缓存网络接口的能力

js
// 缓存名称
const CACHE_NAME = 'test-api-cache';

// Service Worker 安装事件 - 用于初始化缓存
self.addEventListener('install', event => {
  console.log('[Service Worker] 安装完成');
  self.skipWaiting(); // 强制激活新版本
});

// 拦截请求并进行缓存
self.addEventListener('fetch', event => {
  const requestUrl = new URL(event.request.url);

  // 检查是否是要缓存的接口
  if (requestUrl.pathname === '/test.json') {
    event.respondWith(
      caches.match(event.request).then(cachedResponse => {
        if (cachedResponse) {
          console.log('[Service Worker] 从缓存中读取:', event.request.url);
          return cachedResponse; // 如果有缓存,直接返回
        }

        console.log('[Service Worker] 正在从网络获取:', event.request.url);
        return fetch(event.request).then(networkResponse => {
          return caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, networkResponse.clone()); // 缓存响应
            return networkResponse; // 返回网络响应
          });
        }).catch(error => {
          console.error('[Service Worker] 网络请求失败:', error);
        });
      })
    );
  }
});

编写一个网络请求的事件,测试一下被缓存的逻辑

html
<button id="myBtn">click </button>
<script>
    myBtn.addEventListener("click", () => {
        fetch('/test.json').then(res => {
            return res.json()
        }).then(data => {
            console.log(data)
        })
    })
</script>

然后访问该页面(需要通过服务端形式访问),可以看见已经注册好了对应的worker

点击按钮之后,在调试面板的cache storage可以看见对应的缓存

作用域 ​

location.origin包含了协议、域名和端口号,同域名不同端口被认为是不同的origin

同源策略限制,当前协议+域名+端口号下面,在注册register时返回的 registration.scope,就是对应的作用域

js
navigator.serviceWorker.register('/worker.js').then(registration => {
    console.log('Service Worker registered with scope:', registration.scope);
})

可以向register传入第二个参数的配置项中添加scope来显示指定worker的作用域,无法为同一个作用域注册多个worker。

为了简化设计,避免出现作用域污染的情况(一个页面被多个不同域的worker控制),在SPA中,一般在根路径注册一个worker即可。

下面是作用域测试步骤。

首先,本地启动3001和3002两个端口的服务

本地启动3001和3002两个端口的服务

  • 3001有一个index.html文件,用于注册woker和加载资源
  • 3002有一个test2.html文件,用于提供iframe

测试3001注册service worker,是否可以缓存3002端口的的资源

检查控制台和network面板,查看对应资源的返回状态。

图片、样式表等静态资源,以及接口等动态资源,可以跨域被缓存

iframe的src会区分是否跨域

没跨域的可以被sw缓存

跨域的无法被sw缓存

使用workbox提前缓存时

当3002的服务配置了CORS之后,可以提前缓存静态资源

js
workbox.precaching.precacheAndRoute([
  'http://localhost:3002/index.css',
]);

但是无法缓存html资源

js
workbox.precaching.precacheAndRoute([
  '/index.css',
  'http://localhost:3002/test2.html'
  // 其他需要预缓存的资源
]);

会出现下面的错误

copyResponse.js:38 Uncaught (in promise) cross-origin-copy-response: workbox-core.copyResponse() can only be used with same-origin responses. It was passed a response with origin http://localhost:3002.

因此无法直接缓存跨域的html文件。

与Web Worker的区别 ​

这个问题需要从Web Worker的背景说起来。

由于浏览器中的 JavaScript 都是运行在一个单一主线程上的,在同一时间内只能做一件事情。为了解决大量计算导致UI界面卡顿的问题,提出了Web Worker的方案。

Web Worker 是脱离在主线程之外的工作线程,可以在其中进行一些复杂的耗时的工作,完成后通过 postMessage 告诉主线程工作的结果。

有了Web Worker,代码执行性能问题好像是解决了,但 Web Worker 是临时存在的,每次做的事情的结果不能被持久存下来,下次初始化还需要一个worker来做同样重复的事情。那能不能有一个 Worker 线程是一直是可以持久存在的,并且随时准备接受主线程的命令呢?因此就有了Service Worker。

Service Worker可以通过自身的生命周期特性保证复杂的工作只处理一次,并持久缓存处理结果,直到修改了 Service Worker 的内在的处理逻辑。

另外一个方面,为了解决 Web 网络连接不稳定的问题,需要实现离线缓存的机制。Service Worker 可以拦截并代理请求,可以处理请求的返回内容,可以持久化缓存静态资源达到离线访问的效果

因此可以看出,

设计缓存系统 ​

请求一致性 ​

service worker 默认使用cache storage来储存接口信息,而cache storage 只支持 GET 请求。本章节提到的请求一致性都指代GET请求。

在Service Worker 中,请求的一致性 是指两个请求是否被认为是“相同的”,从而在缓存匹配操作(如 cache.match(request))中,它们是否能够成功匹配。

要确定请求是否一致,必须满足以下几个关键条件:

请求的 URL 必须一致

  • 完整的 URL 必须匹配(包括协议、主机名、端口、路径、查询字符串等)。

  • 例如:

    javascript
    // 这两个请求不一致(查询参数不一致)
    const req1 = new Request('https://example.com/api/data');
    const req2 = new Request('https://example.com/api/data?version=2');
  • 但这两个请求是一致的:

    javascript
    const req1 = new Request('https://example.com/api/data');
    const req2 = new Request('https://example.com/api/data');

请求方法 (HTTP Method) 必须一致

  • 只有在 方法相同 的情况下,GET 和 POST 请求不会被互相匹配。

  • 例如:

    javascript
    const req1 = new Request('https://example.com/api/data', { method: 'GET' });
    const req2 = new Request('https://example.com/api/data', { method: 'POST' });
  • 这两个请求是不一致的,因为 GET 和 POST 是不同的方法。

请求的标头 (Headers) 必须一致

  • 在 缓存匹配 中,只有部分头部被考虑,例如:

    • Vary 头会影响缓存匹配。
    • Accept, Content-Type 和某些请求头可能会导致请求被认为是不同的。
  • 例如,下面两个请求由于 Accept 头的差异,可能会被视为不同的请求:

    javascript
    const req1 = new Request('https://example.com/api/data', { headers: { 'Accept': 'application/json' } });
    const req2 = new Request('https://example.com/api/data', { headers: { 'Accept': 'text/html' } });
  • 但是,大多数普通的请求头(如 User-Agent, Host, Referer)不会影响匹配,因为它们不会被存储在 Request 对象中。

请求的模式 (mode) 必须一致

  • mode 表示请求的模式,常见的值包括:

    • cors: 跨域请求
    • same-origin: 同源请求
    • no-cors: 跨域请求的简化模式
  • 例如:

    javascript
    const req1 = new Request('https://example.com/api/data', { mode: 'cors' });
    const req2 = new Request('https://example.com/api/data', { mode: 'same-origin' });
  • 这两个请求是不一致的,因为 mode 不同。

请求的信号 (signal) 必须一致

  • 这是指与请求关联的 AbortController 对象(signal 属性)。
  • 如果 Request 使用了与 AbortController 绑定的 signal,即使 URL 相同,signal 不同也会被认为是不一致的。

请求的重定向 (redirect) 必须一致

redirect可以是以下之一:

  • follow(默认)
  • error(在重定向时返回错误)
  • manual(手动重定向)

如果这两个请求的 redirect不同,则请求被认为是不一致的:

javascript
const req1 = new Request('https://example.com/api/data', { redirect: 'follow' });
const req2 = new Request('https://example.com/api/data', { redirect: 'error' });

请求的缓存 (cache) 必须一致

cache选项可以是:

  • default
  • no-store
  • reload
  • no-cache
  • force-cache
  • only-if-cached

如果 cache策略不同,缓存的匹配也会失败:

javascript
const req1 = new Request('https://example.com/api/data', { cache: 'reload' });
const req2 = new Request('https://example.com/api/data', { cache: 'no-store' });

请求的 referrer 和 referrerPolicy 必须一致

referrer 和 referrerPolicy 也会影响请求的一致性。例如:

javascript
const req1 = new Request('https://example.com/api/data', { referrer: 'https://example.com/' });
const req2 = new Request('https://example.com/api/data', { referrer: '' });

总结

属性解释影响匹配
URL必须完全相同(包括查询参数)✅
HTTP 方法GET, POST, PUT 等必须相同✅
Headers受 Vary 影响,部分 Headers 需要匹配✅
Modecors, same-origin 等✅
Redirectfollow, error, manual✅
Cachedefault, no-store, reload 等✅
Referrer必须一致✅
信号 (signal)受 AbortController 影响✅

如果想确保请求被认为是“一致的”,请确保以下内容完全相同:

  • URL(路径和查询参数)
  • 请求方法(如 GET, POST)
  • 关键头部(如 Vary, Accept)
  • 请求模式、重定向策略、缓存模式、引用来源 (referrer) 等

通过 caches.match(request) 检查缓存时,稍微的变化(如请求头的不同)都会导致不一致。要减少不一致的情况,确保请求的关键部分是完全一致的。

缓存策略 ​

有两种缓存策略

  • 缓存优先,优先从缓存中获取数据,缓存数据存在,且未过期,则不向服务器重新发起请求,这个策略类似于浏览器的强缓存。
  • 网络优先,强制从数据源请求数据,然后更新缓存,一般用于需要获取最新数据的场景

在请求数据时,可以添加一个自定义请求头,这样在service worker拦截时,可以根据这个头来判断使用哪种策略

假设这个请求头的名字叫做cache-strategy

js
 fetch('/test.json', {
    headers: {
        'cache-strategy': 'network-first' // 或者使用 'network-first'
    },
})

这样在worker中就可以拿到本次拦截的请求的请求头

js
self.addEventListener('fetch', event => {
  const strategy = event.request.headers.get('cache-strategy');
  // 根据不同的请求路径使用不同的策略
  if (strategy === 'cache-first') {
    // 使用网络优先策略
    event.respondWith(cacheFirst(event.request));
  } else if (strategy === 'network-first') {
    //对其他 API 请求使用缓存优先策略
    event.respondWith(networkFirst(event.request));
  }
});

接下来实现一下两种策略

网络优先的策略比较简单,就是不走缓存,请求完成之后再更新一下缓存就行

js
// 缓存策略:网络优先
async function networkFirst(request) {
  try {
    // 先尝试网络请求
    const networkResponse = await fetch(request);
    await saveCache(request, networkResponse)
    return networkResponse;
  } catch (error) {
    // 网络请求失败时,尝试从缓存获取
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      console.log('[Service Worker] 网络请求失败,使用缓存:', request.url);
      return cachedResponse;
    }
    throw error;
  }
}

在更新缓存时,可以设置一下缓存的过期时间,这样可以在获取缓存时判断一下缓存是否有效

js
async function saveCache(request, networkResponse){
  const cache = await caches.open(CACHE_NAME);
  
  // 创建新的响应对象,添加时间戳
  const responseToCache = new Response(networkResponse.clone().body, {
    headers: new Headers(networkResponse.headers),
    status: networkResponse.status,
    statusText: networkResponse.statusText
  });
  responseToCache.headers.set('sw-cache-timestamp', Date.now().toString());
  
  cache.put(request, responseToCache);
}

缓存优先的实现逻辑也比较简单,就是在请求前先获取一下缓存,看看是否有缓存或者缓存是否还没过期

js
// 缓存策略:缓存优先
async function cacheFirst(request) {
  // 先尝试从缓存中获取
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    // 获取缓存的时间戳
    const cachedTime = cachedResponse.headers.get('sw-cache-timestamp');
    const expirationTime = 60 * 60 * 1000; // 设置过期时间为1小时(毫秒)
    
    if (cachedTime && Date.now() - parseInt(cachedTime) < expirationTime) {
      console.log('[Service Worker] 从缓存中读取:', request.url);
      return cachedResponse;
    }
    // 缓存过期,删除旧缓存
    const cache = await caches.open(CACHE_NAME);
    await cache.delete(request);
  }
  
  // 缓存未命中或已过期时,从网络获取并缓存
  const networkResponse = await fetch(request);
  await saveCache(request, networkResponse);
  
  return networkResponse;
}

缓存POST请求 ​

service worker 默认使用cache storage来储存接口信息,而cache storage 只支持 GET 请求,因此 Request 的 body 不会包含在缓存 key 中,而POST的参数一般放在body中,cache storage就无法根据body来区分不同的POST请求。

在RESTful的接口中,post提交需要服务端进行处理,一般情况下是不需要处理的,但是如果非要通过service进行缓存,可以将请求的request映射为一个单独的key,然后再缓存时进行保存

js
async function getCacheKey(request) {
  if (request.method === 'POST') {
    // 根据请求的body自定义缓存key
    const body = await request.clone().text()
    const cacheKey = `${request.url}?body=${body}`;
    return cacheKey
  }
  return request
}

在获取cache和设置cache的地方,都通过这个cacheKey来替代

diff
async function saveCache(request, networkResponse) {
  // ...省略
+ const cacheKey = await getCacheKey(request)
- cache.put(request, responseToCache);
}

async function cacheFirst(request) {
  // 先尝试从缓存中获取
+ const cacheKey = await getCacheKey(request)
+ const cachedResponse = await caches.match(cacheKey);
- const cachedResponse = await caches.match(request);
  // ... 其他逻辑
}

预加载 ​

一般情况下,缓存都是在首次请求之后才会生效的,但对于前端静态资源而言,还可以采取预加载的策略,进一步优化体验。

前端静态资源的一个特点是“确定性”,在网站开发的时候就知道某个页面到底需要哪些资源,因此,可以在Service Worker 安装阶段就主动发起静态资源请求并缓存,这样一旦新的 Service Worker 被激活之后,缓存就直接能投入使用了

js
// 定义需要预缓存的资源列表
const PRECACHE_URLS = [
  '/index.css',
  // 添加其他需要预缓存的资源
];

// Service Worker 安装事件 - 用于初始化缓存
self.addEventListener('install', event => {
  console.log('[Service Worker] 安装完成');
  
  // 添加预缓存逻辑
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      console.log('[Service Worker] 预缓存资源');
      return cache.addAll(PRECACHE_URLS);
    })
  );
  
  self.skipWaiting(); // 强制激活新版本
});

这样,当某个页面需要加载这个index.css的样式表时,就可以通过缓存直接响应,加快页面解析和渲染的速度。

当然,在link标签上使用preload似乎也可以达到相同的效果,不过service worker给我们提供了一种手动编程的API来更精细地控制缓存的方式。

Workbox ​

Workbox 是由 Google 提供的一个开源库,用于简化 Service Worker 的开发和管理。

它封装了常用的功能,例如缓存策略、预缓存、动态缓存和后台同步,使得构建离线体验的 Progressive Web Apps (PWAs) 更加简单高效。

上面提到的功能,都可以使用workbox来实现。

js
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');

const { strategies, routing, cacheNames, expiration } = workbox;

// 缓存名称
const CACHE_NAME = 'test-api-cache';

// 预缓存静态资源
workbox.precaching.precacheAndRoute([
  '/index.css',
  // 其他需要预缓存的资源
]);

// 自定义缓存插件,用于处理 POST 请求的缓存键
class PostCacheKeyPlugin {
  async getCacheKey(request) {
    if (request.method === 'POST') {
      const body = await request.clone().text();
      return new Request(`${request.url}?body=${body}`);
    }
    return request;
  }
}

// 创建缓存过期插件
const expirationPlugin = new expiration.ExpirationPlugin({
  maxAgeSeconds: 60 * 60, // 1小时过期
});

// 注册路由处理器
routing.registerRoute(
  ({ request }) => request.headers.get('cache-strategy') === 'cache-first',
  new strategies.CacheFirst({
    cacheName: CACHE_NAME,
    plugins: [
      new PostCacheKeyPlugin(),
      expirationPlugin,
    ],
  })
);

routing.registerRoute(
  ({ request }) => request.headers.get('cache-strategy') === 'network-first',
  new strategies.NetworkFirst({
    cacheName: CACHE_NAME,
    plugins: [
      new PostCacheKeyPlugin(),
      expirationPlugin,
    ],
  })
);

对比一下,实现相同的功能需要的代码更少,也更容易阅读。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。