PWA,不管用不用简单了解下

1,188 阅读9分钟

什么是PWA?

PWA全称为Progressive Web App,即渐变式web应用。

PWA 指的是使用指定技术和标准模式来开发的 Web 应用,这同时赋予它们 Web 应用和原生应用的特性。——MDN

它主要有以下特性

image.png

简单的来讲,举个例子我们访问使用了pwa的事例网站squoosh.app/ ,正常访问使用和普通网站没有什么区别。额外我们发现右上角多了个可安装提示。 image.png 我们发现我们桌面多了个应用图标,我们可以快捷打开,并且可以断网的情况下打开使用部分无需实时网络请求的功能。当然这只是其中PWA应用的特点之一,下面我们再展开来介绍。 image.png

PWA并不是单指某一项技术,而是由多个指定技术结合使用,让网站可以提供更实用和友好的用户体验的一种理念。

PWA的技术点汇总

image.png

哪些网站使用了PWA包含的技术

接下来让我们对Service workerCacheManifestNatifications APIPush API五个技术点逐一了解一番。

Service workers

Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。

这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的资源。它还提供入口以推送通知和访问后台同步 API。

——MDN

它是一个独立的 worker 线程,独立于当前网页进程,是一种特殊的 web worker。主要作用就是可以拦截网站请求,开发者可自定义做一些处理。

image.png

特点

  • 只能由 HTTPS 承载
  • 无权访问 DOM 结构
  • 完全异步,不能使用同步API(如XHR和localStorage)

Service workers的使用

接下来我们结合示例图和代码看看Service workers的使用,它概括来讲就是,Register(注册)——Install(安装)——Waiting(等待激活使用)——Activated(已激活可使用,拦截请求),由于一个网站只能有一个Service workers被激活使用,如果有新版本的Service workers,则重新Install(安装)——Waiting(等待激活使用),激活后旧的Service workers将会被代替。

image.png

接下来我们使用代码来演示一些基础关键的代码。首先我们一个网站html被加载,我们即可着手注册一个Service workers,即下面的sw.js

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PWA</title>
</head>
<body>
  <script>
    window.addEventListener("load", () => {
      if ("serviceWorker" in navigator) {
        navigator.serviceWorker
          .register("./sw.js")
          .then((registration) => {
            console.log("注册成功");
          })
          .catch((err) => {
            console.log("注册失败");
          });
      }
    });
  </script>
</body>
</html>

在sw.js的代码里,我们可以监听install、activate、fetch事件

sw.js

// 安装
self.addEventListener("install", (event) => {
  /** 这里可以有两个简单的方案选择 */

  // 1、跳过等待状态,直接进入到active状态
  self.skipwaitting();

  // 2、或者安装完成前做一些处理
  /*
  event.waitUntil(
    new Promise((resolve) => {
      console.log('在安装后,但在激活前做一些处理')
      resolve();
    })
  );
  */
});

// 激活
self.addEventListener("activate", (event) => {
  // 在激活时,也可以做一些处理,比如清除上一版本的缓存
  // ...

  // activate状态后,立即获取控制权
  // 一般第一次注册时,sw还未被使用。下一次加载才会使用。
  // self.clients.claim()可以让当次的sw立即生效,接管代理
  self.clients.claim();
});

const requestApi = async (req) => {
  console.log('拦截请求')
  try {
    const fresh = await fetch(req)
    return fresh
  } catch (_) {
    /* */
  }
}

// 激活可使用,监听fetch事件,可以拦截请求
self.addEventListener("fetch", (event) => {
  const req = event.request;
  // 处理请求
  event.respondWith(requestApi(req));
});

Service workers的更新

如果你的 service worker 已经被安装,但是刷新页面时有一个新版本的可用,新版的 service worker 会在后台安装,但是还没激活。当不再有任何已加载的页面在使用旧版的 service worker 的时候,新版本才会激活。

——MDN

一般情况下,用户访问了PWA的网站,会将sw.js代码存起来,一直接管代理网站请求。但是当我们更改了sw.js代码并上线的时候,这时就会存在新旧版本的Service worker。

image.png

主动更新Service workers

如果我们想让用户进入页面立刻能够使用到最新版本的Service worker,而不是等到用户下次进入或手动刷新页面才能使用。我们可以让新的Service worker进入到activate状态,通过controllerchange事件监听触发页面刷新。当然这是一个粗暴的方案,如果用户正在操作页面,我们也可以弹窗通知用户是否更新页面。

// sw.js
self.addEventListener("install", (event) => {
  // 跳过等待状态,进入到activate状态
  self.skipwaitting();
});

