- Published on
Punch LCP 优化 1 期
- Authors
- Name
- Magikarp
背景
目标:打卡 LCP TP75 时间减少 50%
现状:
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306104.png&w=3840&q=75)
从现状 tp75 看板来分析,html 资源加载 + 基础资源加载占用了 3029ms/4209ms(71.96%),如果我们能够优化这部分时间,就可大幅度提升首屏加载时间。
实现方案
ServiceWorker
ServiceWorker 是一种运行在浏览器后台的脚本,它可以拦截和处理网络请求,实现离线缓存、消息推送等功能。在使用 ServiceWorker 后,用户可以更快地打开网页,即使在网络不稳定或者断网的情况下,也能够查看已经缓存过的页面。
可以通过使用 ServiceWorker 来缓存文件 & html,使资源加载速度变快
兼容性
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306106.png&w=2048&q=75)
mdn 浏览器兼容性表格
从 mdn 等网站的兼容性表格中可以得知从 android webview 40+ 及 ios 11.3+ 都已经支持了 serviceWorker 的实现,而 PC 上都为兼容状态,可以正常使用。
但由于项目实际运行于 im webview 中,实际结果会和 im 的具体实现有关系,以下是在 andorid/ios 中各 im 端的具体实现:
设备 | 企微 | 钉钉 | 飞书 |
---|---|---|---|
andorid | 支持 | 支持 | 支持 |
ios | 不支持 | 不支持 | 不支持 |
ios 设备需要单独配置开启 ServiceWorker 后才能在 webview 中使用: https://github.com/react-native-webview/react-native-webview/issues/1086
由于 ios 各 im 端都未开启 serviceWorker,所以本次优化不会包含 ios(通过兼容性判断的方式)。
ServiceWorker 兼容性测试网站: https://learn-pwa-sw-registration.glitch.me/
判断是否支持的代码
if ('serviceWorker' in navigator) { // xxx }
可增加云测
Workbox
Workbox 是 Google 推出的一套用于 ServiceWorker 开发的工具集,它提供了一些常用的缓存策略和路由规则,可以帮助开发者更快地搭建 ServiceWorker,减少重复工作。
本次 Punch 项目的 ServiceWorker 接入都会基于 workbox 进行。
通用缓存策略
简单介绍下常用的几种缓存策略
Cache only
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306106.png&w=2048&q=75)
Network only
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306107.png&w=2048&q=75)
Cache first, falling back to network
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306107.png&w=2048&q=75)
Network first, falling back to cache
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306108.png&w=2048&q=75)
Stale-while-revalidate
![![图片]](/_next/image?url=%2Fpunch-sw%2Fimage-1683272306108.png&w=2048&q=75)
关键问题
如何确保不会影响到其他项目
通过增加 scope 的方式可以确保不会影响其他项目。
ServiceWorker 加载路径为 /punch/sw.js,则注册成功后只能运行在 /punch/ 下。
如何确保资源可以成功被缓存
发布后所有静态资源都将走 cdn 的地址,所有资源文件都需要做一下逻辑处理。
- 需要给对应资源增加 CORS header,目前 moka cdn 域名如下:
- 在加载第三方资源文件的元素上增加 crossOrigin Tag。
第三方资源文件需要确保可以被跨域加载,例如 aliyun 等资源
<script src="xxx" crossOrigin></script>
- webpack 加载资源时增加 crossOrigin tag
由于 Punch 使用的是 umi,所以需要开启 crossorigin 配置项
crossorigin: true,
如何确保不需要被缓存的资源可正常更新
在代码中增加对的拦截功能。
经过检查因为 abyss 包是通过网络加载且会更新,所以需要对其进行排除,目前通过 host 排除的方式:
- public-cdn-no-cache.mokahr.com 使用白名单策略
如何保障 serviceWorker 可正常被更新
访问文件 sw.js 时不可走 cdn 域名,直接访问主域名下的对应文件即可。
已和运维同学沟通过,主域名下的内容将不会被缓存。
如果真的被缓存了,用户只能够通过重装 IM 的形式进行解决,所以在上线时需要测试更新是否正常执行。
如何保障用户访问的是最新的版本
用户进入页面时,加载的资源列表并非是如同传统项目进入时就决定的,而是通过调用接口后再加载对应的 mainfest 文件后才能获取到。
下面是一个简略的用户访问流程图:
snapshot

iframe
对于 HTML 资源会采用 Stale-while-revalidate 的策略。
如果内容有变化则会直接刷新页面,使用新的 html,对用户而言无感知。该策略在获取文件时会携带上 cookie,所以后续 html 支持灰度时也能够支持该资源的更新
其余资源文件将会采用缓存优先的策略,因为在构建时会文件将会携带 hash 值,对于某些特殊的资源(例如 abyss)则单独增加排除的方式
增加 sw 后的流程如下:
snapshot

