Bootstrap

PWA 缓存


一、PWA

1、认识 PWA

【原生应用、Web App 与 PWA 的对比】
原生应用(Native app):

  • 好处:
    - 更高的安全性。
    - 更强的计算能力。
    - 更好的 UX(用户体验)。
    - 更少的电池消耗。
  • 不足:
    - 开发成本高(iOS 和 安卓)、速度慢。
    - 版本更新需要将新版本上传到不同的应用商店;
    - 必须去下载才能使用 app。

    网页应用(Web App):
  • 好处:
    - 开发成本低、速度快。
    - 版本更新时上传最新的资源到服务器即可。
    - 应用的加载速度更快。
  • 不足:
    - 缺少离线使用能力。在离线或者在弱网环境下基本上是无法使用的。
    - 缺少了消息推送的能力。
    - 桌面入口不够便捷,想要进入一个页面每次都需要通过浏览器来打开。

    渐进式网页应用(PWA):针对以上 Web App 的缺陷,PWA 进行了查缺补漏。
  • 可以添加至主屏幕。
  • 实现启动动画以及隐藏地址栏。
  • 实现离线缓存功能。即使用户手机没有网络,依然可以使用一些离线功能。
  • 实现消息推送。
  • 实现秒开优化。
  • 兼容性良好:现在 IE 退出了,iOS 也兼容 PWA 了。
  • 其余的优点继承 Web App。

    这些特性将使得 PWA 应用非常接近原生 App,在加载速度方便甚至超越了原生 App。

PWA(Progressive Web App)是渐进式 web app。它实现了网页应用的安装、离线缓存 和 消息推送等功能。

PWA 的功能:

  • 通过引入 Manifest 将 web 应用 添加至主屏幕
  • 通过引入 Service Worker 实现了 离线缓存消息推送

2、用 Manifest 将 web 应用添加至主屏幕

如果需要赋予该 web app 添加至主屏幕(能够作为独立应用独立安装)的能力,则需要 2 步:

  • 在 index.html 中注入 manifest
  • 实现 manifest.json 文件。

在 index.html 中注入 manifest:

<head>
  <link rel="manifest" href="/manifest.json" />
</head>

实现 manifest.json 文件:

{
  // 复制后需要把注释删除掉
  "name": "My PWA", // 必填 显示的插件名称
  "short_name": "PWA Demo", // 可选  在APP launcher和新的tab页显示,如果没有设置,则使用name
  "description": "The app that helps you understand PWA", //用于描述应用
  "display": "standalone", // 定义开发人员对Web应用程序的首选显示模式。standalone模式会有单独的
  "start_url": "/", // 应用启动时的url
  "theme_color": "#313131", // 桌面图标的背景色
  "background_color": "#313131", // 为web应用程序预定义的背景颜色。在启动web应用程序和加载应用程序的内容之间创建了一个平滑的过渡。
  "icons": [ // 桌面图标,是一个数组
    {
    "src": "icon/lowres.webp",
    "sizes": "48x48",  // 以空格分隔的图片尺寸
    "type": "image/webp"  // 帮助userAgent快速排除不支持的类型
  },
  {
    "src": "icon/lowres",
    "sizes": "48x48"
  },
  {
    "src": "icon/hd_hi.ico",
    "sizes": "72x72 96x96 128x128 256x256"
  },
  {
    "src": "icon/hd_hi.svg",
    "sizes": "72x72"
  }
  ]
}

Manifest参考文档

3、Service Worker

(1)、什么是 Service worker

Service Worker 是一个在页面和网络之间的拦截器。是基于 HTML5 API 实现的。也是 PWA 的核心。

Service Worker 能够 拦截请求缓存资源,从而完全控制你的网站。

Service Worker 的结构如下图:
在这里插入图片描述
Service Worker 的实现原理:

  • Service Worker 的是基于 Web Worker 的功能来实现的。不同之处在于,Service Worker 具有存储功能,并且 Service Worker 是与浏览器的生命周期相关联的而非某个页面。

【拓展】Web Worker
Web Worker 让 JavaScript 能够运行在页面主线程之外,不过由于 Web Worker 中是没有当前页面的 DOM 环境的,所以在 Web Worker 中只能执行一些和 DOM 无关的 JavaScript 脚本,并通过 postMessage 方法将执行的结果返回给主线程。所以说在 Chrome 中, Web Worker 其实就是在渲染进程中开启的一个新线程,它的生命周期是和页面关联的。