// index.html
navigator.serviceWorker.addEventListener("controllerchange", () => {
  window.location.reload();
});

Application面板中,我们可以详细看到Service Workers的版本信息以及活动信息。

image.png

Cache

Cache 接口为缓存的 Request / Response 对象提供存储机制。 尽管它被定义在 service worker 的标准中, 但是它不必一定要配合 service worker 使用。

——MDN

Cache也是浏览器缓存机制的一种,它和Storage明显的不同点,它是对http的GET请求资源进行缓存,并且暂未发现有大小限制。

特性

  • 只能GET请求
  • 异步调用
  • 大小几乎无限制

例如我们可以在Application面板中查看。

image.png

Cache的使用

// 创建cache_v1
caches.open("cache_v1");

// 创建cache_v2
caches.open("cache_v2");

// 打印所有cacheStorage
caches.keys().then((res) => {
  console.log(res); // ['cache_v1', 'cache_v2']
});

// 删除cache_v2
caches.delete("cache_v2");

// 打开cache_v1的 Cache 对象
caches.open("cache_v1").then((cache) => {
  // 添加资源缓存
  cache.addAll(["/", "/main.js"]).then(() => {});

  // 更新资源缓存
  fetch("/api/list").then((response) => {
    cache.put("/api/list", response);
  });

  // 从缓存中获取响应数据
  cache
    .match(new Request("/api/list"))
    .then((res) => res.json())
    .then((data) => {
      // 拿到数据
      console.log(data);
    });
});

Cache和Service worker搭配

一般Cache和Service worker搭配使用,发挥更好的作用。

image.png

我们结合代码来使用下。

install阶段缓存最新资源

const CACHE_NAME = "cache_v1";
self.addEventListener("install", async () => {
  // 开启一个Cache
  const cache = await caches.open(CACHE_NAME);
  // 添加缓存资源
  await cache.addAll(["./index.html", "./index.css"]);
  await self.skipwaitting();
});

activate阶段清除旧版本的缓存资源

self.addEventListener("activate", async () => {
  const keys = await caches.keys();
  // 清除上一版本的缓存
  keys.forEach((key) => {
    if (key !== CACHE_NAME) {
      caches.delete(key);
    }
  });

  // 获取控制权
  await self.clients.claim();
});

代理请求做网络优先方案处理

self.addEventListener("fetch", async (event) => {
  event.respondWith(networkFirst(event.request));
});

async function networkFirst(req) {
  const cache = await caches.open(CACHE_NAME);
  try {
    // 网络获取最新的资源
    const fresh = await fetch(req);
    // 更新缓存
    cache.put(req, fresh.clone());
  } catch (_) {
    // 如果网络获取资源失败,则从缓存中拿资源
    const cached = await cache.match(req);
    if (!cached) {
      throw new Error("没有缓存");
    }
    return cached;
  }
}

上面的例子中缓存策略采用网络优先方案。其实针对不同资源的重要程度以及要求,可以采用不同的方案搭配使用。

image.png

上述说的其实都已有实用的库可以直接使用,例如

  • Workbox —— js库,google团队推出
  • lavas —— 基于 Vue 的 PWA 解决方案
  • next-pwa —— Next.js中使用

Notifications

Notifications API 允许网页或应用程序在系统级别发送在页面外部显示的通知;这样即使应用程序空闲或在后台,Web 应用程序也会向用户发送信息

——MDN

该API也是很常用,访问很多网站时左上角都会有 询问是否授权通知,想必大家都会习惯性点X。 它的使用也是很简单

// 请求授权
Notification.requestPermission((status) => {
  // 是否同意
  if (status === "granted") {
    // 发送消息
    new Notification("标题", {
      tag: 1, // 通知id,可避免重复通知
      dir: "auto", // 文本显示方向 auto|ltr|rtl
      body: "你收到了一条新的通知",
      icon: "https://www.example.com/icon.png",
      lang: "zh",
    });
  }
});

同时,创建通知后,我们可以监听其事件

const notification = new Notification("通知");
notification.onshow = () => {
  console.log("通知已显示");
};
notification.onclick = () => {
  console.log("通知被点击");
  // 比如点击后跳转新tab页
  window.open("/newTab");
};
notification.onclose = () => {
  console.log("通知被关闭");
};
notification.onerror = () => {
  console.log("通知发生错误");
};

Push API

Push API 给与了 Web 应用程序接收从服务器发出的推送消息的能力,无论 Web 应用程序是否在用户设备前台,甚至刚加载完成。这样,开发人员就可以向用户投放异步通知和更新,从而让用户能更及时地获取新内容。

——MDN

