PWA 渐进式 Web 应用

作者: 贺鹏飞 分类: 前沿技术,数据可视化 发布时间: 2021-03-12 10:48

前言

Web是一个很神奇的平台,拥有跨设备和跨操作系统的兼容性,拥有以用户为中心的权限模型。它规范是由W3C和WHATWG两个组织共同定制,它的实现则是交给各个浏览器厂商。再加上其固有的可连接性,用户可以随时随地搜索到,或者分享一个网页给任何人。不管何时访问网页,都是最新的。WebApp只需要一套代码,就可以触达任何人,任何地方,任何设备。

PWA 是 Google 于 2016 年提出的概念,于 2017 年正式落地,于 2018 年迎来重大突破,全球顶级的浏览器厂商,Google、Microsoft、Apple 已经全数宣布支持 PWA 技术。

PWA 全称为 Progressive Web App,中文译为渐进式 Web APP,其目的是通过各种 Web 技术实现与原生 App 相近的用户体验。

纵观现有 Web 应用与原生应用的对比差距,如离线缓存、沉浸式体验等等,可以通过已经实现的 Web 技术去弥补这些差距,最终达到与原生应用相近的用户体验效果。

PWA特点

Google 定义的 PWA 具备以下特征:

可靠:即使在互联网连接不佳或没有互联网的情况下,也可以快速加载,因为如果网页未能在3秒内加载完毕,则超过一半的用户就会离开网站。当没有互联网连接时,PWA 会使用 Service Worker 来消除对Web服务器的依赖。

快速:流畅的动画和交互效果,应用程序拥有原生的体验。(没有笨拙的网页滚动。)

参与感:应该尽可能向原生设备的用户体验靠近。这意味着至少能够全屏运行(如果添加到手机桌面),并处理通知。

PWA的核心还是WebApp,通过渐进式增强,新的功能被现代浏览器实现。通过使用 service worker 和 app manifest,可以让你的WebApp具备可靠性和可安装性。如果浏览器不支持这些功能,你的网站的核心功能也不受影响。

Manifest(应用清单)

Web App Manifest是一个W3C规范,定义了一个基于JSON的清单,为开发人员提供一个放置与Web应用程序关联的元数据的集中地点。manifest 就是 PWA 概念的一环,它给你了控制你的应用如何出现在用户期待出现的地方(比如用户手机主屏幕),这直接影响到用户能启动什么,以及更重要的,用户如何启动它。

使用 web 应用程序清单,你的应用可以:

  • 能够真实存在于用户主屏幕上;
  • 在 Android 上能够全屏启动,不显示地址栏;
  • 控制屏幕方向已获得最佳效果;
  • 定义启动画面,为你的站点定义主题;
  • 追踪你的应用是从主屏幕还是 URL 启动的。

例如:Manifest实现添加至主屏幕

index.html

<head>
  <title>Minimal PWA</title>
  <meta name="viewport" content="width=device-width, user-scalable=no" />
  <link rel="manifest" href="manifest.json" />
  <link rel="stylesheet" type="text/css" href="main.css">
  <link rel="icon" href="/e.png" type="image/png" />
</head>

manifest.json

{
  "name": "Minimal 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"
  }
  ]
}

Service Worker

Service Worker 是 Chrome 团队提出和力推的一个 WEB API,用于给 web 应用提供高级的可持续的后台处理能力。

Service Workers 就像介于服务器和网页之间的拦截器,能够拦截进出的HTTP 请求,从而完全控制你的网站。

最主要的特点:

  • 在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。
  • 网站必须使用 HTTPS。除了使用本地开发环境调试时(如域名使用 localhost)
  • 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
  • 单独的作用域范围,单独的运行环境和执行线程
  • 不能操作页面 DOM。但可以通过事件机制来处理
  • 事件驱动型服务线程

Service workerPWA得以实现的核心技术。Service worker 是一个独立的worker线程,独立于当前网页进程,是一种特殊的web worker。主要功能在生命周期函数中实现。

Service Worker 的使用过程很简单,所处理的事情也相对单一,我们基本上需要做的就是利用这个 API 做好站点的缓存策略。在页面脚本中注册 Service Worker 文件所在的 URL。Worker 就可以开始激活了,激活后的 Service Worker 可以监听当前域下的功能性事件,比如资源请求(fetch)、推送通知(push)、后台同步(sync)。在这一系列的流程中,从 Service Worker 的注册到消失,经历了生命周期中不同的状态。

如何工作

我们之前 介绍 了这么多 Service Worker 相关的背景和现状,我们已经知道 Service Worker 是干嘛的了,但是我们还不是很清楚它具体是怎么运作起来的。