Service Worker 的特点:

  • 一个独立的执行线程,单独的运行环境——会在浏览器的渲染进程中,开启一个新的事件驱动型服务线程,运行于浏览器后台,与整个浏览器的生命周期绑定在一起。
  • 单独的作用域范围,能够为作用域范围内的页面请求提供服务。
  • 处于安全性考虑,必须在 HTTPS 环境下才能工作。在本地调试时,使用 localhost 则不受 HTTPS 限制——service worker 的权限太大,能拦截所有页面的请求,若对 HTTP 的请求做拦截处理,很容易受到网络攻击。
  • 由于是独立线程,Service Worker 不能操作页面 DOM。但可以通过事件机制来将执行的结果返回给浏览器主线程,例如使用:postMessage 方法。
  • 一旦被 install 就永远存在,除非被手动 unregister。即使Chrome(浏览器)关闭也会在后台运行。利用这个特性可以实现离线消息推送功能。
  • 一旦请求被 Service Worker 拦截接管,意味着任何请求都由你来控制,一定要做好容错机制,保证页面的正常运行。

【拓展】目前 Service Worker 的各大浏览器的兼容情况:请戳这里。

(2)、Service Worker 缓存

service worker 技术实现离线缓存,可以将一些不经常改变的静态文件放到缓存中,提升用户体验。

Service Worker 缓存的实现步骤:

  • 在 index.html 文件中启用 ServiceWorker。
  • 编写 service-worker.js 的缓存脚本。

index.html 中:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello Caching World!</title>
  </head>
  <body>
    <!-- Image -->
    <img src="/images/hello.png" />                 
    <!-- JavaScript -->
    <script async src="/js/script.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);
        });
      }
    </script>
  </body>
</html>

上述代码中,Service Worker 的注册路径决定了其 scope 默认作用页面的范围:

  • 如果 service-worker.js 是在 /sw/ 页面路径下,这使得该 Service Worker 默认只会收到 页面 /sw/ 路径下的 fetch 事件。
  • 如果存放在网站的根路径下,则将会收到该网站的所有 fetch 事件。
  • 如果希望改变它的作用域,可在第二个参数设置 scope 范围。示例中将其改为了根目录,即对整个站点生效。

service-worker.js 中:

var cacheName = 'helloWorld';     // 缓存的名称  
// install 事件,它发生在浏览器安装并注册 Service Worker 时        
self.addEventListener('install', event => { 
/* event.waitUtil 用于在安装成功之前执行一些预装逻辑
 但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
 安装成功后 ServiceWorker 状态会从 installing 变为 installed */
  event.waitUntil(
    caches.open(cacheName)                  
    .then(cache => cache.addAll([    // 如果所有的文件都成功缓存了,便会安装完成。如果任何文件下载失败了,那么安装过程也会随之失败。        
      '/js/script.js',
      '/images/hello.png'
    ]))
  );
});
  
/**
为 fetch 事件添加一个事件监听器。接下来,使用 caches.match() 函数来检查传入的请求 URL 是否匹配当前缓存中存在的任何内容。如果存在的话,返回缓存的资源。
如果资源并不存在于缓存当中,通过网络来获取资源,并将获取到的资源添加到缓存中。
*/
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)                  
    .then(function (response) {
      if (response) {                            
        return response;                         
      }
      var requestToCache = event.request.clone();  //          
      return fetch(requestToCache).then(                   
        function (response) {
          if (!response || response.status !== 200) {      
            return response;
          }
          var responseToCache = response.clone();          
          caches.open(cacheName)                           
            .then(function (cache) {
              cache.put(requestToCache, responseToCache);  
            });
          return response;             
    })
  );
});

上述代码中,为什么要用 request.clone() 和 response.clone() 呢?

因为 request 和 response 是一个流,它只能消耗一次。我们已经通过缓存消耗了一次,然后发起 HTTP 请求还要再消耗一次,所以我们需要在此时克隆请求。

(3)、Service Worker 实现消息推送

service worker 一旦被 install 就永远存在,除非被手动 unregister。即使Chrome(浏览器)关闭也会在后台运行。利用这个特性可以实现离线消息推送功能。

