Service Worker:离线应用与后台同步的解决方案

前端常用缓存技术

前端常用缓存技术一般分为http缓存和浏览器缓存。

HTTP缓存

Expires

HTTP1.0的内容,服务器使用Expires头来告诉Web客户端它可以使用当前副本,直到指定的时间为止;

Cache-Control

HTTP1.1引入了Cathe-Control,它使用max-age指定资源被缓存多久,主要是解决了Expires一个重大的缺陷,就是它设置的是一个固定的时间点,客户端时间和服务端时间可能有误差;

Last-Modified / If-Modified-Since

Last-Modified是服务器告诉浏览器该资源的最后修改时间,If-Modified-Since是请求头带上的,上次服务器给自己的该资源的最后修改时间。然后服务器拿去对比。

若Last-Modified大于If-Modified-Since,说明资源有被改动过,则响应整片资源内容,返回状态码200;

若Last-Modified小于或等于If-Modified-Since,说明资源无新修改,则响应HTTP 304,告知浏览器继续使用当前版本。

Etag / If-None-Match

Etag是服务器根据每个资源生成的唯一标识符,当文件内容被修改时标识符就会重新生成。服务器存储着文件的Etag字段,可以在与每次客户端传送If-none-match的字段进行比较。如果相等,则表示未修改,响应304;反之,则表示已修改,响应200状态码,返回数据。

浏览器缓存

Storage

简单的缓存方式有cookie,localStorage和sessionStorage,都是浏览器内置储存功能。

mainfest

html5引入的新标准,可以离线缓存静态文件。

Service Worker

ServiceWorker 介绍

什么是ServiceWorker

Service Worker本质上是充当web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们的目的是创建有效的离线体验、拦截网络请求并根据网络是否可用采取适当的操作,以及更新服务器上的资产。它们还允许访问推送通知和后台同步 API。

ServiceWorker特性

  • Service Worker本质上是一个Web Worker,它独立于Javascript主线程,因此它不能直接访问DOM,也不能直接访问window对象,但是可以访问navigator对象,也可以通过消息传递的方式(如postMessage)与Javascript主线程进行通信。
  • Service Worker独立于Javascript主线程,所以不会造成阻塞。它设计为完全异步,同步API(如XHR和localStorage不能在Service Worker中使用。
  • Service Worker是基于 HTTPS 的,因为Service Worker中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。如果是本地调试的话,localhost是可以的。
  • Service Worker拥有独立的生命周期,与页面无关(关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动)。注册Service Worker后,浏览器会默默地在背后安装Service Worker。

ServiceWorker生命周期

Service Worker 的生命周期可以分为6个阶段:解析(parsed)、安装(installing)、安装完成(installed)、激活(activating)、激活完成(activated)、闲置(redundant)。

Parsed

当我们第一次尝试注册 Service Worker 时,用户代理会解析脚本并获取入口点。如果解析成功(并且满足其他一些要求,例如 HTTPS),我们将可以访问 Service Worker 注册对象。其中包含有关 Service Worker 的状态及其作用域的信息。

matlab 复制代码
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
  .then(function(registration) {
    console.log("Service Worker Registered", registration);
  })
  .catch(function(err) {
    console.log("Service Worker Failed to Register", err);
  })
}

Service Worker注册成功并不意味着它已安装完毕或处于激活状态,而只是意味着脚本已成功解析,它与文档处于同一源上,且源为 HTTPS。注册完成后,服务 Worker 将进入下一个状态。

Installing

一旦 Service Worker 脚本被解析,用户代理就会尝试安装它,并进入安装状态。在 Service Worker 的registration对象中,我们可以在installing属性中检查此状态。

并且,在installing状态下,install事件会被触发,我们一般会在这个回调中处理缓存事件。

javascript 复制代码
navigator.serviceWorker.register('./sw.js').then(function(registration) {
  if (registration.installing) {
    // Service Worker is Installing
  }
})
lua 复制代码
self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(currentCacheName).then(function(cache) {
      return cache.addAll(arrayOfFilesToCache);
    })
  );
});

如果事件中有 event.waitUntil() 方法,其中的 Promise只有在resolve后,install事件才会成功。如果 Promise 被拒绝,install就会失败,Service Worker 就会变为redundant状态。