通常我们如果要使用 Service Worker 基本就是以下几个步骤:

  • 首先我们需要在页面的 JavaScript 主线程中使用 serviceWorkerContainer.register() 来注册 Service Worker ,在注册的过程中,浏览器会在后台启动尝试 Service Worker 的安装步骤。
  • 如果注册成功,Service Worker 在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊的 worker context,与主脚本的运行线程相独立,同时也没有访问 DOM 的能力。
  • 后台开始安装步骤, 通常在安装的过程中需要缓存一些静态资源。如果所有的资源成功缓存则安装成功,如果有任何静态资源缓存失败则安装失败,在这里失败的不要紧,会自动继续安装直到安装成功,如果安装不成功无法进行下一步 — 激活 Service Worker。
  • 开始激活 Service Worker,必须要在 Service Worker 安装成功之后,才能开始激活步骤,当 Service Worker 安装完成后,会接收到一个激活事件(activate event)。激活事件的处理函数中,主要操作是清理旧版本的 Service Worker 脚本中使用资源。
  • 激活成功后 Service Worker 可以控制页面了,但是只针对在成功注册了 Service Worker 后打开的页面。也就是说,页面打开时有没有 Service Worker,决定了接下来页面的生命周期内受不受 Service Worker 控制。所以,只有当页面刷新后,之前不受 Service Worker 控制的页面才有可能被控制起来。

生命周期

我们已经知道了,Service Worker 的工作原理是基于注册、安装、激活等步骤在浏览器 js 主线程中独立分担缓存任务的,那么我们如何在这些 API 自身一系列的操作中进行一些我们自己想让 worker 干的事情呢?

这里我们需要了解一下 Service Worker 的生命周期的概念,这有利于我们学会在各个生命周期的阶段进行有目的性的回调,让我们自定义的工作在 Service Worker 中正确有效的开展下去。MDN 给出了详细的 Service Worker 生命周期图:

Service Worker 生命周期

我们可以看到生命周期分为这么几个状态 安装中安装后激活中激活后废弃

  • 安装( installing ):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。

install 事件回调中有两个方法:

  • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。
  • self.skipWaiting()self 是当前 context 的 global 变量,执行该方法表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态。
  • 安装后( installed ):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。
  • 激活( activating ):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。

activate 回调中有两个方法:

  • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。
  • self.clients.claim():在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止。
  • 激活后( activated ):在这个状态会处理 activate 事件回调 (提供了更新缓存策略的机会)。并可以处理功能性的事件 fetch (请求)sync (后台同步)push (推送)
  • 废弃状态 ( redundant ):这个状态表示一个 Service Worker 的生命周期结束。

这里特别说明一下,进入废弃 (redundant) 状态的原因可能为这几种:

  • 安装 (install) 失败
  • 激活 (activating) 失败
  • 新版本的 Service Worker 替换了它并成为激活状态

支持的事件

MDN 也列出了 Service Worker 所有支持的事件:

Service Worker 支持的所有事件
  • install:Service Worker 安装成功后被触发的事件,在事件处理函数中可以添加需要缓存的文件(详见 使用 Service Worker )
  • activate:当 Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。(详见 更新 Service Worker )
  • message:Service Worker 运行于独立 context 中,无法直接访问当前页面主线程的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递,这样主线程就可以接受 Service Worker 的指令操作 DOM。

Service Worker 有几个重要的功能性的的事件,这些功能性的事件支撑和实现了 Service Worker 的特性。

  • fetch (请求):当浏览器在当前指定的 scope 下发起请求时,会触发 fetch 事件,并得到传有 response 参数的回调函数,回调中就可以做各种代理缓存的事情了。
  • push (推送):push 事件是为推送准备的。不过首先需要了解一下 Notification API 和 PUSH API。通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 Service Worker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。
  • sync (后台同步):sync 事件由 background sync (后台同步)发出。background sync 配合 Service Worker 推出的 API,用于为 Service Worker 提供一个可以实现注册和监听同步处理的方法。但它还不在 W3C Web API 标准中。在 Chrome 中这也只是一个实验性功能,需要访问 chrome://flags/#enable-experimental-web-platform-features ,开启该功能,然后重启生效。

代码示例:

1、service worker注册

if('serviceWorker' in navigator) {
  const sw = await navigator.serviceWorker.register(serviceWorker文件路径);
}
//使用serviceworker-webpack-plugin插件注册方式
import runtime from 'serviceworker-webpack-plugin/lib/runtime'
if('serviceWorker' in navigator) {
  const sw = await runtime.register();
}

serviceworker-webpack-plugin插件可以将所有打包后的目录文件注入到打包后的sw.js文件,通过global.serviceWorkerOptions.assets获取所有目录文件名,便于做静态资源的缓存。

2、service worker常用生命周期函数

2.1 install缓存所有你需要的静态资源

