用于加载背景图片的“模糊向上”技巧

Avatar of Emil Björklund
Emil Björklund 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费额度!

以下内容是 Emil Björklund 的客座文章。CSS 中的滤镜效果已经存在一段时间了,与混合模式等功能一起,它们为我们在浏览器中重新创建和操纵之前需要在 Photoshop 中完成的图像提供了新的可能性。在这里,Emil 探讨了一种使用更被遗忘的滤镜效果之一(即 *filter 函数*)的性能技巧,以及使用 SVG 重现该技巧。

这一切都始于 Facebook 工程团队关于如何在他们的原生应用程序中加载封面照片预览的文章。他们面临的问题是,这些“封面照片”很大,通常需要一段时间才能加载,当背景突然从纯色变为图像时,用户会体验到不理想的效果。

这种情况在连接性较差的网络或移动网络中尤其明显,在这种情况下,您往往会盯着一个空空的灰色框,等待图像下载。

理想情况下,图像应该在获取个人资料数据时被编码到应用程序的初始 API 响应中。但是,为了适应此请求,图像大小必须限制在 *200 字节* 内。这是一个难题,因为封面照片的大小超过了 100 *千字节*。

那么如何从 200 字节中获得有价值的东西,以及如何在图像完全加载之前向用户展示 *一些东西* 呢?

解决方案(巧妙无比)是返回一个非常小的图像(大约 40 像素宽),然后在应用高斯模糊的同时将该小图像放大。这会立即显示一个美观的背景,并提供封面图像外观的预览。然后,实际的封面图像可以在后台及时加载,并平滑地切换进来。真是明智之举!

这项技术有一些很酷的地方

  1. 它使得感知到的加载时间快如闪电。
  2. 它使用了一种在性能方面通常很昂贵的技术来 *提升性能*。
  3. 它可以在网络上实现。

大型标题背景图像(以及它们的性能缺陷)绝对是我们构建网络应用时可以理解的东西,因此这对我们很有用。我们可能会试图避免下载大型图像,但有时我们也会做出妥协,以达到某种效果。在这种情况下,我们能做的最好的事情就是尝试优化感知到的性能,因此我们不妨借鉴这项技术。

一个有效的示例

我们将使用类似“关键 CSS”的方法来重新创建此标题图像功能。第一个请求将加载一个很小的图像作为内联 CSS,然后高分辨率背景将在首次渲染后加载。

加载完成后,它会看起来像这样

在此示例中,我们使用了一个背景图像,将其视为装饰,而不是内容的一部分。关于何时将这类图像视为内容(因此编码为 <img>)以及何时将其视为背景图像,有一些更精细的观点需要辩论。为了使用智能大小模式(例如 CSS 值 covercontain),背景图像可能是此类设计的常见解决方案,但像 object-fit 这样的新属性使得对内容图像采用相同方法变得更容易。像 Medium 这样的网站已经使用模糊的内容图像来改善加载时间,但这种技巧的有效性存在争议——如果加载技术失败,模糊的图像是否还有用?总之:在本文中,我们将重点介绍适用于背景图像的这种技术。

以下是工作原理的概要

  1. 将一个很小的图像预览(40×22 像素)作为 base64 编码的背景图像内联到 <style> 标签中。style 标签还包含通用样式和对背景图像应用高斯模糊的规则。最后,它还包含针对较大版本标题图像的样式,并作用域到不同的类名。
  2. 从内联 CSS 获取大型图像的 URL,并使用 JavaScript 预加载它。如果脚本由于某种原因失败,也不会有问题——模糊的背景图像仍然存在,看起来很酷。
  3. 当大型图像加载完成后,添加一个类名,该类名会切换 CSS 以使用大型图像作为背景,同时移除模糊效果。希望可以将移除模糊的效果动画化。

您可以在 Pen 中找到最终示例。您可能会看到在更清晰的图像加载之前短暂显示模糊的图像。如果没有,请尝试清空缓存后重新加载页面。

一个很小且经过优化的图像