javascript 复制代码
self.addEventListener('install', function(event) {
  event.waitUntil(
   return Promise.reject(); // Failure
  );
});

Installed / Waiting

如果安装成功,Service Worker 的状态变为 installed (也叫 waiting )。处于这个状态时, Service Worker 是有效的但是是未激活的 worker,暂时没有控制页面的权力,需要等待从当前 worker 获得控制权。

我们可以在 registration 对象的 waiting 属性中检测到此状态。

javascript 复制代码
navigator.serviceWorker.register('./sw.js').then(function(registration) {
  if (registration.waiting) {
    // Service Worker is Waiting
  }
})

我们可以在这个时机去更新新版本或自动更新缓存。

Activating

在以下情况之一时,处于 Waiting 状态的 worker 的 Activating 状态会被触发:

  • 当前没有处于激活状态的 worker
  • self.skipWaiting() 在 sw.js 中被调用,直接跳过waiting阶段
  • 用户导航离开当前页面,从而释放了前一个 active worker
  • 经过了指定时间段,从而释放了前一个 active worker

在当前状态下,activate事件会被触发,在这个回调中我们通常用于清除旧缓存。

javascript 复制代码
self.addEventListener('activate', function(event) {
  event.waitUntil(
    // Get all the cache names
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        // Get all the items that are stored under a different cache name than the current one
        cacheNames.filter(function(cacheName) {
          return cacheName != currentCacheName;
        }).map(function(cacheName) {
          // Delete the items
          return caches.delete(cacheName);
        })
      ); // end Promise.all()
    }) // end caches.keys()
  ); // end event.waitUntil()
});

同install事件,如果Promise被reject了,则activate事件失败,Service Worker变为redundant状态。

Activated

如果激活成功,Service Worker 状态会变成 active ,在这个状态下,Service Worker 是一个可以完全控制网页的激活 worker,我们可以在 registration 对象的 active 属性中检测到此状态。

javascript 复制代码
navigator.serviceWorker.register('./sw.js').then(function(registration) {
  if (registration.active) {
    // Service Worker is Active
  }
})

当 Service Worker 被成功激活后,即可处理绑定的 fetch 和 message 事件。

less 复制代码
self.addEventListener('fetch', function(event) {
  // Do stuff with fetch events
});

self.addEventListener('message', function(event) {
  // Do stuff with postMessages received from document
});

Redundant

以下任一情况,Service Worker 都会变成 redundant。

  • install失败
  • activate失败
  • 有新的 Service Worker 将其替代成为现有的激活 worker

Service Worker 离线缓存

Service Worker 最重要的功能之一,就是可以通过缓存静态资源来实现离线访问我们的页面。

Service Worker 的缓存基于 CacheStorage,它是一个 Promise 对象,我们可以通过 caches 来获取它。CacheStorage提供了一些方法,我们可以通过这些方法来对缓存进行操作。

lua 复制代码
caches.open(currentCacheName).then(function (cache) {
  /** 可以通过cache.put来添加缓存
   *  它接收两个参数,第一个参数是Request对象或URL字符串,第二个参数是Response对象
  */
  cache.put(new Request('/'), new Response('Hello World'));
  
    /** 可以通过cache.addAll来添加缓存资源数组
   *  它接收一个参数,这个参数可以是Request对象数组,也可以是URL字符串数组
  */
  cache.addAll(['/'])

  /** 可以通过cache.match来获取缓存
   *  它接收一个参数,这个参数可以是Request对象,也可以是URL字符串
  */
  cache.match('/').then(function (response) {
    console.log(response);
  });
  
  /** 可以通过cache.delete来删除缓存
   *  它接收一个参数,这个参数可以是Request对象,也可以是URL字符串
  */
  cache.delete('/').then(function () {
    console.log('删除成功');
  });

  /** 可以通过cache.keys来获取缓存的key
   *  然后通过cache.delete来删除缓存
  */
  cache.keys().then(function (keys) {
    keys.forEach(function (key) {
      cache.delete(key);
    });
  });
});

缓存资源