self.addEventListener('install', async () => {
  console.log('service worker installing');
  const cache = await caches.open(CACHE_NAME); //cacheStorage中缓存的名称
  const CACHE_URL = global.serviceWorkerOptions.assets.concat(['/']); //缓存的静态资源目录,不要忘记缓存'/'目录文件,在断网情况下,页面首先加载的是'/'目录资源。
  await cache.addAll(CACHE_URL); //此处只要有一个资源无法下载,静态资源的缓存都会失败
  await self.skipWaiting(); //跳过等待,保持运行最新的service worker
})

2.2 active删除旧的缓存

self.addEventListener('activate', async () => {
  console.log('service worker activate');
  const cacheKeys = await caches.keys();
  cacheKeys.map(async item => { //删除旧的缓存
    if (item !== CACHE_NAME) {
      await caches.delete(item);
    }
  })
  await self.clients.claim();//接管所有页面
}

2.3 fetch可以拦截所有的请求,并做数据缓存

self.addEventListener('fetch', async e => {
  console.log('service worker fetch');
  const req = e.request;
  const url = new URL(req.url);
  const api = new URL(apiHost); //自己使用的请求域名
  let isNetworkerFirst, isCacheFirst;
  if (isNetworkFirst) {
    e.respondWith(networkFirst(req)); //网络优先
  } else if (isCahceFirst) {
    e.respondWith(cacheFirst(req));//缓存优先
  }
})

const cacheFirst = async req => { //先从缓存中获取数据,如果没有匹配到,再发起网络请求
  const cache = await caches.open(CACHE_NAME);
  let cacheData = await cache.match(req);
  if (!cacheData) {
    cacheData = await fetch(req);
    if (!cacheData || cacheData.status !== 200) return cacheData;
    const cache = await caches.open(CACHE_NAME);
    cache.put(req, cacheData.clone());
  }
  return cacheData;
};

const networkFirst = async req => { //先发起网络请求,如果失败则再从缓存中匹配
  const cache = await caches.open(CACHE_NAME);
  let fetchResult;
  try {
    await Promise.race([requestPromise(req).then(res => {
      fetchResult = res;
      if (timer) clearTimeout(timer);
      if (isNetworkSlowly) isNetworkSlowly = false;
      hasShowNotification = false;
    }), timeout_promise()]);
    if (!fetchResult || fetchResult.status !== 200) return fetchResult;
    cache.put(req, fetchResult.clone());
    return fetchResult;
  } catch (e) {
    const cacheData = await cache.match(req);
    if (navigator.onLine && cacheData && e === 'request timeout' && isNetworkSlowly && !hasShowNotification) {
      showLocalNotification('网络不给力,当前访问的是缓存数据');
      isNetworkSlowly = false;
      hasShowNotification = true;
    }
    console.log(e, cacheData, 'error')
    return cacheData;
  }
}

//设置一定时间,在原本的fetch请求还没有响应的情况下,让service worker中的fetch报出’request timeout‘错误,从而转向向cache中匹配请求资源,实现在弱网情况下的网页正常浏览
const timeout_promise = () => {
  return new Promise((resolve, reject) => {
    timer = setTimeout(() => {
      if (!isNetworkSlowly) isNetworkSlowly = true;
      reject('request timeout');
    }, 9000);
  });
} 

const showLocalNotification = (title, body) => {
  const options = {};
  try {
    self.registration.showNotification(title, options);
  } catch (error) {
    console.warn(error);
  }
};

根据自己的需求选择网络优先还是缓存优先,例如isNetworkFirst = url.origin === self.origin && req.method === 'GET'e.respondWith()对拦截的请求,把缓存匹配的或者网络请求到的数据返回,作出最后的响应。self.registration.showNotification()向浏览器发送消息。

缓存使用到的cacheStorage

浏览器可以通过addEventListener('offline', () => {})addEventListener('online', () => {})来监听浏览器网络在线与离线状态。但无法判断弱网状态,弱网状态请求缓存数据的具体实现也可根据自己的项目逻辑来定(例如:请求超过10秒还未得到响应,判定为弱网状态)。

service worker 注册后,对静态资源的下载缓存会占用部分带宽,影响项目首页的加载速度,可以设置一个定时器,在一定的时间后才启动注册程序。

cacheStorage无法缓存POST请求的数据。

向浏览器发送消息,首先需要获取相应的权限。permission = await window.Notification.requestPermission()获取浏览器发送提醒消息权限,permission = 'granted'时,允许发送消息。

使用self.skipWaiting()可以保证执行最新的sw,但新旧sw的交替,往往都要经过service workerinstall->waiting->active,因此总会有页面前后期由不同的sw来处理的问题。

总结

PWA 已经推出一段时间了,但其受欢迎程度的增加主要还是因为功能强大的手机,以及Google、微软等许多大公司的支持。

随着时间的流逝,PWA 会越来越流行,功能会越来越强大,等得到苹果 iOS 的完全支持后, PWA 就会成为构建移动应用程序的主流方式。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注