Service Worker 实现消息推送的步骤:

  • 提示用户并获得他们的订阅详细信息。
  • 将这些详细信息保存在服务器上。
  • 在需要时发送任何消息。

【拓展】不同浏览器需要用不同的推送消息服务器。以 Chrome 上使用 Google Cloud Messaging 作为推送服务为例,第一步是注册 applicationServerKey(通过 GCM 注册获取),并在页面上进行订阅或发起订阅。每一个会话会有一个独立的端点(endpoint),订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 Service Worker (通过 GCM 与浏览器客户端沟通)。

具体实现如下:

①、index.html 文件中:实现提示用户并获得他们的订阅详细信息,并将这些详细信息保存在服务器上。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Progressive Times</title>
    <link rel="manifest" href="/manifest.json">                                      
  </head>
  <body>
    <script>
      var endpoint;
      var key;
      var authSecret;
      var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
      // 方法很复杂,但是可以不用具体看,知识用来转化vapidPublicKey用
      function urlBase64ToUint8Array(base64String) {                                  
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
          .replace(/\-/g, '+')
          .replace(/_/g, '/');
        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);
        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
      }
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('sw.js').then(function (registration) {
          return registration.pushManager.getSubscription()                            
            .then(function (subscription) {
              if (subscription) {                                                      
                return;
              }
              return registration.pushManager.subscribe({                              
                  userVisibleOnly: true,
                  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
                })
                .then(function (subscription) {
                  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                  key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
                  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                  authSecret = rawAuthSecret ?
                    btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
                  endpoint = subscription.endpoint;
                  return fetch('./register', {                                         
                    method: 'post',
                    headers: new Headers({
                      'content-type': 'application/json'
                    }),
                    body: JSON.stringify({
                      endpoint: subscription.endpoint,
                      key: key,
                      authSecret: authSecret,
                    }),
                  });
                });
            });
        }).catch(function (err) {
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

②、app.js 文件中:实现服务器发送消息给 service worker。

const webpush = require('web-push');                 
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(                             
  'mailto:[email protected]',
  'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
  'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {           
  var endpoint = req.body.endpoint;
  saveRegistrationDetails(endpoint, key, authSecret); 
  const pushSubscription = {                          
    endpoint: req.body.endpoint,
    keys: {
      auth: req.body.authSecret,
      p256dh: req.body.key
    }
  };
  var body = 'Thank you for registering';
  var iconUrl = 'https://example.com/images/homescreen.png';
  // 发送 Web 推送消息
  webpush.sendNotification(pushSubscription,          
      JSON.stringify({
        msg: body,
        url: 'http://localhost:3111/',
        icon: iconUrl
      }))
    .then(result => res.sendStatus(200))
    .catch(err => {
      console.log(err);
    });
});
app.listen(3000, function () {
  console.log('Web push app listening on port 3111!')
});
// service worker监听push事件,将通知详情推送给用户
// service-worker.js
self.addEventListener('push', function (event) {
 // 检查服务端是否发来了任何有效载荷数据
  var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
  var title = 'Progressive Times';
  event.waitUntil(
    // 使用提供的信息来显示 Web 推送通知
    self.registration.showNotification(title, {                           
      body: payload.msg,
      url: payload.url,
      icon: payload.icon
    })
  );
});

4、结合 webpack 实现 PWA

结合 webpack 实现 PWA 的步骤:

  • 在 index.html 文件中启用 ServiceWorker。
  • 在 webpack 配置文件中使用 workbox-webpack-plugin 插件。
  • 编写 service-worker.js 脚本。

(1)、在 index.html 文件中启用 ServiceWorker

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('/service-worker.js') // 这里无需传入第二个参数 scope 指定 serviceWorker 的作用域,会在 webpack 里通过 urlPattern 来实现。
        .then((registration) => {
          // console.log('ServiceWorker registered: ', registration);
        })
        .catch((err) => {
          console.log('ServiceWorker registration failed: ', err);
        });
    });
  }
</script>

【注意】尽量把这段 js 放在其他资源引入之前。

(2)、修改 webpack 的配置文件

workbox-webpack-plugin 插件是 workbox 的构建工具的实现之一。

①、了解 workbox