我们在介绍生命周期的时候我们介绍了在installing状态下会调用install方法,通常我们会在install事件中缓存一些资源。

lua 复制代码
self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open(currentCacheName).then(function (cache) {
      return cache.addAll([
        '/',
        '/index.css',
        '/axios.js',
        '/index.html'
      ]);
    })
  );
});

上面的代码中我们缓存了一些资源,所以我们可以在fetch事件中获取并返回刚刚缓存的资源。

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

上面的代码中我们使用caches.match来匹配请求,如果匹配到了,那么就返回缓存的资源,如果没有匹配到,那么就从网络中获取资源。

缓存更新

在上面的步骤中,我们已经缓存了我们的资源,并且该资源并不会随着我们代码或者资源的更改而更新缓存。因此,我们可以通过版本号来控制更新。

介绍生命周期时,我们有了解到在activating状态下会触发activate回调,在该回调中我们可以清除旧缓存,然后在install事件中缓存新的资源。

javascript 复制代码
const version = '2.0';
const currentCache = 'my-cache' + version;

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

卸载

当我们的页面不再需要Service Worker的时候,可以通过在新版本里使用unregister进行卸载。

ini 复制代码
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.ready.then(registration => {
    registration.unregister();
  });
}

需要注意的是,Service Worker卸载并不会删掉我们之前缓存的资源,所以在卸载之前我们需要清除所有的缓存。

缓存策略

从上面的例子可以看出,Service Worker的缓存是通过Cache接口和fetch事件共同实现的。通过Cache接口和fetch事件可以实现多种缓存策略。

仅缓存 (Cache only)

适用于你认为属于该"版本"网站静态内容的任何资源,匹配的请求将只会进入缓存。

仅网络 (Network only)

与"仅缓存"相反,"仅限网络"是指请求通过 Service Worker 传递到网络,而无需与 Service Worker 缓存进行任何交互。

缓存优先 (Cache first)

该策略流程如下:

  • 请求到达缓存。如果资源位于缓存中,请从缓存中提供。
  • 如果请求不在缓存中,请转到网络。
  • 网络请求完成后,将其添加到缓存中,然后从网络返回响应。

该策略适用于静态资源的缓存,它可以绕过 HTTP 缓存可能启动的服务器执行任何内容新鲜度检查,从而加快不可变资源的速度。

网络优先 (Network first)

该策略如下:

  • 先前往网络请求一个请求,然后将响应放入缓存中。
  • 如果您日后处于离线状态,则会回退到缓存中该响应的最新版本。

此策略非常适合 HTML 或 API 请求,当您想在线获取资源的最新版本,同时又希望离线可以访问到最新的可用版本。

延迟验证 (Stale-while-revalidate)

该机制与最后两种策略类似,但其过程优先考虑资源访问速度,同时还在后台保持更新。策略大致如下:

  • 在第一次请求获取资源时,从网络中提取资源,将其放入缓存中并返回网络响应。
  • 对于后续请求,首先从缓存提供资源,然后"在后台"从网络重新请求该资源,并更新资源的缓存条目。
  • 对于此后的请求,您将收到在上一步中从缓存中放置的最后一个网络提取的版本。

Service Worker 后台同步

假设用户在我们的页面上操作了数据并提交,此时正好进入一个网络极差甚至断网的环境里,用户只能看着一直处于loading状态的页面,直到失去耐心关闭页面,这时请求就已经被中断了。

上面这种情况暴露了两个问题:

  • 普通页面会随着页面关闭而终止
  • 网络极差或无网络情况下没用一种解决方案能够解决并维持当前请求以待有网时恢复请求

后台同步是构建在 Service Worker 进程之上的另一个功能,它允许一次性或以一个时间间隔请求后台数据同步。我们可以充分利用这一功能规避以上问题。

工作流程

  • 在Service Worker中监听sync事件
  • 在浏览器中发起后台同步sync
  • 就会触发Service Worker的sync事件,在该监听的回调中进行操作,例如向后端发起请求
  • 然后可以在Service Worker中对服务端返回的数据进行处理

页面触发同步

