PWA学习总结

简单介绍

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

1 离线缓存css

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

3 消息推送java

相关知识

Service Worker

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

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

注册Service Work

咱们须要在主线程中注册Service Worker,而且通常是在页面触发load事件以后进行注册。当Service Worker注册成功后便会进入其生命周期。scope表明Service Worker控制该路径下的全部请求,若是请求路径不是在该路径之下,则请求不会被拦截。git

// 注册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生命周期大体以下github

install -> installed -> actvating -> Active -> Activated -> Redundantweb

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跳过等待时期,从而直接进入下一个阶段。ajax

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)

简书

掘金

我的公众号

我的公众号
相关文章
相关标签/搜索