参考资料
PWA的特点
- 可安装
- 资源在线加载且离线可用
- 推送通知
- 符合 PWA 安装条件的网站,浏览器会触发事件来提示用户安装
Web 应用清单
Web 应用清单是您创建的一种文件,用于告知浏览器您希望自己的 Web 内容在操作系统中如何显示为应用。该清单可以包含基本信息,例如应用名称、图标和主题颜色;高级偏好设置,例如所需的屏幕方向和应用快捷方式;以及目录元数据,例如屏幕截图。
每个 PWA 都应为每个应用包含一个清单,该清单通常托管在根文件夹中,并在可以从所有可安装 PWA 的 HTML 页面上提供相应链接。其官方扩展名为 .webmanifest
网站变成pwa的条件
- 项目根目录下添加app.webmanifest
{
"name": "My First Application"
}
<html lang="en">
<title>This is my first PWA</title>
<link rel="manifest" href="/app.webmanifest">
- 安装的网站环境
https || localhost || file 协议
- service worker(官网说是必需,但实际并不是必须的)
google浏览器发现这不是必要的捏
安装的方式与相关代码
不同操作系统安装pwa的方式
android安装方式
当网站加载了app.webmanifest应用清单,那么浏览器会自动弹窗提示用户是否安装该网页。
设备和浏览器都会影响PWA的安装,PWA 可能会以 WebAPK、快捷方式或 QuickApp 的形式进行安装。
浏览器安装的提示图可能有两种,左跟右
WebAPK
适用于安装了 Google 移动服务 (GMS) 的设备的 Google Chrome 和三星互联网浏览器,但仅限于三星制造的设备。
特点:
- 在应用启动器和主屏幕中具有一个图标。
- 显示在“设置”>“应用”下。
- 可拥有多项功能,例如标记、应用快捷方式和在操作系统中捕获链接。
- 可以更新图标和应用的元数据。
- 无法安装两次。
快捷键
网站快捷方式
特点:
- 在主屏幕上显示带有浏览器标记的图标(请参阅以下示例)。
- 启动器中或设置、应用中没有图标。
- 无法使用任何需要安装的功能。
- 无法更新其图标和应用元数据。
- 可安装多次,即使使用同一个浏览器也可以;发生这种情况时,所有实例都将指向同一实例,并使用相同的存储空间。
QuickApp
当您的 PWA 作为 QuickApp 安装时,用户获得的体验与使用快捷方式时的体验类似,但带有一个带有 QuickApps 图标的图标(闪电图片)
商店上架
ios安装
在 iOS 和 iPadOS 上没有浏览器提示安装 PWA,必须通过 Safari 中才提供的菜单,将这些应用手动添加到主屏幕。只能通过Safari安装PWA。
特点:
- 显示在主屏幕、特别关注的搜索、Siri 建议和应用库搜索中。
- 不会显示在应用程序库的类别文件夹中。
- 缺乏对标志和应用快捷方式等功能的支持。
相关疑问解答
如何开发环境下调试
如果想要测试安装与调试,则将访问地址改成localhost:端口号即可,同时点击此处即可。如图
如果想要查看清单的配置属性是否正确,如图
如何通过点击事件唤起安装弹窗?
整体思路是:google浏览器会在用打开网站的时候自动触发beforeinstallprompt事件,此时进行拦截beforeinstallprompt事件的默认发生并把对象存储起来,在需要唤起安装弹窗的时候调用对应的方法。
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
// Prevents the default mini-infobar or install dialog from appearing on mobile
e.preventDefault();
// Save the event because you'll need to trigger it later.
deferredPrompt = e;
});
// 在需要手动触发的时机触发此对象方法
deferredPrompt.prompt()
如何监听是否安装或者取消
// Gather the data from your custom install UI event listener
installButton.addEventListener('click', async () => {
// deferredPrompt is a global variable we've been using in the sample to capture the `beforeinstallevent`
deferredPrompt.prompt();
// Find out whether the user confirmed the installation or not
const { outcome } = await deferredPrompt.userChoice;
// Act on the user's choice
if (outcome === 'accepted') {
console.log('User accepted the install prompt.');
} else if (outcome === 'dismissed') {
console.log('User dismissed the install prompt');
}
});
如何判断是否在pwa环境中
let displayMode = 'browser';
const mqStandAlone = '(display-mode: standalone)';
if (navigator.standalone || window.matchMedia(mqStandAlone).matches) {
displayMode = 'standalone';
}
如何区分安装(广告)来源?
{
"name": "Pooke",
"short_name": "Pooke",
"icons": [
{
"src": "public/imgs/512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"theme_color": "#232227",
"background_color": "#232227",
"start_url": "./?op=2",
"display": "standalone"
}
将start_url添加上对应的参数即可。
有一个问题,如果需要一个网站区分多个渠道,那么只能动态添加引入不同的app.webmanifest文件,理由是我们是通过start_url这个值来区分的,而这个值写死在app.webmanifest里面。
仔细看会发现每个webmanifest文件唯一的区分也就是start_url不同,能不能通过前端自己生成blob来动态修改呢?不行,pwa的安装不认识blob协议,只能通过http协议。故此,只能由服务端同学支持达到最好的动态引入效果
export const importPwaConfig = () => {
let href = '/app.webmanifest'
// 如果存在op(pixId),则通过js动态生成json文件。
if (query['op']) {
// 无效,pwa不认识blob协议
const stringData = JSON.stringify({
"name": "Pooke",
"short_name": "Pooke",
"icons": [
{
"src": "public/imgs/512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"theme_color": "#232227",
"background_color": "#232227",
"start_url": `./?op=${query['op']}`,
"display": "standalone"
})
const blob = new Blob([stringData], {
type: 'application/octet-stream'
})
// 换成服务的协议,注意这里有同源策略的限制,对于部署不同的服务器可走nginx处理。
href = '/askWebmanifest?op=12'
}
let link = document.createElement('link')
link.setAttribute('rel', 'manifest')
link.setAttribute('href', href)
document.getElementsByTagName('head')[0].appendChild(link)
window.addEventListener('beforeinstallprompt', (e: any) => {
// Prevents the default mini-infobar or install dialog from appearing on mobile
e.preventDefault();
// Save the event because you'll need to trigger it later.
window.deferredPrompt = e;
});
}
node express关键代码
// 动态生成webmanifest文件,根据参数进行动态变化。
app.get('/askWebmanifest',(req,res)=>{
const stringData = JSON.stringify({
name: "Pooke2",
short_name: "Pooke",
icons: [
{
src:"https://upload.wikimedia.org/wikipedia/commons/5/5c/Bml_x_512_y_512_p_31_iterated_32000.png",
type: "image/png",
sizes: "512x512"
}
],
theme_color: "#232227",
background_color: "#232227",
start_url: `./?op=${req.query['op']}`,
display: "standalone"
})
res.setHeader('Content-Type', 'application/manifest+json');
res.send(stringData);
})
如何卸载pwa
打开pwa应用
浏览器的兼容性情况
移动端
测试机器: 苹果15 系统17.3.1
- safari 只能以快捷方式的形式添加
红米13 andorid系统 11 RKQ1.211001.001
- google 浏览器可以
- edge 不行
- 火狐 不行
荣耀v20 HarmonyOs: 4.0.0
- google 浏览器可以
- edge 不行
- 火狐 只能以快捷方式的形式添加
清单的属性说明
{
"name": "My First Application",
"short_name": "Application",
"icons": [
{
"src": "icons/512.webp",
"type": "image/webp",
"sizes": "512x512"
}
],
"start_url": "/",
"display": "standalone"
}
Service Worker
相关资料
service worker 的功能类似于代理服务器,允许你去修改请求和响应,将其替换成来自其自身缓存的项目。只有将网站部署在https下或者localhost访问才能启动Service Worker
工作流程
当给网站写入Service worker相对应的代码后(注册),第一次加载网站时,会触发Service worker的两个钩子install与activate,可以在install这个钩子里面缓存本地文件。
更新serive workder
// 要求立即激活新的sw代码,如果不这么做的话,网页会一直使用旧的sw代码直到浏览器关闭重新打开,这是更新sw的关键。
skipWaiting()
注册
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
})
if (registration.installing) {
console.log("正在安装 Service worker")
} else if (registration.waiting) {
console.log("已安装 Service worker installed")
} else if (registration.active) {
console.log("激活 Service worker")
}
} catch (error) {
console.error(`注册失败:${error}`)
}
}
}
registerServiceWorker();
install(缓存对应文件)
const addResourcesToCache = async (resources) => {
const cache = await caches.open("v1")
await cache.addAll(resources)
console.log('缓存数据成功!')
}
// install 事件会在注册成功完成之后触发. install 事件通常会这样用,将离线运行 app 产生的资源放置在浏览器离线缓存的空间。
self.addEventListener("install", event => {
console.log("Service worker installed")
event.waitUntil(
// 很傻逼,如果里面的路径有一个是错误的,那么里面的所有缓存都会失效。
addResourcesToCache([
// "/index.html",
"/icons/512.webp",
"/icons/512.jpg",
]),
)
})
当第二次打开网站的时候,会触发fetch事件时,拦截请求,将让sw去找是否有缓存数据,没有则发网络请求获取
const cacheFirst = async (request) => {
const responseFromCache = await caches.match(request)
if (responseFromCache) {
console.log('来自本地缓存的数据', request.url)
return responseFromCache
}
console.log('请求网络的数据', request.url)
return fetch(request)
}
// 每次获取 service worker 控制的资源时,都会触发 fetch 事件,这些资源包括了指定的作用域内的文档,和这些文档内引用的其他任何资源
self.addEventListener("fetch", event => {
event.respondWith(cacheFirst(event.request))
})
webpack自动化插件
npm i workbox-webpack-plugin
// webpack
const { GenerateSW, InjectManifest } = require('workbox-webpack-plugin')
module.exports = {
plugins:[
...,
new GenerateSW({
clientsClaim: true, // 快速启用服务
skipWaiting: true,
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024
}),
]
}
构建完成后会自动生成一个service-worker.js在dist文件夹,然后我们在html上进行引用。
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
console.log('page load...')
let res = await navigator.serviceWorker.register('/service-worker.js')
console.log(res, 'serviceWorker res')
if (res) {
console.log('register success!')
} else {
console.log('register fail!')
}
})
}
</script>
遇到的坑
- workbox-webpack-plugin版本7以上用不了,跟node环境有关系,我本地的node环境为14.18.2,将版本换成6就好了,选择这个版本的原因是通过查看workbox-webpack-plugin的下载量来确定的。
- 由于生成的service-worker.js是一个固定的名称,在cdn上就无法进行更新,因为运维同学会默认将所有静态资源在线上环境进行cdn处理,会导致用户无法更新资源。故此为了解决这个问题,我们可以写一个webpack插件,当代码编译完成后,会这个文件赋值一个新的时间戳来达到server-worker.js动态更新的效果。
const fs = require('fs')
const path = require('path')
module.exports = class {
apply(compiler) {
compiler.hooks.done.tap('ChangeWorkerServiceName', (stats) => {
// 查找dist文件夹里面的html文件与service-worker.js文件,
// 0、获取当前时间戳后8位。
// 1、遍历所有的html文件内容,如果内容有里面service-worker.js这段字符串,则将字符串替换成service-worker.js + 后8位时间戳,
// 2、将dist目录下service-worker.js文件名称替换为 service-worker.js + 后8位时间戳。
const distPath = path.resolve(compiler.options.output.path)
const timestamp = Date.now().toString().slice(-8)
// 递归遍历目录
function traverseDirectory(dirPath) {
fs.readdir(dirPath, (err, files) => {
if (err) {
console.error('Error reading directory:', err)
return
}
files.forEach(file => {
const filePath = path.join(dirPath, file)
fs.stat(filePath, (err, stats) => {
if (err) {
console.error('Error getting file stats:', err)
return
}
if (stats.isDirectory()) {
// 递归处理子目录
traverseDirectory(filePath)
} else if (path.extname(file) === '.html') {
// 处理 HTML 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading file ${filePath}:`, err)
return
}
if(data.indexOf('service-worker\.js') < 0) {
return
}
// 替换 service-worker.js
const updatedData = data.replace(/service-worker\.js/g, `service-worker${timestamp}.js`)
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error(`Error writing file ${filePath}:`, err)
} else {
console.log(`Updated ${filePath}`)
}
})
})
}
})
})
})
}
// 重命名 service-worker.js 文件
function renameServiceWorkerFile(dirPath) {
const swFilePath = path.join(dirPath, 'service-worker.js')
const newSwFilePath = path.join(dirPath, `service-worker${timestamp}.js`)
fs.rename(swFilePath, newSwFilePath, (err) => {
if (err) {
console.error(`Error renaming service-worker.js to ${newSwFilePath}:`, err)
} else {
console.log(`Renamed service-worker.js to ${newSwFilePath}`)
}
})
}
// 重命名 service-worker.js 文件
renameServiceWorkerFile(distPath);
// 开始遍历 dist 目录
traverseDirectory(distPath);
})
}
}