- 原文地址(本人博客):www.waylon.online/blog?id=299…
前言
因为工作需要,这段时间接触到了PWA,所以突发奇想给自己的博客稍作下优化,运用PWA技术来给博客添加一些新功能(生成网页应用)与提升一些体验(离线访问),顺带深入学习下PWA。
Progressive Web App (PWA)技术介绍
PWA并不是具体的某项技术,而是提升 Web App 的体验的一系列新方法,能给用户原生应用的体验,在安全、性能和体验三个方面都有很大提升,PWA 本质上是 Web App,借助一些新技术也具备了 Native App 的一些特性,兼具 Web App 和 Native App 的优点。
PWA所主要涉及的技术
- Web app manifests: 通过 JSON 提供应用程序相关信息,目的是将应用程序安装到设备桌面;
- Service Worker: 充当浏览器与服务端间的网络代理,提供离线访问能力
- Push API: 从服务器向客户端推送消息,需要借助 service worker;
- Notifications API: 允许网页或应用程序在系统级别发送在页面外部显示的通知;
- Add to Home Screen: 将应用程序安装到设备桌面的关键,需要浏览器支持 beforeinstallprompt 事件;
在上面所涉猎的技术栈中, Web app manifest与Service Worker技术是PWA最关键的两项技术,本篇文章会着重介绍,而随着apple在iOS Safari中也开始支持PWA(其中的某些技术),PWA的舞台更大了。
Web app manifests
熟悉小程序的朋友想必都清楚manifest.json,在PWA中也有着一个manifest,我们称之为“功能清单”,它被用来定义应用的基本信息和行为。
范例:
{
"id":"/",
"name": "Waylon's Blog",
"short_name": "waylon blog",
"start_url": "/",
"scope":"/",
"orientation": "portrait-primary",
"display": "standalone",
"background_color": "#fff",
"theme_color": "rgba(0, 0, 0, 0.8)",
"description": "Waylon的博客,一起学习前端JavaScript,typeScript,Vue,React。",
"icons": [{
"src": "logo.png",
"sizes": "144x144",
"type": "image/png"
},{
"src": "logo192.png",
"sizes": "192x192",
"type": "image/png"
}],
"lang": "en-US",
"related_applications": [{
"id": "waylon blog.app",
"url":".",
"platform": "web"
}]
}
在上述代码中包含着许多的字段,这里着重介绍几个比较关键的:
-
name,short_name:应用的全称与简称,一般来说,当没有足够空间展示应用的
name时,系统就会使用short_name; -
description:应用描述;
-
start_url: Web App首屏加载的URL;
-
diplay:
display控制了应用的显示模式,它有四个值可以选择:fullscreen、standalone、minimal-ui和browser。fullscreen:全屏显示,会尽可能将所有的显示区域都占满;standalone:独立应用模式,这种模式下打开的应用有自己的启动图标,并且不会有浏览器的地址栏。因此看起来更像一个Native App;minimal-ui:与standalone相比,该模式会多出地址栏;browser:一般来说,会和正常使用浏览器打开样式一致。
-
background_color,theme_color:
background_color是指在应用的样式资源为加载完毕前的默认背景,因此会展示在开屏界面,theme_color则是应用程序主题颜色; -
icons: 桌面应用的图标,icons本身是一个数组,每个元素包含三个属性:
- sizes:图标的大小。通过指定大小,系统会选取最合适的图标展示在相应位置上。
- src:图标的文件路径。注意相对路径是相对于manifest。
- type:图标的图片类型。
这里有个坑点,PC端icon尺寸至少需要144*144,否则无法生成桌面应用,笔者在这里卡了很久。
-
orientation:定义所有 Web 应用程序顶级的默认方向,在window操作系统中这个是必选项,否则无法安装(也是一个小坑)。
引用方式:
<link rel="manifest" href="/manifest.json">
引入成功后,就可以在地址栏的右侧看到【添加到应用】的按钮,点击即可安装。
但是作为一名有追求的开发,怎么能够忍受浏览器原生的功能,所以我选择在页面添加一个按钮引导【添加应用】,就是图中显示的这样。
这里我们要用到浏览器的
BeforeInstallPromptEvent事件,它会在应用被安装到设备前触发,我们可以通过它内置的prompt()方法触发安装提示框,并且通过userChoice对象获取用户点击了【安装】还是取消。值得一提的是,这个API目前还处于实验阶段,其兼容性如下:(caniuse.com/?search=Bef…)
具体代码实践
<template>
<div class="download-pwa">
<a-popover placement="bottom" trigger="hover" overlayClassName="link-popover" :mouseEnterDelay="0">
<template #content>
<div class="popover-content">Download PWA</div>
</template>
<i class="waylon pwa" id="PWA-BTN" @click="download" />
</a-popover>
</div>
</template>
<script setup lang="ts">
import { ComputedRef, Ref } from 'vue';
import { useThemeStore } from 'store/theme';
import { useMessage } from '@/common/util/hook';
import { isSafari } from '@/common/util';
const { isBlack } = toRefs(useThemeStore());
const { message } = useMessage();
// PWA下载图标颜色
const pwaColor: ComputedRef<string> = computed(() =>
isBlack.value ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.4)'
);
type PromptType = {
prompt: Function;
userChoice: Promise<{ outcome: string; platform: string }>;
} | null;
type Window = {
navigator: {
standalone?: boolean;
};
};
const deferredPrompt: Ref<PromptType> = ref(null);
const download = () => {
// 浏览器判断是否已在pwa环境
const isPWASituation = isSafari
? (window as Window).navigator.standalone === true
: window.matchMedia('(display-mode: standalone)').matches;
if (isPWASituation) {
message('info', '你现在已处于PWA应用中了~');
} else {
if (deferredPrompt.value) {
deferredPrompt.value.prompt();
deferredPrompt.value.userChoice.then(result => {
if (result.outcome == 'dismissed') {
message('info', '取消安装!');
} else if (result.outcome == 'accepted') {
console.log('用户接受了安装');
deferredPrompt.value = null;
}
});
}
}
};
// 获取Prompt对象
const catchPrompt = (e: any) => {
// Chrome 67 及之前版本,会自动展现安装的 prompt
// 为了版本统一及用户体验,我们禁止自动展现 prompt
e.preventDefault();
// 存放事件用于后续触发
deferredPrompt.value = e as unknown as PromptType;
console.warn('beforeinstallprompt', e);
};
// 安装成功的回调
const installedCallback = () => {
deferredPrompt.value = null;
console.log('应用安装');
};
onMounted(() => {
// 仅浏览器支持且未安装该应用,以下事件才会触发
window.addEventListener('beforeinstallprompt', catchPrompt);
// 无论以何种方式安装 PWA 该事件都会触发
// 因此这里可以用来做埋点
window.addEventListener('appinstalled', installedCallback);
});
onUnmounted(() => {
// 侦听器清除
window.removeEventListener('beforeinstallprompt', catchPrompt);
window.removeEventListener('appinstalled', installedCallback);
});
</script>
<style lang="scss" scoped>
.download-pwa {
.pwa {
color: v-bind(pwaColor);
font-size: 30px;
margin-right: 16px;
cursor: pointer;
}
}
</style>
Service Worker
Service Worker(下文称SW)服务是一个介于 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。
Service Worker的基本特征
- 无法操作DOM
- 只能使用HTTPS以及localhost
- 可以拦截全站请求从而控制你的应用
- 与主线程独立不会被阻塞(不要再应用加载时注册sw)
- 完全异步,无法使用XHR和localStorage
- 一旦被 install,就永远存在,除非被 uninstall或者dev模式手动删除
- 独立上下文
- 响应推送
- 后台同步
SW是事件驱动的worker,生命周期与页面无关。 关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动,其生命周期如下:
register这个是由 client 端发起,注册一个 SW,这需要一个专门处理sw逻辑的文件;parsed注册完成,解析成功,尚未安装;installing注册中,此时 sw 中会触发 install 事件, 需知 sw 中都是事件触发的方式进行的逻辑调用,如果事件里有 event.waitUntil() 则会等待传入的 Promise 完成才会成功;installed(waiting)注册完成,但是页面被旧的 SW 脚本控制, 所以当前的脚本尚未激活处于等待中,可以通过 self.skipWaiting() 跳过等待;activating安装后要等待激活,也就是 activated 事件,只要 register 成功后就会触发 install ,但不会立即触发 activated,如果事件里有 event.waitUntil() 则会等待这个 Promise 完成才会成功,这时可以调用 Clients.claim() 接管所有页面;activated在 activated 之后就可以开始对 client 的请求进行拦截处理,sw 发起请求用的是 fetch api,XHR无法使用;fetch激活以后开始对网页中发起的请求进行拦截处理 terminate 这一步是浏览器自身的判断处理,当 sw 长时间不用之后,处于闲置状态,浏览器会把该 sw 暂停,直到再次使用;update浏览器会自动检测 sw 文件的更新,当有更新时会下载并 install,但页面中还是老的 sw 在控制,只有当用户新开窗口后新的 sw 才能激活控制页面;redundant安装失败, 或者激活失败, 或者被新的 SW 替代掉;
Service Worker实战
首先,明确我们需要使用SW做什么?首先,我们使用manifest.json的功能清单注册了一个桌面应用,但是作为一个桌面应用,它必须具备一些离线访问的能力,所以在这里,我们需要使用SW来缓存一些站点必要的资源,以便于在离线的情况下访问应用。
SW本质上就是一个JS脚本,在这个内置的脚本中,我们可以使用addEventListener侦听器去侦听其特有的事件,在SW中内置了许多API,详细可见MDN,这里我们会用到:
self对象:在Service Worker中,"self"是一个特殊的全局对象,它表示当前Service Worker线程的上下文,可以使用"self"对象来注册事件监听器、发送网络请求、缓存资源等。install事件,当当前SW脚本被下载并安装后触发的事件,一般用来添加需要缓存的资源。fetch事件,它会在每一个HTTP请求发出时触发,可用于实现自己的缓存策略,请求拦截等。caches对象,用于缓存和管理资源。
编码
创建SW
首先,我们需要创建一个sw.js文件作为SW。
// packages\waylon-blog-pages\public\initSw.js
// 缓存key
const CACHE_KEY = 'WAYLON_BLOG_CACHE';
// 需要缓存的资源 - 固定资源
const CACHE_LIST = [
'/',
'/pixel-block-font.woff2',
'/logo.svg',
'/gem.json',
'/manifest.json',
'/logo.png',
'/api/sdk/monokai-sublime.min.css',
'/sw.js',
'/initSw.js',
'/api/sdk/three.min.js',
'/api/sdk/marked.min.js',
'/api/sdk/highlight.min.js',
'/api/sdk/echarts.min.js',
'/api/sdk/echarts-wordcloud.min.js',
];
// 侦听器
self.addEventListener('install', event => {
console.warn('Service Worker install success');
// 添加缓存
event.waitUntil(
caches.open(CACHE_KEY).then(cache => {
return cache.addAll(CACHE_LIST);
})
);
});
// 监听sw是否活跃
self.addEventListener('activate', event => {
console.log('Service Worker activate');
});
// 在 fetch 事件中返回缓存的资源
self.addEventListener('fetch', event => {
try {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
} catch (e) {
console.error('service worker fetch error:', e);
}
});
注册SW
// packages\waylon-blog-pages\public\initSw.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then(res => console.warn('service worker registered'))
.catch(err => console.error('service worker not registered', err));
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Waylonの树洞 | Waylon blog.</title>
<meta name="description" content="Waylon的博客,一起学习前端JavaScript,typeScript,Vue,React。" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,shrink-to-fit=no">
<!-- 预加载字体文件 -->
<link rel="preload" as="font" href="/pixel-block-font.woff2" type="font/woff2" crossorigin="anonymous">
<!-- 无色icon -->
<link rel=" stylesheet" href="//at.alicdn.com/t/c/font_3856002_u7n6feqprti.css">
<!-- 带颜色的icon -->
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_3862335_a12e2rys0xa.css">
<!-- 字体 -->
<link rel="stylesheet" href="/src/assets/style/init.css">
<!-- 百度验证 -->
<meta name="baidu-site-verification" content="codeva-vL92OUhjoa" />
<!-- PWA 配置 -->
<link rel="manifest" href="/manifest.json">
<!-- PWA 初始化 defer防止阻塞页面渲染 -->
<script defer src="/initSw.js"></script>
</head>
<body>
<div id="app" />
<script type="module" src="/src/main.ts"></script>
</body>
</html>
查看效果
可以在【控制台】【应用】【Service Worker】查看已启动的SW,并且可勾选【离线】进入off-line模式debug。
并且可以在【缓存空间】中查看我们刚刚设置的缓存。
结束
自此,我们就打造了一款支持离线访问的PWA应用~但是我目前我也仅仅只是对必要的静态资源进行了SW缓存,仅仅保证页面在断网的情况下可访问,样式正常,并没有对数据层做缓存,后续会继续深入学习。