Published on

Punch LCP 优化 1 期

Authors
  • avatar
    Name
    Magikarp
    Twitter

背景

目标:打卡 LCP TP75 时间减少 50%

现状:

![图片]

从现状 tp75 看板来分析,html 资源加载 + 基础资源加载占用了 3029ms/4209ms(71.96%),如果我们能够优化这部分时间,就可大幅度提升首屏加载时间。

实现方案

ServiceWorker

ServiceWorker 是一种运行在浏览器后台的脚本,它可以拦截和处理网络请求,实现离线缓存、消息推送等功能。在使用 ServiceWorker 后,用户可以更快地打开网页,即使在网络不稳定或者断网的情况下,也能够查看已经缓存过的页面。

可以通过使用 ServiceWorker 来缓存文件 & html,使资源加载速度变快

兼容性

![图片]

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

![图片]

Network only

![图片]

Cache first, falling back to network

![图片]

Network first, falling back to cache

![图片]

Stale-while-revalidate

![图片]

关键问题

如何确保不会影响到其他项目

通过增加 scope 的方式可以确保不会影响其他项目。

ServiceWorker 加载路径为 /punch/sw.js,则注册成功后只能运行在 /punch/ 下。

如何确保资源可以成功被缓存

发布后所有静态资源都将走 cdn 的地址,所有资源文件都需要做一下逻辑处理。

  1. 需要给对应资源增加 CORS header,目前 moka cdn 域名如下:
  1. 在加载第三方资源文件的元素上增加 crossOrigin Tag。

    第三方资源文件需要确保可以被跨域加载,例如 aliyun 等资源

<script src="xxx" crossOrigin></script>
  1. webpack 加载资源时增加 crossOrigin tag

    由于 Punch 使用的是 umi,所以需要开启 crossorigin 配置项

crossorigin: true,

如何确保不需要被缓存的资源可正常更新

在代码中增加对的拦截功能。

经过检查因为 abyss 包是通过网络加载且会更新,所以需要对其进行排除,目前通过 host 排除的方式:

如何保障 serviceWorker 可正常被更新

访问文件 sw.js 时不可走 cdn 域名,直接访问主域名下的对应文件即可。

已和运维同学沟通过,主域名下的内容将不会被缓存。

如果真的被缓存了,用户只能够通过重装 IM 的形式进行解决,所以在上线时需要测试更新是否正常执行。

如何保障用户访问的是最新的版本

用户进入页面时,加载的资源列表并非是如同传统项目进入时就决定的,而是通过调用接口后再加载对应的 mainfest 文件后才能获取到。

下面是一个简略的用户访问流程图:

snapshot
flow-1
iframe

对于 HTML 资源会采用 Stale-while-revalidate 的策略。

如果内容有变化则会直接刷新页面,使用新的 html,对用户而言无感知。该策略在获取文件时会携带上 cookie,所以后续 html 支持灰度时也能够支持该资源的更新

其余资源文件将会采用缓存优先的策略,因为在构建时会文件将会携带 hash 值,对于某些特殊的资源(例如 abyss)则单独增加排除的方式

增加 sw 后的流程如下:


snapshot
flow-1
iframe

sw 文件由于构建后会直接发布到 oss 中,且路径是固定的,所以该文件不支持灰度

在首次上线时仍然会在灰度环境进行构建,但全量后灰度环境将不会再构建该文件。

构建后 precache 逻辑也只会加载全量的 mainfest。

如果 precache 缓存到了错误的版本也没有关系,因为不会对其进行加载。

测试方案

  1. 使用移动端设备访问 Punch 项目,需要运行至少二次
    1. 第一次注册 ServiceWorker
    2. 第二次 ServiceWorker 使用缓存文件
    3. ios 不会使用 ServiceWorker
  2. 访问 hcm-h5-umi 项目,检查是否拦截
    1. hcm-h5-umi 项目未启用,不会被拦截
  3. 通过 charles 拦截安卓设备请求,检查访问时资源的加载情况
    1. layout__xxx.js 没有进行加载即为正常
    2. public-cdn-no-cache.mokahr.com 进行了加载,即为正常情况(需要排除缓存)
    3. 查看 console
  4. 更新 html 文件,增加测试代码,重新发布
    1. 访问页面,正常运行测试代码
  5. 更新 sw 文件,增加测试代码,重新发布
    1. 访问页面,正常运行测试代码
  6. 测试清空的情况,增加了代码用于清空 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()
    }
  })
}

灰度方案

  1. 全量发布 node & html 代码,不代理 sw.js
  2. 全量发布 sw.js 文件
  3. 灰度进行 sw 注册
  4. 全量后移除灰度的 sw 构建,更改为只在全量时构建

回滚方案

ServiceWorker 如果失败,不能单纯进行代码回滚,具体步骤如下:

  1. 回滚代码:将代码 revert
  2. 部署空的 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()
  }
}

参考链接