iframe
sw 文件由于构建后会直接发布到 oss 中,且路径是固定的,所以该文件不支持灰度。
在首次上线时仍然会在灰度环境进行构建,但全量后灰度环境将不会再构建该文件。
构建后 precache 逻辑也只会加载全量的 mainfest。
如果 precache 缓存到了错误的版本也没有关系,因为不会对其进行加载。
测试方案
- 使用移动端设备访问 Punch 项目,需要运行至少二次
- 第一次注册 ServiceWorker
- 第二次 ServiceWorker 使用缓存文件
- ios 不会使用 ServiceWorker
- 访问 hcm-h5-umi 项目,检查是否拦截
- hcm-h5-umi 项目未启用,不会被拦截
- 通过 charles 拦截安卓设备请求,检查访问时资源的加载情况
- layout__xxx.js 没有进行加载即为正常
- public-cdn-no-cache.mokahr.com 进行了加载,即为正常情况(需要排除缓存)
- 查看 console
- 更新 html 文件,增加测试代码,重新发布
- 访问页面,正常运行测试代码
- 更新 sw 文件,增加测试代码,重新发布
- 访问页面,正常运行测试代码
- 测试清空的情况,增加了代码用于清空 ServiceWorker
// 清除 cache storage
if (window.caches) {
window.caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name)
})
})
}
// 清空已注册的 ServiceWorker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (let registration of registrations) {
registration.unregister()
}
})
}
灰度方案
- 全量发布 node & html 代码,不代理 sw.js
- 全量发布 sw.js 文件
- 灰度进行 sw 注册
- 全量后移除灰度的 sw 构建,更改为只在全量时构建
回滚方案
ServiceWorker 如果失败,不能单纯进行代码回滚,具体步骤如下:
- 回滚代码:将代码 revert
- 部署空的 ServiceWorker:已经部署的 ServiceWorker 将无法被清除,只可以再部署一个空的 ServiceWorker 将其替换(所以这里需要保证 sw.js 不被缓存)一个空的 ServiceWorker:
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', () => {
self.clients
.matchAll({
type: 'window',
})
.then((windowClients) => {
windowClients.forEach((windowClient) => {
windowClient.navigate(windowClient.url)
})
})
})
Workbox 接入
本次接入将会使用到的 packages
"workbox-broadcast-update": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-webpack-plugin": "^6.5.4",
"workbox-window": "^6.5.4",
由于项目独特的加载逻辑,不可直接使用 workbox 的默认逻辑,所以将会使用 injectManifest 策略进行扩展。
Webpack 改造
由于 punch 使用的是 umi 进行构建,所以需要按照 umi 的规范进行改造。
在 chainWebpack 配置项中增加以下配置:
const { InjectManifest } = require('workbox-webpack-plugin')
config.plugin('workbox').use(InjectManifest, [
{
swSrc: './src/sw.ts',
swDest: 'sw.js',
},
])
Html 资源缓存
HTML 资源使用 Stale-while-revalidate 策略,具体使用方法如下:
import { NavigationRoute, registerRoute, Route } from 'workbox-routing'
const navigationRoute = new NavigationRoute(
new StaleWhileRevalidate({
cacheName: 'navigations',
})
)
registerRoute(navigationRoute)
Html 资源更新后通知页面刷新
// sw.ts
import { NavigationRoute, registerRoute, Route } from 'workbox-routing'
const navigationRoute = new NavigationRoute(
new StaleWhileRevalidate({
cacheName: 'navigations',
// 增加插件,成功时 postMessage
plugins: [new BroadcastUpdatePlugin()],
})
)
registerRoute(navigationRoute)
// app.ts
navigator.serviceWorker.addEventListener('message', async (event) => {
if (event.data.meta === 'workbox-broadcast-update') {
const { cacheName, updatedURL } = event.data.payload
if (cacheName === 'navigations' && updatedURL.includes(window.location.pathname)) {
Modal.notice({
content: '需要更新',
onConfirm: () => window.location.reload(),
})
}
}
})
style,script,image 资源缓存
将会使用 cacheFirst 策略,遇到了就缓存
import { NavigationRoute, registerRoute, Route } from 'workbox-routing'
// 遇到就缓存
const imageRoute = new Route(
({ request, sameOrigin }) => {
return request.destination === 'image'
},
new CacheFirst({
cacheName: 'images',
})
)
// 遇到就缓存
const scriptsRoute = new Route(
({ request }) => {
return request.destination === 'script'
},
new CacheFirst({
cacheName: 'scripts',
})
)
// 遇到就缓存
const stylesRoute = new Route(
({ request }) => {
return request.destination === 'style'
},
new CacheFirst({
cacheName: 'styles',
})
)
registerRoute(imageRoute)
registerRoute(scriptsRoute)
registerRoute(stylesRoute)
precache
precahce 时需要先判断当前环境,然后加载 cdn 域名下的资源
import { precacheAndRoute } from 'workbox-precaching'
function getPublicPath(host: string) {
if (IS_DEV) {
return '/'
} else if (/staging/.test(host)) {
return 'https://static-core-staging.mokahr.com'
} else if (host.includes('core.mokahr.com')) {
return 'https://static-core.mokahr.com'
} else {
throw new Error('Can not match host:' + host)
}
}
const publicPath = getPublicPath(self.location.origin)
precacheAndRoute(
manifest.map((item) => ({
...item,
url: `${publicPath}${item.url}`,
}))
)
sw 注册
import { Workbox } from 'workbox-window'
export const registerServiceWorker = (enabled: boolean) => {
if ('serviceWorker' in navigator) {
const wb = new Workbox('/punch/sw.js')
wb.register()
}
}
sw 注册支持灰度
import { Workbox } from 'workbox-window'
export const clearServiceWorker = () => {
// 清除 cache storage
if (window.caches) {
window.caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name)
})
})
}
// 清空已注册的 ServiceWorker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister()
}
})
}
}
export const registerServiceWorker = (enabled: boolean) => {
if (enabled) {
if ('serviceWorker' in navigator) {
const wb = new Workbox('/punch/sw.js')
wb.register()
}
} else {
clearServiceWorker()
}
}
参考链接
- workbox: https://developer.chrome.com/docs/workbox/service-worker-overview/
- punch 阿里云看板: https://sls.console.aliyun.com/lognext/project/moka-apm/dashboard/dashboard-1669973461313-649998
- serviceWorker caniuse: https://caniuse.com/?search=ServiceWorker
- mdn: https://developer.mozilla.org/zh-CN/docs/Web/API/ServiceWorker