javascript 复制代码
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
  
  navigator.serviceWorker.ready.then(function (registration) {
    let tag = "data_sync";
    document.getElementById('submit-btn').addEventListener('click', function () {
      registration.sync.register(tag).then(function () {
        console.log('后台同步已触发', tag);
      }).catch(function (err) {
        console.log('后台同步触发失败', err);
      });
    });
  })
}

由于后台同步功能需要在Service Worker注册完成后触发,所以我们可以使用navigator.serviceWorker.ready等待注册完成准备好之后使用 registration.sync.register 注册同步事件。

registration.sync 会返回一个SyncManager对象其中包含register方法和getTags方法。

SW监听同步事件

当页面触发同步事件后,我们需要通过Service Worker来处理sync事件。

javascript 复制代码
self.addEventListener('sync', function (e) {
  let init = { method: 'GET' };
  
  switch (e.tag){
    case "data_sync":
      let request = new Request(`xxxxx/sync`, init);
      e.waitUntil(
        fetch(request).then(function (response) {
          return response;
        })
      );
      break;
  }
});

Taro项目集成

理论说完了,接下来我们可以在taro项目里实践接入Service Worker。

俗话说,站在巨人的肩膀上看世界。

现在市面上实现SW的工具非常多,其中google团队提供了一个十分强大且完善的插件 workbox-webpack-plugin ,接下来我们将通过这个插件实现Service Worker的离线缓存功能。

插件配置

workbox-webpack-plugin提供了两个类名为 GenerateSW 和 InjectManifest,接下来我们通过使用GenerateSW来实现预缓存文件和简单的运行时缓存需求。

在taro项目负责打包的config文件中加入以下配置:

javascript 复制代码
const { GenerateSW } = require('workbox-webpack-plugin');

const config = {
  ...
  h5: {
    ...
    webpackChain(chain) {
        ...
      chain.plugin('generateSW').use(new GenerateSW({
        clientsClaim: true,
        skipWaiting: true,
        runtimeCaching: [
          {
            urlPattern: /./*/, // 需要缓存的路径
            handler: 'StaleWhileRevalidate', // 缓存策略
            options: {
              cacheName: 'my-webcache',
              expiration: {
                maxEntries: 2000,
              },
            },
          }],
      }));
    }
  }
}

加入以上配置后,我们运行build命令可以发现该插件为我们自动生成了Service Worker文件。

Service Worker注册

生成Service Worker文件之后我们需要在项目中进行注册。

在register文件中处理Service Worker的生命周期、状态等信息。

javascript 复制代码
import { register } from 'register-service-worker';

register('./service-worker.js', {
  registrationOptions: { scope: './' },
  ready(registration) {
    console.log('Service worker is active.', registration);
  },
  registered(registration) {
    console.log('Service worker has been registered.', registration);
  },
  cached(registration) {
    console.log('Content has been cached for offline use.', registration);
  },
  updatefound(registration) {
    console.log('New content is downloading.', registration);
  },
  updated(registration) {
    console.log('New content is available; please refresh.', registration);
  },
  offline() {
    console.log('No internet connection found. App is running in offline mode.');
  },
  error(error) {
    console.error('Error during service worker registration:', error);
  },
});

在app.ts中引入该文件,我们就完成了简单的Service Worker的引入。接下来把项目启动,让我们看看SW是否生效。

在正常网络环境中,可以看到我们发起第一次访问的请求列表。

在把网络设置成离线状态后,可以看到我们的请求依然正常返回,并走的是Service Worker的缓存。

我们也可以在控制台看到所有缓存的文件列表。

总的来说,Service Worker是一个非常强大的功能,除了以上介绍的离线缓存和后台同步功能,还可以通过SW实现消息推送、多页面通信等等功能。

(本文作者:龚思晗)

关注公众号「哈啰技术」,第一时间收到最新技术推文。

相关推荐
Мартин.3 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。4 小时前
案例-表白墙简单实现
前端·javascript·css
数云界4 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd4 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常4 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer4 小时前
Vite:为什么选 Vite
前端
小御姐@stella4 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing4 小时前
【React】增量传输与渲染
前端·javascript·面试
eHackyd4 小时前
前端知识汇总(持续更新)
前端
万叶学编程7 小时前
Day02-JavaScript-Vue
前端·javascript·vue.js