PWA学习总结

933 阅读12分钟

简单介绍

PWA(Progressive Web App)渐进式Web APP,它并不是单只某一项技术,而是一系列技术综合应用的结果,其中主要包含的相关技术就是Service Worker、Cache Api、Fetch Api、Push API、Notification API 和 postMessage API。使用PWA可以给我们带来什么好处呢?主要体现在如下几方面

1 离线缓存

2 web页面添加桌面快速入口

3 消息推送

相关知识

Service Worker

简单来说,Service Worker 是一个可编程的 Web Worker,它就像一个位于浏览器与网络之间的客户端代理,可以拦截、处理、响应流经的 HTTP 请求。它没有调用 DOM 和其他页面 api 的能力,但他可以拦截网络请求,包括页面切换,静态资源下载,ajax请求所引起的网络请求。Service Worker 是一个独立于JavaScript主线程的浏览器线程。Service Worker有如下特性:

  • 必须在 HTTPS 环境下才能工作(在开发模式下http://localhost也可以工作)
  • 不能直接操作 DOM,(但是可以通过postMessage发送某些信号,主进程根据信号类型,进行不同的操作)
  • 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
  • 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
  • Service Worker 必须要在主线中进行注册
  • 一旦被 install,就永远存在,除非被手动 unregister
  • 用到的时候可以直接唤醒,不用的时候自动睡眠

注册Service Work

我们需要在主线程中注册Service Worker,并且一般是在页面触发load事件之后进行注册。当Service Worker注册成功后便会进入其生命周期。scope代表Service Worker控制该路径下的所有请求,如果请求路径不是在该路径之下,则请求不会被拦截。

// 注册service worker
window.addEventListener('load', function () {
  navigator.serviceWorker.register('/sw.js', {scope: '/'})
    .then(function (registration) {

      // 注册成功
      console.log('ServiceWorker registration successful with scope: ', registration.scope);
    })
    .catch(function (err) {

      // 注册失败:(
      console.log('ServiceWorker registration failed: ', err);
    });
});

Service Worker生命周期

Service Worker生命周期大致如下

install -> installed -> actvating -> Active -> Activated -> Redundant

Service Worker生命周期图

在Service Worker注册成功之后就会触发install事件,在触发install事件后,我们就可以开始缓存一些静态资。waitUntil方法确保所有代码执行完毕后,Service Worker 才会完成Service Worker的安装。需要注意的是只有CACHE_LIST中的资源全部安装成功后,才会完成安装,否则失败,进入redundant状态,所以这里的静态资源最好不要太多。如果 sw.js 文件的内容有改动,当访问网站页面时浏览器获取了新的文件,它会认为有更新,于是会安装新的文件并触发 install 事件。但是此时已经处于激活状态的旧的 Service Worker 还在运行,新的 Service Worker 完成安装后会进入 waiting 状态。直到所有已打开的页面都关闭,旧的 Service Worker 自动停止,新的 Service Worker 才会在接下来打开的页面里生效。为了能够让新的Service Worker及时生效,我们使用skipWaiting直接使Service Worker跳过等待时期,从而直接进入下一个阶段。

const CACHE_NAME = 'cache_v' + 2;
const CACGE_LIST = [
  '/',
  '/index.html',
  '/main.css',
  '/app.js',
  '/icon.png'
];

function preCache() {
  // 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。
  return caches.open(CACHE_NAME).then(cache => {
    // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
    return cache.addAll(CACGE_LIST);
  })
}

// 安装
self.addEventListener('install', function (event) {
  // 等待promise执行完
  event.waitUntil(
    // 如果上一个serviceWorker不销毁 需要手动skipWaiting()
    preCache().then(skipWaiting)
  );
});

在安装成功后,便会触发activate事件,在进入这个生命周期后,我们一般会删除掉之前已经过期的版本(因为默认情况下浏览器是不会自动删除过期的版本的),并更新客户端Service Worker(使用当前处于激活状态的Service Worker)。

// 删除过期缓存
function clearCache() {
  return caches.keys().then(keys => {
    return Promise.all(keys.map(key => {
      if (key !== CACHE_NAME) {
        return caches.delete(key);
      }
    }))
  })
}

// 激活 activate 事件中通常做一些过期资源释放的工作
self.addEventListener('activate', function (e) {
  e.waitUntil(
    Promise.all([
      clearCache(),
      self.clients.claim()
    ])
  );
});

在这里还有一个问题就是sw.js文件有可能会被浏览器缓存,所以我们一般需要设置sw.js不缓存或者较短的缓存时间 更多详细参考 如何优雅的为 PWA 注册 Service Worker

Service Worker 拦截请求

之前说过,Service Worker 是可以拦截请求的,那么一定就会存在一个拦截请求的事件fetch。我们需要在sw.js去监听这个事件。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(cache => {
      return cache.match(event.request).then(function (response) {

        // 如果 Service Worker 有自己的返回,就直接返回,减少一次 http 请求
        if (response) {
          console.log('cache 缓存', event.request.url, response);
          return response;
        } else {
            
            if (navigator.online) {
            
                return fetch(event.request).then(function(response) {
                    console.log('network', event.request.url, response);
            // 由于响应是一个JavaScript或者HTML,会认为这个响应为一个流,而流是只能被消费一次的,所以只能被读一次
            // 第二次就会报错 参考文章https://jakearchibald.com/2014/reading-responses/
            cache.put(event.request, response.clone());
            return response;
          }).catch(function(error) {
            console.error('请求失败', error);
            throw error;
          });
          
            } else {
                // 断网处理
                offlineRequest(fetchRequest);
            }
          
        }
      });
    })
  );
});

