Article

用户体验至上「PWA」时代的缓存策略

从前,我们依靠浏览器来为我们处理缓存。作为当时的开发人员,我们几乎没有控制权。但是随后出现了渐进式 Web 应用程序(PWA),云服务器和缓存 API,突然之间,我们对放入缓存中的内容以及如何放入缓存有了强大的控制力。现在,我们可以缓存我们想要存储的所有内容…

如今,媒体文件(尤其是图像)占据了平均页面重量的大部分,而且情况越来越糟。为了提高性能,试图尽可能多地缓存这些内容是很诱人的,但是我们应该这样做吗?在大多数情况下,不会。即使掌握了所有这些新颖的技术,出色的性能仍然取决于一个简单的规则:仅请求您需要的内容,并尽可能减少每个请求。

为了在不滥用网络连接或硬盘驱动器的情况下为我们的用户提供最佳体验,是时候尝试一些经典的最佳做法了,尝试使用媒体缓存策略,并尝试一些 Service Workers 的 Cache API 技巧。

性能最佳实践

当移动端起飞时,我们学到的所有用于移动端优化网页的经验教训变得非常有用,它们继续适用于今天为全球大众所做的工作。不可靠或高延迟的网络连接仍然是世界许多地方的常态,这提醒我们,假设技术基准均匀上升或与其相应的同步上升从来都不是安全的。这就是性能最佳实践的意义:历史证明,现在有益的方法在将来将继续对性能有利。

在 Service Workers 出现之前,我们可以向浏览器提供一些指示,说明它们应将特定资源缓存多长时间,但仅此而已。下载到用户计算机上的文档和资产将放置在硬盘驱动器上的目录中。当浏览器组合了对特定文档或资产的请求时,它将首先在缓存中进行查看是否已存在,避免拉取相同重复资源。如今,我们对网络请求和缓存有了更多的控制权,但这并不能免除我们对网页优化的停滞。

索取需要的内容

正如我所提到的,当今的网络糟糕透顶。图像和视频已成为一种主要的交流手段。在销售和市场营销方面,它们的转换效果可能很好,但是在下载和渲染速度方面,它们的性能却很差,因为每个图像(以及视频等)都争取在页面上的主要位置出现。

当然,我可以责怪建立该网站的人们对读者造成如此大的损害,但现实情况是,我们当中没有一个人以恶化用户体验为目标。这可能发生在我们任何人身上,可能需要花费几天的时间来仔细检查网页的性能,只是让某个委员会决定将精心制作的网页设置在自动播放视频广告的时代广场上。想象一下,如果我们将两个表现出色的页面彼此叠放在一起,情况将会变得更糟!

当竞争激烈时(例如,在报纸的首页上),媒体可以吸引人们的注意力,但是当您希望读者专注于一项任务(例如,阅读实际文章)时,媒体的价值可能会从重要下降为“很高兴有。” 是的,研究表明,图像擅长于吸引眼球,但是一旦访客出现在文章页面上,就不会有人在乎了。我们只是使下载时间更长,访问成本更高。随着我们将更多的媒体推入页面,情况只会变得更糟。

我们必须竭尽所能来减少页面的重量,因此请避免要求不要增加附加值的事情。首先,如果您要写一篇有关数据泄露的文章,请不要在一个非常黑暗的房间里的计算机上打字的连帽衫中放一些随机花花公子的可笑股票照片。

索取最小的文件

现在,我们已经对需要包括的内容进行了盘点,我们必须问自己一个关键问题:我们如何以最快的方式交付它?这可以简单到为呈现的内容选择最合适的图像格式(并对其进行优化),也可以复杂到完全重新创建资产(例如,如果从光栅图像转换为矢量图像会更有效)。

提供替代格式

当涉及图像格式时,我们不必在性能和覆盖范围之间进行选择。我们可以提供多个选项,让浏览器根据其处理能力来决定使用哪个选项。

您可以通过 sources 在 picture 或 video 元素内提供多个来完成此操作。首先创建媒体资产的多种格式。例如,WebP 和 JPG,WebP 的文件大小可能会比 JPG 小。使用这些备用源,您可以将它们放入 picture 如下所示:

<picture>
  <source srcset="my.webp" type="image/webp">
  <img src="my.jpg" alt="Descriptive text about the picture.">
</picture>

识别该 picture 元素的浏览器将 source 在决定要请求的图像之前检查该元素。如果浏览器支持MIME类型 “ image / webp”,它将启动对 WebP 格式图像的请求。如果不是(或者浏览器无法识别 picture),它将请求 JPG。

这种方法的好处是,您可以为用户提供尽可能小的图像,而不必诉诸任何 JavaScript 黑科技。