workbox 是 GoogleChrome 团队推出的一套 Web App 静态资源本地存储(静态资源离线缓存)的解决方案,该解决方案包含一些 Js 库和构建工具。

workbox 的 5 种缓存模式

  • stateWhileRevalidate
    • 允许你使用缓存的内容尽快响应请求(如果可用),如果未缓存,则回退到网络请求。 然后,网络请求用于更新缓存。
    • 这是一种相当常见的策略,适合更新频率很高但重要性要求不高(至少允许一次缓存 读取)的内容。在有缓存的情况下,该模式保证了客户端第一时间拿到数据的同时,也会去请求网络资源更新缓存, 保证下次客户端从缓存中读取的数据是最新的。因此该模式不能减轻后台服务器的访问压力,但却能给前端用户提供 快速响应的体验。
  • networkFirst(网络回落到缓存):
    • 默认情况下,它将尝试从网络获 取最新请求。 如果请求成功,它会将响应放入缓存中。 如果网络无法返回响应,则将使用缓存响应
    • 这意味着只要当 第一次请求成功时,service worker 就会缓存一份请求结果。当后续重复请求时,缓存也会每次更新。若网络请求失 败时,只要缓存里有内容,就能让前端获取一个响应(从service worker的缓存里)。
    • 因此,此种模式提高了前端页 面的稳固性,即使在网络请求失败的情况下,页面也能从上次的缓存中读取到数据展示,而不是粗鲁的告诉用户网络请 求失败的一个响应。
    • 这种策略一般适用于返回结果不太固定或对实时性有要求的请求,为网络请求失败进行兜底。对于经常更新且关键(由业务判断出来)的请求,网络优先策略是理想的解决方案。
  • cacheFirst(缓存优先,缓存回落到网络):
    • 如果缓存中有响应,则将使用缓存的响应来完成请求,根本不会使用网络。 如果没有缓存的响应,则将通过网络请求 来满足请求,并且将缓存响应,以便直接从缓存提供下一个请求。
    • 该模式可以在为前端提供快速响应的同时,减轻后 台服务器的访问压力。但是数据的时效性就需要开发者通过设置缓存过期时间或更改sw.js里面的修订标识来完成缓 存文件的更新。一般需要结合实际的业务场景来判断数据的时效性。
  • networkOnly:强制使用正常的网络请求,并将结果返回给客户端,这种策略比较适合对实时性要求非常高的请求。(不建议使用)
  • cacheOnly:直接使用 Cache 缓存的结果,并将结果返回给客户端,这种策略比较适合一上线就不会变的静态资源请求。(不建议使用)
②、在 webpack 配置文件中使用 workbox-webpack-plugin 插件。
const { GenerateSW } = require('workbox-webpack-plugin');
const id = new Date().getTime();

// 在 plugins 数组中添加以下配置
new GenerateSW({
  clientsClaim: true,
  skipWaiting: true,
  maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
  cacheId: `${id}-gsw`,
  exclude: ['service-worker.js'],
  // mode: 'development',
  runtimeCaching: [
  // 根据实际情况配置符合条件资源的缓存
    {
      urlPattern: /^https:\/\/test.ceshi.com\/static/,
      handler: 'CacheFirst', // 采用“缓存优先”的缓存模式
      options: {
        cacheName: `${id}-test-cdn`,
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
        },
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
  ],
}),

5、使用 Chrome Devtools 调试 Service Worker

这是一个实现了 service worker 离线缓存功能的网站,打开调试工具,调试 service worker。

在这里插入图片描述





【推荐阅读】
渐进式 Web 应用(PWA)——MDN
PWA 技术解析及爱奇艺 PC 端的实践


【参考文章】
PWA Builder Blog
讲讲 PWA
PWA 构建器博客
第一本 PWA 中文书
PWA 英文书
网站渐进式增强体验(PWA)改造:Service Worker 应用详解
Basic Service Worker Sample
【翻译】Service Worker 入门
Web App Manifest
Service Workers: an Introduction
The Offline Cookbook
微信小程序和PWA对比分析
使用workbox-webpack-plugin实现PWA
npm 之 workbox-webpack-plugin
workbox-webpack-plugin创建pwa
Webpack 插件扫盲系列(四) WorkboxWebpackPlugin
深入浅出 PWA
前端缓存-workbox缓存策略

;