Push API可以让服务端主动发起通知,并且浏览器可以及时接受。不同于使用长连接、WebSocket或是其他技术手段来向客户端推送消息,Web Push其实是一个三方交互的过程。

image.png

如上图中的Push Service代表着不同的浏览器厂商,也就是我们服务端是把消息发给浏览器厂商服务,由它们来推送到用户浏览器端。

具体流程

  1. 在Web Push协议中,客户端持有公钥,服务端持有私钥。
  2. 客户端发起订阅,将公钥发给Push Service,而Push Service会将该公钥与相应的endpoint关联。
  3. 当服务端要推送消息时,会使用私钥对发送的数据进行数字签名。
  4. Push Service收到请求后,根据endpoint取到公钥,对数字签名解密验证。
  5. 向客户端推送消息。

endpoint存在于每个用户浏览器端,作为个人用户唯一标识。

下面结合代码说明,我们使用web-push包生成公钥和私钥。

const webpush = require('web-push');

// VAPID keys should be generated only once.
// 获取生成公钥和私钥
const { publicKey, privateKey } = webpush.generateVAPIDKeys();

接下来前端页面开始订阅

navigator.serviceWorker.ready.then(async (registration) => {
  let subscription;
  // 使用PushManager获取用户对推送服务的订阅
  try {
    subscription = await registration.pushManager.getSubscription();
  } catch (_) {
    // ...
  }

  // 如果未订阅,则开始订阅
  if (subscription) {
    const convertedVapidKey = urlBase64ToUint8Array(publicKey);
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: convertedVapidKey,
    });

    // 将订阅信息发给后端
    await fetch('/api/auth', {
      data: JSON.stringify(subscription)
    })
  }
});

写个简单的node服务

const express = require("express");
const webpush = require("web-push");

// webpush配置
webpush.setVapidDetails(
  "https://example.com", // 你的网站
  publicKey,
  privateKey
);

const app = express();
// ...
app.post("/api/auth", (req, res) => {
  const { subscription } = req.body;
  // 订阅成功,每隔段时间主动推送消息给该用户
  setInterval(() => {
    webpush.sendNotification(
      subscription,
      JSON.stringify({
        text: "服务端推送消息给你啦",
      })
    );
  }, 600000);
  res.send("订阅成功");
});

回到前端,我们在Service Workers添加监听事件

// sw.js
self.addEventListener("push", (event) => {
  const data = event.data.json();
  self.registration.showNotification("收到来自服务端的消息:", {
    body: data.text,
  });
});

这种方式明显的优势是,其实是服务端发送消息存放到浏览器厂商,用户处于离线状态也没有关系,只需要下次有网时,浏览器厂商即可更新消息给到用户。当然这也可能是缺点,比如国内就收不到chrome厂商的消息,测试了Microsoft Edge倒是有效。

Manifest.json

Manifest.json作为Web应用程序清单,可以让我们的Web网站安装到设备的主屏幕上,并且可以自定义配置主题,方便用户快捷使用。 先看下配置了Manifest.json的网站有什么特点,我们可以访问squoosh.app/ 。可以看到右上角出现安装应用的按钮

image.png 安装之后,发现它已经出现在我们的桌面上

image.png

打开它,发现有点原生应用的feel,要是搭配离线方案和主动通知方案,可以让这个PWA应用更加接近原生应用的特性。 image.png

介绍完Manifest.json的特点,接下来看下如何配置,我们只需要配置一个Manifest.json文件

{
  "name": "个人笔记",
  "short_name": "笔记",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#fff",
  "description": "一个简单的PWA应用",
  "icons": [{
    "src": "images/touch/homescreen48.png",
    "sizes": "48x48",
    "type": "image/png"
  }, {
    "src": "images/touch/homescreen72.png",
    "sizes": "72x72",
    "type": "image/png"
  }],
  "related_applications": [{
    "platform": "web"
  }, {
    "platform": "play",
    "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb"
  }]
}

具体配置我们可以前往Web App Manifest查看。

紧接着,我们在html文件引入即可

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

虽然简单配置,但是目前兼容性很差。为了兼容更多浏览器,我们可以参考如下进行配置

<meta name="application-name" content="PWA App" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PWA App" />
<meta name="description" content="Best PWA App in the world" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-config" content="/icons/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#2B5797" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="theme-color" content="#000000" />

<link rel="apple-touch-icon" href="/icons/touch-icon-iphone.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/touch-icon-ipad.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon-iphone-retina.png" />
<link rel="apple-touch-icon" sizes="167x167" href="/icons/touch-icon-ipad-retina.png" />

<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg" color="#5bbad5" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" />

回顾

image.png