您可以对视频文件采用相同的方法:

<video controls>
  <source src="my.webm" type="video/webm">
  <source src="my.mp4" type="video/mp4">
  <p>Your browser doesn’t support native video playback,
    but you can <a href="my.mp4" download>download</a>
    this video instead.</p>
</video>

支持 WebM 的浏览器将请求第一个 source,而不支持但理解 MP4 视频的浏览器将请求第二个。不支持该 video 元素的浏览器将退回到有关下载文件的段落。source元素的顺序很重要。浏览器将选择第一个可用的 source,因此,如果在更广泛兼容的格式之后指定一种优化的替代格式,则该替代格式可能永远不会被采用。

根据您的情况,您可以考虑绕过这种基于标记的方法,而是在服务器上处理事情。例如,如果正在请求 JPG,并且浏览器支持 WebP,则不会阻止您使用该资源的 WebP 版本进行回复。实际上,某些CDN服务(例如 Cloudinary)开箱即用。

提供不同尺寸

除了格式,您可能需要提供针对当前浏览器视口大小优化的备用图像大小。毕竟,加载比屏幕渲染大 3-4 倍的图像毫无意义。那只是浪费带宽。这是响应式图像出现的地方。

这是一个例子:

<img src="medium.jpg"
  srcset="small.jpg 256w,
    medium.jpg 512w,
    large.jpg 1024w"
  sizes="(min-width: 30em) 30em, 100vw"
  alt="Descriptive text about the picture.">

这个超级丰满的img元素有很多事情要做,所以我将其分解:

img对于给定的 JPG,这提供了三种尺寸选项:256 像素宽(small.jpg),512 像素宽(medium.jpg)和 1024 像素宽(large.jpg)。在srcset属性中为它们提供了相应的宽度描述符。在src定义了一个默认图像源,其作为对于不支持的浏览器的回退srcset。您对默认图像的选择可能取决于上下文和常规使用模式。

通常,我建议最小的图像为默认图像,但是如果您的大部分流量都在较旧的桌面浏览器上,则可能需要使用中等大小的图像。该sizes属性是一种表示形式的提示,它通知浏览器一旦应用 CSS,将如何在不同的场景(外部尺寸)中渲染图像。这个特定的示例说,图像将是视口的整个宽度(100vw),直到视口的宽度(min-width: 30em)达到 30 em 为止,此时图像的宽度将为 30em。您可以根据需要使sizes值。省略它会导致浏览器使用默认值 100vw。

推迟请求(如果可能)

多年前,Internet Explorer 11 引入了一个新属性,该属性使开发人员可以取消对特定 img 元素的优先级设置,以加快页面呈现速度 lazyload。从标准的角度来看,该属性从没有到过任何地方,但是它是将图像加载推迟到图像可见(或接近图像)时进行的可靠尝试,而不必涉及 JavaScript。

从那时起,已经有无数的基于 JavaScript 的延迟加载图像实现,但是最近 Google 也使用了另一个属性:采用了更具声明性的方法loading

loading属性支持三个值autolazyeager来定义资源的引入方式。出于我们的目的,lazy值是最有趣的,因为它会延迟加载资源直到它被获取焦点或到达可视的计算距离。

<img src="medium.jpg"
  srcset="small.jpg 256w,
    medium.jpg 512w,
    large.jpg 1024w"
  sizes="(min-width: 30em) 30em, 100vw"
  loading="lazy"
  alt="Descriptive text about the picture.">

此属性在基于 Chromium 的浏览器中提供了性能提升。希望它将成为标准并在将来被其他浏览器所采用,即使在目前不支持该属性的浏览器包含它也没有任何危害,因为不了解该属性的浏览器会忽略它。

Service Worker

服务工作者是 Web 工作者的一种特殊类型,能够通过 Fetch API 拦截,修改和响应所有网络请求。他们还可以访问 Cache API 以及其他异步客户端数据存储(如IndexedDB)来进行资源存储。

安装 Service Worker 后,您可以加入该事件,并为缓存提供以后要使用的资源。许多人都利用这个机会来松散全球资产的副本,包括样式,脚本,徽标等,但是您也可以使用它来缓存图像,以供网络请求失败时使用。

将后备图像保留

假设您要在多个网络配方中使用后备,可以设置一个命名函数来响应该资源:

function respondWithFallbackImage() {
  return caches.match( "/i/fallbacks/offline.svg" );
}

然后,在 fetch 事件处理程序中,可以使用该函数在网络上对图像的请求失败时提供该后备图像:

self.addEventListener( "fetch", event => {
  const request = event.request;
  if ( request.headers.get("Accept").includes("image") ) {
    event.respondWith(
      return fetch( request, { mode: 'no-cors' } )
        .then( response => {
          return response;
        })
        .catch(
          respondWithFallbackImage
        );
    );
  }
});