这里我们在fetch事件中监听请求事件,我们通过cache.match来进行请求的比较,如果存再这个请求的响应我们就直接返回缓存结果,否则就去请求。在这里我们通过cache.add来添加新的缓存,他实际上内部是包含了fetch请求过程的(注意:Cache.put, Cache.add和Cache.addAll只能在GET请求下使用)。在match的时候,需要请求的url和header都一致才是相同的资源,可以设定第二个参数ignoreVary:true。caches.match(event.request, {ignoreVary: true}) 表示只要请求url相同就认为是同一个资源。另外需要提到一点,Fetch 请求默认是不附带 Cookies 等信息的,在请求静态资源上这没有问题,而且节省了网络请求大小。但对于动态页面,则可能会因为请求缺失 Cookies 而存在问题。此时可以给 Fetch 请求设置第二个参数。示例:fetch(fetchRequest, { credentials: 'include' } );

Cache API

Cache API 不仅在Service Worker中可以使用,在主页面中也可以使用。我们通过 caches.open(cacheName)来打开一个缓存空间,在,默认情况下,如果我们不手动去清除这个缓存空间,这个缓存会一直存在,不会过期。在使用Cache API之前,我们都需要通过caches.open先去打开这个缓存空间,然后在使用相应的Cache方法。这里有几个注意点:

  • Cache.put, Cache.add和Cache.addAll只能在GET请求下使用
  • 自Chrome 46版本起,Cache API只保存安全来源的请求,即那些通过HTTPS服务的请求。
  • Cache API不支持HTTP缓存头

在使用cache.add和cache.addAll的时候,是先根据url获取到相应的response,然后再添加到缓存中。过程类似于调用 fetch(), 然后使用 Cache.put() 将response添加到cache中

详细MDN文档

Fetch API

Fetch API不仅可以在主线程中进行使用,也可以在Service Worker中进行使用。fetch 和 XMLHttpRequest有两种方式不同:

  • 当接收到一个代表错误的 HTTP 状态码时,从 fetch()返回的 Promise 不会被标记为 reject, 即使该 HTTP 响应的状态码是 404 或 500。相反,它会将 Promise 状态标记为 resolve (但是会将 resolve 的返回值的 ok 属性设置为 false ),仅当网络故障时或请求被阻止时,才会标记为 reject。

  • 默认情况下,fetch 不会从服务端发送或接收任何 cookies, 如果站点依赖于用户 session,则会导致未经认证的请求(要发送 cookies,必须设置 credentials 选项)


