侧边栏

初识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可以看见对应的缓存

作用域

同源策略限制,当前协议+域名+端口号下面,在注册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即可。

与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) 必须一致

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

  • 例如:

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

请求的标头 (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 必须一致

referrerreferrerPolicy 也会影响请求的一致性。例如:

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 等必须相同
HeadersVary 影响,部分 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,
    ],
  })
);

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

你要请我喝一杯奶茶?

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

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