当网络可用时,用户将获得预期的行为:

当网络可用时呈现方式

但是当网络中断时,图像将自动交换以进行备用,并且用户体验仍然可以接受:

当网络无用时呈现方式

从表面上看,这种方法在性能方面似乎并没有太大帮助,但是,有了这个系统,您会获得一些相当的整洁。

尊重用户的选择

一些用户通过进入“精简”模式或打开“数据保护程序”功能来减少数据消耗。发生这种情况时,浏览器通常会发送带有其网络请求的Save-Data标头。

在您的 Service Worker 中,您可以查找此标头并相应地调整响应。首先,您寻找标题:

let save_data = false;
if ( 'connection' in navigator ) {
  save_data = navigator.connection.saveData;
}

然后,在fetch图像处理程序中,您可以选择先占后备图像进行响应,而不用优先占用网络:

self.addEventListener( "fetch", event => {
  const request = event.request;
  if ( request.headers.get("Accept").includes("image") ) {
    event.respondWith(
      if ( save_data ) {
        return respondWithFallbackImage();
      }
      // code you saw previously
    );
  }
});

您甚至可以更进一步,并respondWithFallbackImage()根据原始请求的内容进行调整以提供备用图像。为此,您需要在 Service Worker 中全局定义几个后备:

const fallback_avatar = "/i/fallbacks/avatar.svg",
      fallback_image = "/i/fallbacks/image.svg";

然后,应该在 Service Worker 安装事件期间缓存这两个文件:

return cache.addAll( [
  fallback_avatar,
  fallback_image
]);

最后,respondWithFallbackImage()您可以根据所获取的 UR L在内部提供适当的图像。

function respondWithFallbackImage( url ) {
  const image = avatars.test( /webmention\.io/ ) ? fallback_avatar
                                                 : fallback_image;
  return caches.match( image );
}

进行此更改后,我需要更新fetch处理程序以request.url作为的参数传入respondWithFallbackImage()。一旦完成,当网络中断时,我最终会看到类似以下内容:

包含 Save-Data 标头

确定某些媒体的优先级

网络上的媒体,尤其是图像,往往分为三类。在频谱的一端是没有增加有意义的价值的元素。在另一端是确实可以增加价值的关键资产,例如对于理解周围内容必不可少的图表。中间的某个地方是所谓的“必备”媒体。它们确实为页面的核心体验增加了价值,但对于理解内容并不关键。

如果您考虑将媒体考虑到这种划分,则可以根据情况建立一些处理每种媒体的一般准则。换句话说,一种缓存策略。

媒体加载策略,按资产对理解界面的关键程度细分:

媒体类别 快速连接 Save-Data 连接缓慢 没有网络
批判的 加载媒体 加载媒体 加载媒体 占位符
认同的 加载媒体 占位符 占位符 占位符
非关键 完全删除 完全删除 完全删除 完全删除

当要从关键特性中消除关键点的歧义时,将那些资源组织到单独的目录(或类似目录)中会很有帮助。这样,我们可以向 Service Worker 中添加一些逻辑,以帮助其确定哪个逻辑。例如,在我自己的个人网站上,关键图像要么是自托管的,要么来自云存储。可以编写与这些域匹配的正则表达式:

const high_priority = [
    /aaron\-gustafson\.com/,
    /adaptivewebdesign\.info/
  ];

使用high_priority定义的变量,我可以创建一个函数,该函数将给定的图像请求(例如)是否为高优先级请求:

function isHighPriority( url ) {
  // how many high priority links are we dealing with?
  let i = high_priority.length;
  // loop through each
  while ( i-- ) {
    // does the request URL match this regular expression?
    if ( high_priority[i].test( url ) ) {
      // yes, it’s a high priority request
      return true;
    }
  }
  // no matches, not high priority
  return false;
}

添加对媒体请求进行优先级排序的支持仅需要向fetch事件处理程序中添加一个新的条件,就像我们使用所做的那样Save-Data。您用于网络和缓存处理的特定方法可能会有所不同,这是我选择在图像请求中混入此逻辑的方式:

// Check the cache first
  // Return the cached image if we have one
  // If the image is not in the cache, continue

// Is this image high priority?
if ( isHighPriority( url ) ) {

  // Fetch the image
    // If the fetch succeeds, save a copy in the cache
    // If not, respond with an "offline" placeholder

// Not high priority
} else {

  // Should I save data?
  if ( save_data ) {

    // Respond with a "saving data" placeholder

  // Not saving data
  } else {

    // Fetch the image
      // If the fetch succeeds, save a copy in the cache
      // If not, respond with an "offline" placeholder
  }
}