首先,我们需要一个图像的预览版本。Facebook 通过压缩魔法(例如将不变的 JPEG 头部位存储在应用程序中)将他们的图像大小缩减到 200 字节,但我们无法达到这种程度。对于大小为 40×22 像素的图像,在经过一些图像优化软件处理后,该图像的大小约为 1000 字节。

完整大小的 JPEG 图像大小约为 120Kb,分辨率为 1500×823 像素。该文件大小可能可以更小,但我们将保持原样,因为它只是一个概念验证。在实际应用中,您可能会有几个图像尺寸变体,并根据视窗大小加载不同的尺寸——甚至可能加载不同的格式,比如 WebP。

用于图像的 filter 函数

接下来,我们要将小图像放大以覆盖元素,但我们不希望它看起来像素化且难看。这就是 filter() 函数发挥作用的地方。CSS 中的滤镜可能看起来有点让人困惑,因为实际上存在三种类型的滤镜:filter 属性、它提出的 backdrop-filter 对等体(在 Filter Effects Level 2 规范 中),以及用于图像的 filter() 函数。让我们先看看 *属性*。

.myThing {
  filter: hue-rotate(45deg);
}

将应用一个或多个滤镜,每个滤镜都作用于之前滤镜的结果——非常像一个转换列表。我们有一系列预定义的滤镜 可供使用:blur()brightness()contrast()drop-shadow()grayscale()hue-rotate()invert()opacity()sepia()saturate()

更酷的是,这是一个 CSS 和 SVG 共享的规范,因此不仅在 SVG 中对预定义滤镜进行了规范,我们还可以创建自己的 SVG 滤镜并从 CSS 中引用它们。

