背景
平台各个子应用静态资源加载速度慢,用户体验有待提升
主要目的:
- 解决资源加载慢问题,提升体验
- 支持应用自启动(原生
app
体验)
项目使用到的技术栈为:React
umi3
qiankun
PWA 介绍
简介
PWA 它不是特指某一项技术,而是应用多项技术来改善用户体验的 Web App
,其核心技术包括 Web App Manifest
,Service Worker
等,用户体验才是 PWA
的核心。
特点:
- 可靠 - 即使在网络不稳定甚至断网的环境下,也能瞬间加载并展现
- 用户体验 - 快速响应,具有平滑的过渡动画及用户操作的反馈
- 用户黏性 - 和
Native App
一样,可以被添加到桌面,具有沉浸式的用户体验
核心技术
Web App Manifest
主要为项目配置manifest.json
,提供浏览器安装PWA
所需的信息,例如应用程序名称和图标等。Web app manifests
允许开发者配置隐藏浏览器多余的 UI(地址栏,导航栏等),让PWA
具有和Native App
一样的沉浸式体验。
Service Worker
- 使用到的时候浏览器会自动唤醒,不用的时候自动休眠
- 可拦截并代理请求和处理返回,可以操作本地缓存,如 CacheStorage,IndexedDB 等
- 离线内容开发者可控
- 能接受服务器推送的离线消息
- 必须在 HTTPS 环境下才能工作
实现过程
注册 Service Worker
- 根目录新建
service-worker.js
文件,用于编写Service Worker
具体逻辑。
主要添加安装、激活、缓存捕获后的处理逻辑(会在后续缓存策略章节详细描述)
- 注册 service worker 服务
在 index.html 文件中注入以下代码
html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
我们项目是基于umi开发的,在umi3中在document.ejs
添加script
标签,在umi4中则需要在umirc.ts
中的headScript
添加内容。
安装到主屏幕
- 根目录(或是public目录)下新建
manifest.json
文件,添加需要的配置,具体可参考文档。 Web App Manifest,下面提供一份实例:
json
{
"short_name": "灵思 Aicity",
"name": "灵思 Aicity",
"icons": [
{
"src": "https://xxx/144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "https://xxx/192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
],
"start_url": ".",
"display": "minimal-ui",
"background_color": "#fff",
"theme_color": "#fff"
}
- index.html中进行manifest引入
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
id="scale-view"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
</head>
</html>
到这一步启动项目之后就会在地址栏多出一个按钮
点击按钮就可以进行web app
安装
安装完文件夹(或桌面)就会多出一个应用信息。
到这web应用的可安装操作就完成了,如果没有出现下载按钮,可打开控制台查看 Application-Manifest相关报错提示!!!
缓存策略
其核心是使用了 service worker
相关技术,详细可参考文档 Service Worker API。
为了减少一些处理细节上的时间,采用了google的插件workbox进行快速集成,省去了很多准备工作。
- 如果你只需要实现一些基本的缓存,不做预加载这些其他操作,workbox GenerateSW这个就足以满足你的需求,以下是一个建议配置:
js
import { defineConfig } from 'umi';
import { GenerateSW } from 'workbox-webpack-plugin';
export default defineConfig({
// umi configs
// ...
chainWebpack(memo) {
memo.plugin('workbox').use(GenerateSW, {
// 设置前缀
cacheId: 'webpack-pwa',
// 强制等待中的 Service Worker 被执行
skipWaiting: true,
clientsClaim: true, // Service Worker 被执行后使其立即获得页面控制权
swDest: 'service-wroker.js', // 输出 Service worker 文件
globPatterns: ['**/*.{html,js,css,png.jpg}'], // 匹配的文件
globIgnores: ['service-wroker.js'], // 忽略的文件
runtimeCaching: [
// 配置路由请求缓存,可设置多个
{
// 匹配文件
urlPattern: /.*\.js/,
// 网络优先
handler: 'NetworkFirst'
}
]
})
}
})
- 遇到复杂点的场景,还是需要一定自由度去编写策略,我们可以选择workbox InjectManifest进行处理,详细实现如下:
umirc.ts
js
import { defineConfig } from 'umi';
import { InjectManifest } from 'workbox-webpack-plugin';
export default defineConfig({
// umi configs
// ...
chainWebpack(memo) {
// InjectManifest 插件会对我们自己编写的service-worker.js文件进行二次处理,添加构建后的manifest相关映射等
memo.plugin('workbox').use(InjectManifest, [
{
// 编写好的 service-worker.js 的位置,相对于根目录
swSrc: './service-worker.js',
// 经 webpack 处理后的 service-worker.js 位置,相对于public目录
swDest: 'service-worker.js',
// 适当调整预缓存的单个文件大小上限
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024
},
]);
}
})
service-worker.js
registerRoute
和GenerateSW
中的runtimeCaching
配置是一样的,主要区分下集中缓存策略:
CacheFirst
会在有缓存的时候返回缓存,没有缓存才会去请求并且把请求结果缓存CacheOnly
只返回缓存,不请求NetworkFirst
请求将会发出,成功的话就返回结果添加到缓存中,如果失败则返回立即缓存NetworkOnly
只请求,不读写缓存StaleWhileRevalidate
类似于 CacheFirst,区别在于在返回 Cache 缓存结果的同时会在后台发起网络请求拿到请求结果并更新 Cache 缓存,如果本来就没有 Cache 缓存的话,直接就发起网络请求并返回结果
js
import { registerRoute } from 'workbox-routing';
import {
StaleWhileRevalidate,
NetworkFirst,
CacheFirst,
} from 'workbox-strategies';
// 开发环境禁止打印日志信息(实在太多了...)
self.__WB_DISABLE_DEV_LOGS = true;
// 三方资源缓存
registerRoute(
/.*(gif|jpg|jpeg|png|svg|otf|woff|woff2|ttf|mp4|pbf).*/,
new CacheFirst({
cacheName: 'cache-static',
expiration: {
maxEntries: 1000, // TODO 类似于图库、地图类的加载图片、pbf文件数量巨大;需要调整修改👈🏻
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
}),
);
// js缓存
registerRoute(
/.*\.js.*/,
new StaleWhileRevalidate({
cacheName: 'cache-js',
expiration: {
maxEntries: 100, // 最多缓存 100 个,超过的按照LRU原则删除
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
}),
);
// 样式缓存
registerRoute(
/.*\.css.*/,
new StaleWhileRevalidate({
cacheName: 'cache-style',
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
}),
);
// 接口缓存
registerRoute(
/^.*(-api\.).*$/,
new NetworkFirst({
cacheName: 'cache-api',
cacheableResponse: {
statuses: [200],
},
}),
);
// 兜底规则
registerRoute(
({ url }) => {
// vms 视频流格式进行过滤,直播没必要缓存
const reg = /.*(\.flv|\.m3u8|\.ts|\.tsx).*/;
return !reg.test(url.href);
},
new NetworkFirst({
cacheName: 'cache-others',
cacheableResponse: {
statuses: [200],
},
}),
);
到这再请求页面的时候Application => Storage =>Cache storage
里就能看到被缓存下来的文件了。
预加载实现
在上述基础上,我们对service-worker.js
进行一些改造,让他可以进行预缓存。其实在配置InjectManifest
的时候也提到了,workbox
会帮我们生成一份构建产物的映射(manifest
)。借助这个我们可以实现预缓存。具体操作如下:
js
import { precacheAndRoute } from 'workbox-precaching';
// 预加载路由
// ---
// 子模块一些路由
const routes = ['/xxx', '/aaa'];
// 构建产物(基座)
const precacheList = self.__WB_MANIFEST || [];
precacheAndRoute([...precacheList, ...routes]);
// 子应用manifest请求
// ---
// 需要预加载的子模块,这一步之前需要到各个子模块去配置打包生成`asset-manifest.json`文件
const precacheApps = ['portrait', 'map', 'graph'];
precacheApps.forEach((app) => {
fetch(`/${app}/asset-manifest.json`)
.then((response) => response.json())
.then((data) => {
const files = Object.values(data).filter(
(file) => !file.includes('/index.html'),
);
precacheAndRoute(files);
})
.catch((e) =>
console.error(
`Error: can not fetch ${app} app asset-manifest.json`,
),
);
});
Features
多 Tab 探索
这是目前比较常见的一个问题,在pwa应用内使用window.open()
打开新tab会跳转到浏览器中,这样体验感是很不好的;对此我们想到了两种解决方案:
- 自己实现一套 Tab UI
大概长这样...
主要就是通过拦截window.open()
,维护一份tabs的相关数据,使用iframe
渲染每一个子页面
- 优点:功能齐全,UI展示效果好
- 缺点:性能差(主要原因你想想,开20个自定义tab,实际使用的是一个浏览器Tab性能,这不得炸了...);刷新、全屏、多个pwa应用等场景数据维护成本高;
- 使用chrome实验性api(目前楼主的方案)
在大概chrome@89
版本左右开始,chrome 提出了 display: tabbed
模式,使用该模式需要手动开启:
这样在安装的时候会提示"在新标签页中打开"
大概长这样
- 优点:原生交互体验好
- 缺点:实验性api,用户使用成本大(目前我们平台还是小范围的推多tab形式,主要是内部人员使用,用于演示这些操作)
唤起方式
主要是针对pwa应用之间的相互唤起、开机启动这些
持续探索中,有好方案欢迎留言...
POST 缓存方案
由于cache storage
不支持缓存 POST
请求,所以在首页展示有POST
相关接口进行数据拉取时,离线效果不理想。这个问题更多的是探讨是否有必要这样做,理论上遵守restful api
规范的话就不会存在这个问题(具体的原因就不进行深究了...)。
抛开业务,我们只探讨技术方案;对此找了下相关文档,还真有实现方案,大致分以下几部:
- Service Worker 拦截一个 POST 请求,并根据request中的查询字符串组成一个 MD5 加密的缓存key。
- Service Worker 使用缓存key将新的 JSON 响应存储在 IndexedDB 中。
- 如果POST请求失败,Service Worker 使用缓存key检查 IndexedDB。如果密钥存在,则返回缓存的 JSON。
具体的可以参考原文Service worker无法缓存POST请求?来我教你
结语
这是一次很好的学习过程,上述所提到的插件是Webpack
的,Vite
也有相关插件vite-plugin-pwa
,配置大同小异。有什么问题欢迎评论区交流👏👏👏