我们可以将这种优先处理的方法应用于多种资产。我们甚至可以使用它来控制向哪个页面提供缓存优先与网络优先。

保持缓存整洁

控制将哪些资源缓存到磁盘的能力是一个巨大的进步,同时也承担着不滥用资源的同样巨大的责任。

每种缓存策略可能会有所不同。例如,如果我们要在线出版一本书,则可以缓存所有章节,图像等以供离线查看。内容的数量是固定的,并且假设没有大量的图像和视频,那么用户将不必从每个章节中下载内容而受益。

但是,在新闻网站上,缓存每篇文章和照片将很快使用户的硬盘充满。如果站点提供的页面和资产数量不确定,那么至关重要的是要有一个缓存策略,该策略对要缓存到磁盘上的资源有严格的限制。

一种方法是创建与缓存不同形式的内容相关联的几个不同的块。临时性内容缓存越多,可以存储的项目数就越严格。当然,我们仍然会受到设备存储限制的约束,但是我们是否真的希望我们的网站占用某人 2GB 的硬盘驱动器?

这是网站上的一个缓存示例:

const sw_caches = {
  static: {
    name: `${version}static`
  },
  images: {
    name: `${version}images`,
    limit: 75
  },
  pages: {
    name: `${version}pages`,
    limit: 5
  },
  other: {
    name: `${version}other`,
    limit: 50
  }
}

在这里,我定义了几个缓存,每个缓存都有一个name用于在 Cache API 中寻址的缓存和一个 version 前缀。将 version 服务别处定义,并允许在必要时清除所有缓存一次。

除了static用于静态资产的高速缓存外,每个高速缓存都有一个limit可以存储的项目数。例如,我仅缓存某人访问过的最近 5 页。图片仅限于最近的 75 张,依此类推。杰里米·基思(Jeremy Keith)在他的绝妙书《脱机》中概述了这种方法(如果您还没有读过的话,您应该读它)。

有了这些缓存定义后,可以定期清理缓存并修剪最旧的项目。这是杰里米(Jeremy)为这种方法推荐的代码:

function trimCache(cacheName, maxItems) {
  // Open the cache
  caches.open(cacheName)
  .then( cache => {
    // Get the keys and count them
    cache.keys()
    .then(keys => {
      // Do we have more than we should?
      if (keys.length > maxItems) {
        // Delete the oldest item and run trim again
        cache.delete(keys[0])
        .then( () => {
          trimCache(cacheName, maxItems)
        });
      }
    });
  });
}

每当加载新页面时,我们都可以触发此代码运行。通过在 Service Worker 中运行它,它可以在单独的线程中运行,并且不会降低站点的响应速度。我们通过postMessage()从 JavaScript 主线程向 Service Worke r发布一条消息(使用)来触发它:

// First check to see if you have an active service worker
if ( navigator.serviceWorker.controller ) {
  // Then add an event listener
  window.addEventListener( "load", function(){
    // Tell the service worker to clean up
    navigator.serviceWorker.controller.postMessage( "clean up" );
  });
}

进行所有连接的最后一步是设置 Service Worker 以接收消息:

addEventListener("message", messageEvent => {
  if (messageEvent.data == "clean up") {
    // loop though the caches
    for ( let key in sw_caches ) {
      // if the cache has a limit
      if ( sw_caches[key].limit !== undefined ) {
        // trim it to that limit
        trimCache( sw_caches[key].name, sw_caches[key].limit );
      }
    }
  }
});

在这里,Service Worker 侦听入站消息,并通过trimCache()在每个带有define的缓存桶上运行来响应“清理”请求limit

这种方法绝非优雅,但确实有效。根据访问每个项目的频率和/或它在磁盘上占用的空间来决定是否清除缓存的响应。(仅根据缓存的时间删除缓存的项目几乎没有用。)可悲的是,在检查缓存时,还没有那么详细的信息…但是实际上正在努力解决 Cache API 中的这一限制。

用户永远第一位

渐进式 Web 应用程序所基于的技术正在不断成熟,但是,即使您不希望将网站转变为 PWA,今天也可以做很多事情来改善用户在媒体方面的体验。

必要和多余的媒体之间进行区分。删除残留物,然后从每个剩余资产中优化 bejeus。为您的媒体提供多种格式和大小的服务,首先优先考虑最小的版本,以充分利用高延迟和慢速连接。如果您的用户说他们想保存数据,请尊重这一点并制定一个备用计划。明智地缓存,并最大程度地尊重用户的磁盘空间。最后,定期审核您的缓存策略,尤其是涉及大型媒体文件时,遵循这些准则以及每个用户的感受。