// Example POST method implementation:

postData('http://example.com/answer', {answer: 42})
  .then(data => console.log(data)) // JSON from `response.json()` call
  .catch(error => console.error(error))

function postData(url, data) {
  // Default options are marked with *
  return fetch(url, {
    body: JSON.stringify(data), // must match 'Content-Type' header
    cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
    credentials: 'same-origin', // include(始终携带), same-origin(同源携带cookie), omit(始终不携带)
    headers: {
      'user-agent': 'Mozilla/4.0 MDN Example',
      'content-type': 'application/json'
    },
    method: 'POST', // *GET, POST, PUT, DELETE, etc.
    mode: 'cors', // no-cors, cors, *same-origin
    redirect: 'follow', // manual, *follow, error
    referrer: 'no-referrer', // *client, no-referrer
  })
  .then(response => response.json()) // parses response to JSON
}

更多信息请查阅:使用 Fetch

Notification

Notification API 用来进行浏览器通知,当用户允许时,浏览器就可以弹出通知。这个API在主页面和Service Worker中都可以使用,MDN文档

  • 在主页面中使用

// 先检查浏览器是否支持
  if (!("Notification" in window)) {
    alert("This browser does not support desktop notification");
  }

  // 检查用户是否同意接受通知
  else if (Notification.permission === "granted") {
    // If it's okay let's create a notification
    new Notification(title, {
      body: desc,
      icon: '/icon.png',
      requireInteraction: true
    });
  }

  // 否则我们需要向用户获取权限
  else if (Notification.permission !== 'denied') {
    Notification.requestPermission(function (permission) {
      // 如果用户同意,就可以向他们发送通知
      if (permission === "granted") {
        new Notification(title, {
          body: desc,
          icon: '/icon.png',
          requireInteraction: true
        });
      } else {
        console.warn('用户拒绝通知');
      }
    });
  }

  • 在Service Worker中使用

// 发送 Notification 通知
function sendNotify(title, options={}, event) {

  if (Notification.permission !== 'granted') {
    console.log('Not granted Notification permission.');

    // 通过post一个message信号量,来在主页面中询问用户获取页面通知权限
    postMessage({
      type: 'applyNotify'
    })
  } else {

    // 在Service Worker 中 触发一条通知
    self.registration.showNotification(title || 'Hi:', Object.assign({
      body: '这是一个通知示例',
      icon: '/icon.png',
      requireInteraction: true
    }, options));
  }
  
}

我们可以看见当我们在Service Worker中进行消息提示时,用户可能关闭了消息提示的功能,所以我们首先要再次询问用户是否开启消息提示的功能,但是在Service Worker中是不能够直接询问用户的,我们必须要在主页面中去询问,这个时候我们可以通过postMessage去发送一个信号量,根据这个信号量的类型,来做响应的处理(例如:询问消息提示的权限,DOM操作等等)


function postMessage(data) {
  self.clients.matchAll().then(clientList => {
    clientList.forEach(client => {
      // 当前打开的标签页发送消息
      if (client.visibilityState === 'visible') {
        client.postMessage(data);
      }
    })
  })
}

在这里我们只向打开的标签页发送该信号量,避免重复询问

message 事件

由于Service Worker是一个单独的浏览器线程,与JavaScript主线程互不干扰,但是我们还是可以通过postMessage实现通信,而且可以通过post特定的消息,从而让主线程去进行相应的DOM操作,实现间接操作DOM的方式。

  • 页面发送消息给Service Worker 在页面上通过 navigator.serviceWorker.controller 获得 ServiceWorker 的句柄。但只有 ServiceWorker 注册成功后该句柄才会存在。

