阅读 378

使用 ServiceWorker 打造极速浏览体验

背景

性能优化是前端一个经久不衰的话题,我们都希望能给用户带来性能更好、更快的体验。优化页面的性能有非常多的手段,本文主要介绍 Alibaba.com 使用 ServiceWorker 优化性能的实践经验。

性能指标

在谈性能优化之前我们,我们需要了解一下页面的性能该如何定义。其实性能有非常多种定义方式和指标,除了大家所熟知的 domready  TTFB 等指标外。Google 曾经提出一系列以用户体验为中心的性能指标

大致有 FirstPaint(FP,第一次绘制时间),First ContentFul Paint(FCP,第一次有内容的绘制),First Meaningful Paint(FCP,第一次有意义的绘制),Time to Interactive(TTI,可交互时间)。

这里的 FCP 中的 Meaningful 其实是个相对主观的定义,例如我们认为一个搜索结果页的搜索框被展示是不够 Meaningful 的,而搜索商品加载出来就是 Meaningful 的。这个过程很大程度上依赖认为去定义。Alibaba.com 就是采用自己定义的 FCP 作为页面的首屏。

Chrome 78 已经尝试用一种通用算法来计算 FMP,具体的原理是通过页面填充程度的变化判断相对稳定的时间点,感兴趣的可以看:Time to First Meaningful Paint: a layout-based approach

过程分析

了解了我们要优化的目标后,我们来具体分析一下要优化性能我们需要优化哪些过程。


大致分解了一下一个页面从点击到展示首屏前的过程,后面其实可能还有更多的过程,但是一般来说对于优化的还不错的 Landing 页面,在 DOM 和  CSS 处理完毕后基本就能看到首屏了。

我们可以看到在整个过程中,网络 + Server 端的耗时其实远大于我们平时看到的 TTFB(一般 TTFB 就挺大了),根据我们之前在一个 ServerRT 大概 200 的页面测试,网络 + Server 耗时要 800ms 以上。
**
而且更重要的是,这部分的时间对于前端来说能做的手段有限,难以有效的进行优化。

如何减少网络耗时

为了最彻底的减少这段时间的耗时,我们能做的就是通过预加载的手段在上个页面提前进行网络请求

Resource Hints

其中一个常见的预加载方法就是 Resource Hints,我们经常在一些网站的 <head> 中见到

<link rel="dns-prefetch" href="//cdn.domain.com">
复制代码

这样的代码,就是在告知浏览器要提前对这个域名进行 DNS 解析,从而跳过接下来的 DNS 解析时间。

同样的,Resource Hints 还能用于连接的提前建立(不要小瞧这个时间),以及对资源进行预缓存。

具体的说明可以看 Resource Hints,这里不一一介绍。

页面怎么办

然而 Resource Hints 只提供了 DNS、连接、资源的预加载,却没有提供页面的预加载 & 缓存能力。

其实有个 Prerender 的标签本来打算用于预渲染页面,然而实际上已经被 Chrome 废弃,取而代之的是 NoStatePrefetch。而且和资源缓存相比显然不够『长效』。

在页面的预加载过程中,有几个问题是我们要解决才能真正用于生产环境的

快速失效客户端缓存

一个是客户端缓存的快速失效能力,例如当我们修复了一个线上 bug,或者活动切换页面内容。需要快速把客户端的缓存失效掉。而不是只能等着超时自动失效。

缓存实时性

一般来说页面的内容远远比静态资源要动态,我们希望在不损伤缓存命中率的前提下,页面的内容又能尽可能的保持最新。

这些要求决定了直接用 HTTP 缓存来缓存页面也不符合现实的业务场景

我们在这里就需要引入 ServiceWorker 的能力来解决页面预加载缓存的问题。

ServiceWorker

ServiceWorker 是可以部署在浏览器端的一段 JS,可以通过注册 fetch 事件来响应页面上所有的网络请求。

更具体的介绍可以见:使用 ServiceWorker

this.addEventListener('fetch', function(event) {
  event.respondWith(
    // magic goes here
  );
});
复制代码

通过这种机制,我们可以在上个页面把需要预加载的页面加载下来,存到 IndexDB 等存储中,在用户访问下个页面时通过 ServiceWorker 把缓存的内容取出来。

ServiceWorker 如何解决缓存碰到的问题

客户端缓存失效

我们仍然可以让 ServiceWorker 直接取缓存内容(从而保证性能),而在另外一个地方间隔一段时间检查 CDN 上的缓存配置,从而决定是否要失效化缓存。


用简单的代码表示就是:

// sw.js
this.addEventListener(‘fetch', function(event) 
{
  event.respondWith(
    caches.match(event.request)
  );
});
复制代码

而在另外一个地方

setInterval(async () => {
  const res = await fetch('/cdn/cache-config');
  const data = await res.json();
  caches.checkCacheLifeTime(data);
}, CHECK_CACHE_INTERVAL);
复制代码

缓存实时性

其实有了 ServiceWorker 后我们就有了对缓存流程丰富灵活的控制能力,我们只需要实现一个命中缓存同时去线上取最新页面覆盖缓存的机制即可。

当页面请求到 ServiceWorker 时,ServiceWorker 同时请求缓存和网络,把缓存的内容直接给用户,而后覆盖缓存。


用代码可以简单的表示为:

this.addEventListener('fetch', function(event) 
{
  fetch(event.request).then(res => caches.update(res));
  event.respondWith(
    caches.match(event.request)
  );
});
复制代码

要预加载什么

解决了预加载的基础能力后,我们仍然要面临一个问题:页面太多,一个两个页面还可以手动写死预加载的内容,一旦时间长了后,往往出现很多地方不知道预加载什么,资源版本升级后忘了改预加载等情况。

其实我们可以通过在资源访问的时候上报数据,从而归纳出预加载的规则。

当用户访问时,上报当前页面的资源加载情况(通过 performance.getEntries() 可以取到)和上一条页面。在服务端可以定时从过去一段时间的数据『归纳』出预加载的规则,然后更新到 CDN 上给客户端消费。


这个『归纳』其实也并非很神秘的过程,最简化的思路可以用一段 SQL 就能实现

SELECT COUNT(1) as role, resUrl
FROM res_log
WHERE time in LAST HOUR
AND pre_page = 'homepage'
AND hit_cache = 'N'
ORDER by role
复制代码

从上报的路径 页面 => 资源 中直接按照出现次数排序最高的若干条,就能归纳出在某个页面应该预加载什么。

错误监控

现在前端有很多错误监控平台,大多没有针对 ServiceWorker 单独适配,其实 ServiceWorker 也只是一个 JS 执行环境,可以通过 onerror 和 addEventListener 监听错误即可。

self.onerror = function (errorMessage, scriptURI, lineNumber, columnNumber, e) {
  logError(e);
};

// 监听 Promise 的异常
window.addEventListener('unhandledrejection', (event) => {
  logError(event.reason);
  event.preventDefault();
});
复制代码

总结

在我们具备了DNS、连接、资源甚至页面预加载能力后,在一些场景下能够减少甚至完全消灭用户白屏的『800ms』。而通过数据驱动的能力,能够帮助我们把预加载所需要的配置更加自动化。

对于一个较大的网站,landing 页面的性能优化很重要,但站内的页面不应该总是一个个割裂的页面体验,在适合的实际预加载能让用户在站内的访问有更好的体验。

关注下面的标签,发现更多相似文章
评论