什么是PWA?
PWA全称为Progressive Web App,即渐变式web应用。
PWA 指的是使用指定技术和标准模式来开发的 Web 应用,这同时赋予它们 Web 应用和原生应用的特性。——MDN
它主要有以下特性
简单的来讲,举个例子我们访问使用了pwa的事例网站squoosh.app/ ,正常访问使用和普通网站没有什么区别。额外我们发现右上角多了个可安装提示。 我们发现我们桌面多了个应用图标,我们可以快捷打开,并且可以断网的情况下打开使用部分无需实时网络请求的功能。当然这只是其中PWA应用的特点之一,下面我们再展开来介绍。
PWA并不是单指某一项技术,而是由多个指定技术结合使用,让网站可以提供更实用和友好的用户体验的一种理念。
PWA的技术点汇总
哪些网站使用了PWA包含的技术
- 网易云课堂study.163.com
- 语雀www.yuque.com
- 斗鱼www.douyu.com
- 移动端B站m.bilibili.com
- 爱奇艺www.iqiyi.com
- 金山文档www.kdocs.cn
- 移动端微博m.weibo.cn
- vscode网页版vscode.dev
- ......
接下来让我们对Service worker
、Cache
、Manifest
、Natifications API
、Push API
五个技术点逐一了解一番。
Service workers
Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。
这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的资源。它还提供入口以推送通知和访问后台同步 API。
——MDN
它是一个独立的 worker 线程,独立于当前网页进程,是一种特殊的 web worker。主要作用就是可以拦截网站请求,开发者可自定义做一些处理。
特点
- 只能由 HTTPS 承载
- 无权访问 DOM 结构
- 完全异步,不能使用同步API(如XHR和localStorage)
Service workers的使用
接下来我们结合示例图和代码看看Service workers的使用,它概括来讲就是,Register(注册)——Install(安装)——Waiting(等待激活使用)——Activated(已激活可使用,拦截请求),由于一个网站只能有一个Service workers被激活使用,如果有新版本的Service workers,则重新Install(安装)——Waiting(等待激活使用),激活后旧的Service workers将会被代替。
接下来我们使用代码来演示一些基础关键的代码。首先我们一个网站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。
主动更新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的版本信息以及活动信息。
Cache
Cache 接口为缓存的 Request / Response 对象提供存储机制。 尽管它被定义在 service worker 的标准中, 但是它不必一定要配合 service worker 使用。
——MDN
Cache也是浏览器缓存机制的一种,它和Storage明显的不同点,它是对http的GET请求资源进行缓存,并且暂未发现有大小限制。
特性
- 只能GET请求
- 异步调用
- 大小几乎无限制
例如我们可以在Application
面板中查看。
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搭配使用,发挥更好的作用。
我们结合代码来使用下。
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;
}
}
上面的例子中缓存策略采用网络优先方案。其实针对不同资源的重要程度以及要求,可以采用不同的方案搭配使用。
上述说的其实都已有实用的库可以直接使用,例如
- 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其实是一个三方交互的过程。
如上图中的Push Service代表着不同的浏览器厂商,也就是我们服务端是把消息发给浏览器厂商服务,由它们来推送到用户浏览器端。
具体流程
- 在Web Push协议中,客户端持有公钥,服务端持有私钥。
- 客户端发起订阅,将公钥发给Push Service,而Push Service会将该公钥与相应的endpoint关联。
- 当服务端要推送消息时,会使用私钥对发送的数据进行数字签名。
- Push Service收到请求后,根据endpoint取到公钥,对数字签名解密验证。
- 向客户端推送消息。
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/ 。可以看到右上角出现安装应用的按钮
安装之后,发现它已经出现在我们的桌面上
打开它,发现有点原生应用的feel,要是搭配离线方案和主动通知方案,可以让这个PWA应用更加接近原生应用的特性。
介绍完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" />