function sendMsg(msg) {
    const controller = navigator.serviceWorker.controller;

    if (!controller) {
        return;
    }

    controller.postMessage(msg, []);
}

// 在 serviceWorker 注册成功后,页面上即可通过 navigator.serviceWorker.controller 发送消息给它
navigator.serviceWorker
    .register('/test/sw.js', {scope: '/test/'})
    .then(registration => console.log('ServiceWorker 注册成功!作用域为: ', registration.scope))
    .then(() => sendMsg('hello sw!'))
    .catch(err => console.log('ServiceWorker 注册失败: ', err));
    

在 ServiceWorker 内部,可以通过监听 message 事件即可获得消息:


self.addEventListener('message', function(ev) {
    console.log(ev.data);
});
  • Service Worker发送消息给页面

// self.clients.matchAll方法获取当前serviceWorker实例所接管的所有标签页,注意是当前实例 已经接管的
self.clients.matchAll().then(clientList => {
    clientList.forEach(client => {
        client.postMessage('Hi, I am send from Service worker!');
    })
});

在主页面中监听

navigator.serviceWorker.addEventListener('message', event => {
  console.log(event.data);
}); 

Client.postMessage

manifest

3 manifest.json 作用 PWA 添加至桌面的功能实现依赖于 manifest.json,也就是说如果要实现添加至主屏幕这个功能,就必须要有这个文件

{
  "short_name": "短名称",
  "name": "这是一个完整名称",
  "icons": [
  {
    "src": "icon.png",
    "type": "image/png",
    "sizes": "144x144"
  }
],
  "start_url": "index.html"
}

<link rel="manifest" href="path-to-manifest/manifest.json">

name —— 网页显示给用户的完整名称

short_name —— 当空间不足以显示全名时的网站缩写名称

description —— 关于网站的详细描述

start_url —— 网页的初始 相对 URL(比如 /)

scope —— 导航范围。比如,/app/的scope就限制 app 在这个文件夹里。

background-color —— 启动屏和浏览器的背景颜色

theme_color —— 网站的主题颜色,一般都与背景颜色相同,它可以影响网站的显示

orientation —— 首选的显示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。

display —— 首选的显示方式:fullscreen, standalone(看起来像是native app),minimal-ui(有简化的浏览器控制选项) 和 browser(常规的浏览器 tab)

icons —— 定义了 src URL, sizes和type的图片对象数组。

详细配置

MDN详细配置

manifest验证

相关问题

  • 对于不同的资源,我们可能有不同的缓存策略,怎么方便的去实现这些复杂的场景

使用workbox,如果使用webpack进行项目打包,我们可以使用workbox-webpack-plugin插件

  • 为什么不适用其他的本地缓存方案

Web Storage(例如 LocalStorage 和 SessionStorage)是同步的,不支持网页工作线程,并对大小和类型(仅限字符串)进行限制。 Cookie 具有自身的用途,但它们是同步的,缺少网页工作线程支持,同时对大小进行限制。WebSQL 不具有广泛的浏览器支持,因此不建议使用它。File System API 在 Chrome 以外的任意浏览器上都不受支持。目前正在 File and Directory Entries API 和 File API 规范中改进 File API,但该 API 还不够成熟也未完全标准化,因此无法被广泛采用。

同步的问题 就是负担大,如果有大量请求缓存在本地缓存中,如果是同步,可能负担重

  • 在将相应存在cache中并返回给浏览器报错

resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request

这是因为在使用put的时候,是流的一个pipe操作,流是只能被消费一次的。我们可以clone这个response或者reques参考文章

  • 在经过webpack打包后,所有的静态资源都会带有hash值,怎么办

使用某些webpack插件,例如offline-plugin或者webpack-workbox-plugin

代码示例

pwa-study

pwa-webpack-study

参考资料

最后(欢迎大家关注我)

DJL箫氏个人博客

博客GitHub地址(欢迎star)

简书

掘金

个人公众号

个人公众号