【原生应用、Web App 与 PWA 的对比】
原生应用(Native app):
- 好处:
- 更高的安全性。
- 更强的计算能力。
- 更好的 UX(用户体验)。
- 更少的电池消耗。- 不足:
- 开发成本高(iOS 和 安卓)、速度慢。
- 版本更新需要将新版本上传到不同的应用商店;
- 必须去下载才能使用 app。
网页应用(Web App):- 好处:
- 开发成本低、速度快。
- 版本更新时上传最新的资源到服务器即可。
- 应用的加载速度更快。- 不足:
- 缺少离线使用能力。在离线或者在弱网环境下基本上是无法使用的。
- 缺少了消息推送的能力。
- 桌面入口不够便捷,想要进入一个页面每次都需要通过浏览器来打开。
渐进式网页应用(PWA):针对以上 Web App 的缺陷,PWA 进行了查缺补漏。- 可以添加至主屏幕。
- 实现启动动画以及隐藏地址栏。
- 实现离线缓存功能。即使用户手机没有网络,依然可以使用一些离线功能。
- 实现消息推送。
- 实现秒开优化。
- 兼容性良好:现在 IE 退出了,iOS 也兼容 PWA 了。
- 其余的优点继承 Web App。
这些特性将使得 PWA 应用非常接近原生 App,在加载速度方便甚至超越了原生 App。
PWA(Progressive Web App)是渐进式 web app。它实现了网页应用的安装、离线缓存 和 消息推送等功能。
PWA 的功能:
Manifest
将 web 应用 添加至主屏幕。Service Worker
实现了 离线缓存 和 消息推送。如果需要赋予该 web app 添加至主屏幕(能够作为独立应用独立安装)的能力,则需要 2 步:
manifest
。在 index.html 中注入 manifest:
<head>
<link rel="manifest" href="/manifest.json" />
head>
实现 manifest.json 文件:
{
// 复制后需要把注释删除掉
"name": "My 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"
}
]
}
Manifest参考文档
Service Worker 是一个在页面和网络之间的拦截器。是基于 HTML5 API 实现的。也是 PWA 的核心。
Service Worker 能够 拦截请求 并 缓存资源,从而完全控制你的网站。
Service Worker 的结构如下图:
Service Worker 的实现原理:
【拓展】Web Worker
Web Worker 让 JavaScript 能够运行在页面主线程之外,不过由于 Web Worker 中是没有当前页面的 DOM 环境的,所以在 Web Worker 中只能执行一些和 DOM 无关的 JavaScript 脚本,并通过 postMessage 方法将执行的结果返回给主线程。所以说在 Chrome 中, Web Worker 其实就是在渲染进程中开启的一个新线程,它的生命周期是和页面关联的。
Service Worker 的特点:
postMessage
方法。install
就永远存在,除非被手动 unregister
。即使Chrome(浏览器)关闭也会在后台运行。利用这个特性可以实现离线消息推送功能。【拓展】目前 Service Worker 的各大浏览器的兼容情况:请戳这里。
service worker 技术实现离线缓存,可以将一些不经常改变的静态文件放到缓存中,提升用户体验。
Service Worker 缓存的实现步骤:
index.html 中:
DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello Caching World!title>
head>
<body>
<img src="/images/hello.png" />
<script async src="/js/script.js">script>
<script>
// 注册 service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) {
// 注册成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(function (err) {
// 注册失败 :(
console.log('ServiceWorker registration failed: ', err);
});
}
script>
body>
html>
上述代码中,Service Worker 的注册路径决定了其 scope 默认作用页面的范围:
service-worker.js 中:
var cacheName = 'helloWorld'; // 缓存的名称
// install 事件,它发生在浏览器安装并注册 Service Worker 时
self.addEventListener('install', event => {
/* event.waitUtil 用于在安装成功之前执行一些预装逻辑
但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
安装成功后 ServiceWorker 状态会从 installing 变为 installed */
event.waitUntil(
caches.open(cacheName)
.then(cache => cache.addAll([ // 如果所有的文件都成功缓存了,便会安装完成。如果任何文件下载失败了,那么安装过程也会随之失败。
'/js/script.js',
'/images/hello.png'
]))
);
});
/**
为 fetch 事件添加一个事件监听器。接下来,使用 caches.match() 函数来检查传入的请求 URL 是否匹配当前缓存中存在的任何内容。如果存在的话,返回缓存的资源。
如果资源并不存在于缓存当中,通过网络来获取资源,并将获取到的资源添加到缓存中。
*/
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request)
.then(function (response) {
if (response) {
return response;
}
var requestToCache = event.request.clone(); //
return fetch(requestToCache).then(
function (response) {
if (!response || response.status !== 200) {
return response;
}
var responseToCache = response.clone();
caches.open(cacheName)
.then(function (cache) {
cache.put(requestToCache, responseToCache);
});
return response;
})
);
});
上述代码中,为什么要用 request.clone() 和 response.clone() 呢?
因为 request 和 response 是一个流,它只能消耗一次。我们已经通过缓存消耗了一次,然后发起 HTTP 请求还要再消耗一次,所以我们需要在此时克隆请求。
service worker 一旦被 install
就永远存在,除非被手动 unregister
。即使Chrome(浏览器)关闭也会在后台运行。利用这个特性可以实现离线消息推送功能。
Service Worker 实现消息推送的步骤:
【拓展】不同浏览器需要用不同的推送消息服务器。以 Chrome 上使用 Google Cloud Messaging 作为推送服务为例,第一步是注册 applicationServerKey(通过 GCM 注册获取),并在页面上进行订阅或发起订阅。每一个会话会有一个独立的端点(endpoint),订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 Service Worker (通过 GCM 与浏览器客户端沟通)。
具体实现如下:
①、index.html 文件中:实现提示用户并获得他们的订阅详细信息,并将这些详细信息保存在服务器上。
DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Progressive Timestitle>
<link rel="manifest" href="/manifest.json">
head>
<body>
<script>
var endpoint;
var key;
var authSecret;
var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
// 方法很复杂,但是可以不用具体看,知识用来转化vapidPublicKey用
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(function (registration) {
return registration.pushManager.getSubscription()
.then(function (subscription) {
if (subscription) {
return;
}
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
.then(function (subscription) {
var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
authSecret = rawAuthSecret ?
btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
endpoint = subscription.endpoint;
return fetch('./register', {
method: 'post',
headers: new Headers({
'content-type': 'application/json'
}),
body: JSON.stringify({
endpoint: subscription.endpoint,
key: key,
authSecret: authSecret,
}),
});
});
});
}).catch(function (err) {
// 注册失败 :(
console.log('ServiceWorker registration failed: ', err);
});
}
script>
body>
html>
②、app.js 文件中:实现服务器发送消息给 service worker。
const webpush = require('web-push');
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(
'mailto:[email protected]',
'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {
var endpoint = req.body.endpoint;
saveRegistrationDetails(endpoint, key, authSecret);
const pushSubscription = {
endpoint: req.body.endpoint,
keys: {
auth: req.body.authSecret,
p256dh: req.body.key
}
};
var body = 'Thank you for registering';
var iconUrl = 'https://example.com/images/homescreen.png';
// 发送 Web 推送消息
webpush.sendNotification(pushSubscription,
JSON.stringify({
msg: body,
url: 'http://localhost:3111/',
icon: iconUrl
}))
.then(result => res.sendStatus(200))
.catch(err => {
console.log(err);
});
});
app.listen(3000, function () {
console.log('Web push app listening on port 3111!')
});
// service worker监听push事件,将通知详情推送给用户
// service-worker.js
self.addEventListener('push', function (event) {
// 检查服务端是否发来了任何有效载荷数据
var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
var title = 'Progressive Times';
event.waitUntil(
// 使用提供的信息来显示 Web 推送通知
self.registration.showNotification(title, {
body: payload.msg,
url: payload.url,
icon: payload.icon
})
);
});
结合 webpack 实现 PWA 的步骤:
workbox-webpack-plugin
插件。<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js') // 这里无需传入第二个参数 scope 指定 serviceWorker 的作用域,会在 webpack 里通过 urlPattern 来实现。
.then((registration) => {
// console.log('ServiceWorker registered: ', registration);
})
.catch((err) => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
script>
【注意】尽量把这段 js 放在其他资源引入之前。
workbox-webpack-plugin
插件是 workbox 的构建工具的实现之一。
workbox 是 GoogleChrome 团队推出的一套 Web App 静态资源本地存储(静态资源离线缓存)的解决方案,该解决方案包含一些 Js 库和构建工具。
workbox 的 5 种缓存模式:
workbox-webpack-plugin
插件。const { GenerateSW } = require('workbox-webpack-plugin');
const id = new Date().getTime();
// 在 plugins 数组中添加以下配置
new GenerateSW({
clientsClaim: true,
skipWaiting: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
cacheId: `${id}-gsw`,
exclude: ['service-worker.js'],
// mode: 'development',
runtimeCaching: [
// 根据实际情况配置符合条件资源的缓存
{
urlPattern: /^https:\/\/test.ceshi.com\/static/,
handler: 'CacheFirst', // 采用“缓存优先”的缓存模式
options: {
cacheName: `${id}-test-cdn`,
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
}),
这是一个实现了 service worker 离线缓存功能的网站,打开调试工具,调试 service worker。
【推荐阅读】
渐进式 Web 应用(PWA)——MDN
PWA 技术解析及爱奇艺 PC 端的实践
【参考文章】
PWA Builder Blog
讲讲 PWA
PWA 构建器博客
第一本 PWA 中文书
PWA 英文书
网站渐进式增强体验(PWA)改造:Service Worker 应用详解
Basic Service Worker Sample
【翻译】Service Worker 入门
Web App Manifest
Service Workers: an Introduction
The Offline Cookbook
微信小程序和PWA对比分析
使用workbox-webpack-plugin实现PWA
npm 之 workbox-webpack-plugin
workbox-webpack-plugin创建pwa
Webpack 插件扫盲系列(四) WorkboxWebpackPlugin
深入浅出 PWA
前端缓存-workbox缓存策略