.myThing {
  filter: url(myfilter.svg#myCustomFilter);
}

相同的滤镜效果在 backdrop-filter 中也是有效的,在将透明元素与其背景合成时应用这些效果——这对于创建 “磨砂玻璃”效果 可能最有帮助。

最后,还有用于图像值的 filter() 函数。想法是在任何可以引用 CSS 中图像的地方,您也应该能够将其通过一系列滤镜。对于小型标题图像,我们将其作为 base64 数据 URI 内联,并将其通过 blur() 滤镜。

.post-header {
  background-image: filter(url(data:image/jpeg;base64,/9j/4AAQ ...[truncated] ...), blur(20px));
}

这很棒,因为这正是我们在重新创建 Facebook 应用程序中的技术时所需要的!但在支持方面有一个坏消息。filter *属性* 在除了 IE 之外的所有最新浏览器版本中都受支持,但 除了 WebKit 之外,没有其他浏览器 实现规范的 filter() *函数* 部分。

在这里我所说的 WebKit 是指撰写本文时 WebKit 的夜间构建版本,而不是 Safari。用于图像的 filter 函数 *确实* 在 iOS9 中以 -webkit-filter() 的形式存在,但我能找到的官方资料中都没有报道过这一点,这有点奇怪。原因可能是它与 background-size 存在一个非常糟糕的 错误:不会调整原始图像的大小,但会调整过滤输出的平铺大小。这会严重破坏背景图像功能,尤其是在模糊的情况下。该错误已修复,但没有及时修复以使其进入 Safari 9 版本,因此我猜他们不想宣布该功能。

但是对于缺少或损坏的 filter() 功能,我们该怎么办呢?我们可以让不支持该功能的浏览器使用纯色背景,直到图像加载完成,但这意味着如果 JS 无法加载,他们将根本无法看到背景。太无聊了!

不,我们将 filter() 函数保存为以后对替换后的图像进行动画处理的额外调味料,而是使用 SVG 模拟初始图像的滤镜函数。

使用 SVG 重新创建模糊滤镜

由于规范方便地提供了一个 用于 blur() 滤镜的 SVG 等效项,因此我们可以通过一些调整,使用 SVG 重新创建模糊滤镜的工作原理。

  • 应用高斯模糊后,边缘会变得有点半透明。我们可以通过添加一个名为 feComponentTransfer 的滤镜来修复这个问题。组件转换允许您操纵源图形的每个颜色通道(包括 alpha)。这种特殊的变体使用 feFuncA 元素,它将 alpha 通道中 01 之间的任何值映射到 1,这意味着它会移除任何 alpha 透明度。
  • <filter> 元素上的 color-interpolation-filters 属性必须设置为 sRGB。SVG 滤镜默认使用 linearRGB 色彩空间,而 CSS 使用 sRGB。大多数浏览器似乎能够正确处理颜色校正,但除非设置此值,否则 Safari/WebKit 会使所有颜色都变淡。
  • filterUnits 设置为 userSpaceOnUse,简单来说,这意味着坐标和长度(比如模糊的 stdDeviation)会映射到我们应用模糊的元素的像素上。

生成的 SVG 代码看起来像这样

<filter id="blur" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
    <feGaussianBlur stdDeviation="20" edgeMode="duplicate" />
    <feComponentTransfer>
      <feFuncA type="discrete" tableValues="1 1" />
    </feComponentTransfer>
</filter>

filter 属性使用它自己的 url() 函数,我们可以使用它来引用或 URI 编码 SVG 过滤器。那么,我们如何将过滤器应用到 background-image: url(...) 中的内容呢?

好吧,SVG 文件可以指向其他图像,并且我们可以将过滤器应用到 SVG 中的这些图像。问题是,SVG 背景图像无法获取任何外部资源。但是,我们可以通过在 SVG 中对 JPG 进行 base64 编码来解决这个问题。对于大型图像来说,这不可行,但对于我们的小图像来说应该没问题。SVG 将看起来像这样

<svg xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink"
     width="1500" height="823"
     viewBox="0 0 1500 823">
  <filter id="blur" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
    <feGaussianBlur stdDeviation="20 20" edgeMode="duplicate" />
    <feComponentTransfer>
      <feFuncA type="discrete" tableValues="1 1" />
    </feComponentTransfer>
  </filter>
  <image filter="url(#blur)"
         xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJ ...[truncated]..."
         x="0" y="0"
         height="100%" width="100%"/>
</svg>

另一个缺点(与直接使用 filter() 函数和位图相比)是我们需要手动设置一些大小,才能使 SVG 与背景大小配合得当。SVG 本身有一个 viewBox,设置为模拟图像的纵横比,并且 widthheight 属性设置为相同的测量值,以确保它在跨浏览器中都能正常工作(例如,如果缺少这些属性,IE 会搞乱纵横比)。最后,<image> 元素设置为覆盖整个 SVG 画布。

现在,我们可以将此文件用作文章标题的背景,它将看起来像这样

最后一步,我们可以将 SVG 包装器图像内联到 CSS 中,以避免额外的请求。内联 SVG 需要进行 URI 编码,我使用 yoksel 的 SVG 编码器 来完成此操作。所以,现在我们有一个包含另一个 dataURI 的 dataURI。DataURInception!

在编码 SVG 时,我们会得到一些要粘贴到 url() 中的文本,但需要注意的是,我们需要在前面添加一些元数据才能使其显示:data:image/svg+xml;charset=utf-8,charset 部分很重要:它使编码的 SVG 在跨浏览器中都能正常工作。

.post-header {
  background-color: #567DA7;
  background-size: cover;
  background-image: url(data:image/svg+xml;charset=utf-8,%3Csvg...);
}

此时,整个页面(包括图像)在使用 GZIP 时只有 1 个请求和 5KB

获取大型图像的 URL

接下来,我们为增强型标题创建一个规则,在其中设置大型背景图像。

.post-header-enhanced {
  background-image: url(largeimg.jpg);
}

我们不希望仅仅切换类名,从而触发大型图像加载,而是希望预加载大型图像,然后才应用类名。这样,我们就可以在以后平滑地动画切换,并合理地确保大型图像已完成加载。由于我们不想在 CSS 和 JavaScript 中都硬编码图像 URL,因此我们将使用 JavaScript 从样式中获取 URL。由于类名尚未应用,因此我们不能只查看 headerElement.style.backgroundImage 等 - 它不知道背景是什么。为了解决这个问题,我们将使用 CSSOM - CSS 对象模型,以及允许我们遍历 CSS 规则的只读 JS 属性。

以下代码片段会找到增强型标题的类名,然后使用一些正则表达式获取 URL。之后,它会预加载图像,并在图像加载完成后触发添加的类名。

<script>
window.onload = function loadStuff() {
  var win, doc, img, header, enhancedClass;
  
  // Quit early if older browser (e.g. IE 8).
  if (!('addEventListener' in window)) {
    return;
  }
  
  win = window;
  doc = win.document;
  img = new Image();
  header = doc.querySelector('.post-header');
  enhancedClass = 'post-header-enhanced';

  // Rather convoluted, but parses out the first mention of a background
  // image url for the enhanced header, even if the style is not applied.
  var bigSrc = (function () {
    // Find all of the CssRule objects inside the inline stylesheet 
    var styles = doc.querySelector('style').sheet.cssRules;
    // Fetch the background-image declaration...
    var bgDecl = (function () {
      // ...via a self-executing function, where a loop is run
      var bgStyle, i, l = styles.length;
      for (i=0; i<l; i++) {
        // ...checking if the rule is the one targeting the
        // enhanced header.
        if (styles[i].selectorText &&
            styles[i].selectorText == '.'+enhancedClass) {
          // If so, set bgDecl to the entire background-image
          // value of that rule
          bgStyle = styles[i].style.backgroundImage;
          // ...and break the loop.
          break; 
        }
      }
      // ...and return that text.
      return bgStyle;
    }());
    // Finally, return a match for the URL inside the background-image
    // by using a fancy regex I Googled up, as long as the bgDecl 
    // variable is assigned at all.         
    return bgDecl && bgDecl.match(/(?:\(['|"]?)(.*?)(?:['|"]?\))/)[1];
  }());

  // Assign an onLoad handler to the dummy image *before* assigning the src
  img.onload = function () {
    header.className += ' ' +enhancedClass;
  };
  // Finally, trigger the whole preloading chain by giving the dummy
  // image its source.
  if (bigSrc) {
    img.src = bigSrc;
  }
};
</script>

如果 addEventListener 不受支持,脚本会提前退出,这应该与所需的其余支持很好地重叠。据我所知,所有合理现代的 SVG 支持浏览器都支持 CSSOM 和其他使用的 JavaScript 功能。

动画切换

我们没有能够使用 filter() 函数,这有点可惜,因为我们已经发现了它的存在。所以,我们将添加一个动画效果,在切换到高分辨率图像时使用。目前,这只有在 WebKit nightly 版本中才有效,我们可以安全地使用 @supports 规则来限制更改范围。这是一个动画 GIF,展示了该效果的实际操作

注意,我们不能为此使用 transitionfilter() 函数是可以动画化的,但仅限于更改过滤器链中的值 - 当背景图像更改时,我们就会遇到问题。但是,我们可以为此使用动画,但这意味着我们需要将背景图像的 URL 重复两次,作为起始值和结束值。这只是一个小小的代价。

以下是支持 filter() 函数的浏览器的增强型标题样式的 CSS 代码

@supports (background-image: filter(url('i.jpg'), blur(1px))) {
  .post-header {
    transform: translateZ(0);
  }
  .post-header-enhanced {
    animation: sharpen .5s both;
  }
  @keyframes sharpen {
    from {
      background-image: filter(largeimg.jpg), blur(20px));
    }
    to {
      background-image: filter(largeimg.jpg), blur(0px));
    }
  }
}

最后一个细节是标题上的 translateZ(0) 技巧:没有它,动画会非常卡顿。我尝试使用所有现代方法,并使用了 will-change: background-image,但这并没有说服浏览器创建硬件支持的层,因此我不得不使用旧的技巧,即 添加一个 3D "空变换"

快速、渐进增强型背景图像

我们已经得到了一个带有巨大背景图像(虽然是模糊的)的页面,它在 5KB 内加载,并延迟加载清晰的完整尺寸图像。目前,只有 WebKit 可以为更清晰的图像进行动画处理,但我希望其他浏览器也能很快实现 filter() 函数。我相信我们可以使用它来实现更多有趣的技巧。