使用 Sharp、BlurHash 和 Lambda 函数实现内联图像预览

Avatar of Adam Rackis
Adam Rackis

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

您是否讨厌加载网站或 Web 应用时,某些内容会先显示,然后图像才会加载,导致内容四处移动?这被称为内容重排,并可能导致访问者体验非常糟糕。

我之前曾撰写过一篇关于使用 React 的 Suspense 来解决此问题的文章,它可以防止 UI 在图像加载完成之前加载。这解决了内容重排问题,但以牺牲性能为代价。用户在图像加载完成之前无法看到任何内容。

如果我们能够兼得:防止内容重排,同时又不让用户等待图像加载,那岂不是很好?本文将逐步介绍如何生成模糊的图像预览并立即显示它们,并在实际图像加载完成后,使用实际图像覆盖预览。

您的意思是渐进式 JPEG 吗?

您可能想知道我是否要谈论渐进式 JPEG,这是一种替代编码方式,它会导致图像最初以全尺寸模糊的方式呈现,然后随着数据的传入逐渐细化,直到所有内容都正确呈现。

这似乎是一个很好的解决方案,直到您深入了解一些细节。将您的图像重新编码为渐进式 JPEG 相对简单;有一些Sharp插件可以为您处理此操作。不幸的是,您仍然需要等待图像的一些字节通过网络传输,直到图像的模糊预览显示出来,此时您的内容将重新排版,以适应图像预览的大小。

您可能会寻找某种事件来指示图像的初始预览已加载,但目前不存在这样的事件,并且解决方法……并不理想

让我们看看两种替代方案。

我们将使用的库

在开始之前,我想说明一下我将在本文中使用的库的版本。

创建我们自己的预览

我们大多数人都习惯于使用<img />标签,并提供一个src属性,该属性指向互联网上某个位置的图像 URL。但我们也可以提供图像的Base64 编码,并将其内联设置。我们通常不希望这样做,因为这些 Base64 字符串对于图像来说可能非常大,并将它们嵌入到我们的 JavaScript 包中会导致严重的膨胀。

但是,如果我们在处理图像(调整大小、调整质量等)时,也制作一个低质量的模糊图像版本,并获取图像的 Base64 编码呢?该 Base64 图像预览的大小将大大减小。我们可以保存该预览字符串,将其放入我们的 JavaScript 包中,并在实际图像加载完成之前内联显示它。这将导致图像的模糊预览立即显示,同时图像正在加载。当实际图像加载完成后,我们可以隐藏预览并显示实际图像。

让我们看看如何实现。

生成我们的预览

现在,让我们看看Jimp,它没有依赖于node-gyp之类的东西,可以在 Lambda 中安装和使用。

这是一个函数(去除了错误处理和日志记录),它使用 Jimp 处理图像、调整图像大小,然后创建图像的模糊预览。

function resizeImage(src, maxWidth, quality) {
  return new Promise<ResizeImageResult>(res => {
    Jimp.read(src, async function (err, image) {
      if (image.bitmap.width > maxWidth) {
        image.resize(maxWidth, Jimp.AUTO);
      }
      image.quality(quality);

      const previewImage = image.clone();
      previewImage.quality(25).blur(8);
      const preview = await previewImage.getBase64Async(previewImage.getMIME());

      res({ STATUS: "success", image, preview });
    });
  });
}

在本篇文章中,我将使用Flickr Commons 提供的这张图像

Photo of the Big Boy statue holding a burger.

以下是预览的效果:

Blurry version of the Big Boy statue.

如果您想仔细查看,可以在CodeSandbox中查看相同的预览。

显然,此预览编码并不小,但我们的图像也不小;较小的图像会生成较小的预览。请根据您自己的用例进行测量和分析,以了解此解决方案的可行性。

现在,我们可以将该图像预览与实际图像 URL 和任何其他相关数据一起从我们的数据层发送。我们可以立即显示图像预览,并在实际图像加载完成后进行替换。这是一些(简化后的)React 代码来实现这一点:

const Landmark = ({ url, preview = "" }) => {
    const [loaded, setLoaded] = useState(false);
    const imgRef = useRef<HTMLImageElement>(null);
  
    useEffect(() => {
      // make sure the image src is added after the onload handler
      if (imgRef.current) {
        imgRef.current.src = url;
      }
    }, [url, imgRef, preview]);
  
    return (
      <>
        <Preview loaded={loaded} preview={preview} />
        <img
          ref={imgRef}
          onLoad={() => setTimeout(() => setLoaded(true), 3000)}
          style={{ display: loaded ? "block" : "none" }}
        />
      </>
    );
  };
  
  const Preview: FunctionComponent<LandmarkPreviewProps> = ({ preview, loaded }) => {
    if (loaded) {
      return null;
    } else if (typeof preview === "string") {
      return <img key="landmark-preview" alt="Landmark preview" src={preview} style={{ display: "block" }} />;
    } else {
      return <PreviewCanvas preview={preview} loaded={loaded} />;
    }
  };

暂时不用担心PreviewCanvas组件。也不要担心诸如 URL 更改之类的事情没有考虑在内。

请注意,我们是在onLoad处理程序之后设置图像组件的src,以确保它会触发。我们显示预览,当实际图像加载完成后,我们将它替换进来。

使用 BlurHash 优化

更新:自从撰写本文以来,我不再推荐使用 BlurHash。它需要客户端 JavaScript 和<canvas>标签来显示预览。这使得它对于基于 SSR 的 Web 框架(如 Next 和 SvelteKit)极其不友好。

相反,我建议使用plaiceholder。它使用 Sharp 作为依赖项,因此特殊的 Lambda 安装说明仍然相关。我喜欢 base64 选项,它会生成一个非常小的 base64 预览。您仍然需要像我们在文章中所做的那样跟踪实际大小,然后放大预览。在执行此操作并应用模糊滤镜后,最终结果看起来与 BlurHash 差不多。最棒的是,它完全兼容 SSR。事实上,您可以使用 CSS 将预览显示在实际图像下方。这将导致预览显示,直到实际图像加载完成。然后它接管,仅使用 HTML 和 CSS,无需任何客户端 JavaScript。

这是一个我使用 SvelteKit 编写的 Svelte 组件,用于实现这一点。此组件在服务器端运行,甚至在水合之前或 JavaScript 被禁用时,也会执行预览和替换。

查看代码
<script lang="ts">
  import type { PreviewPacket } from "$data/types";

  export let url: string | null = null;
  export let preview: string | PreviewPacket | null;

  $: previewString = preview == null ? "" : typeof preview === "string" ? preview : preview.b64;
  $: sizingStyle = preview != null && typeof preview === "object" ? `width:${preview.w}px;height:${preview.h}px` : "";
</script>

<div>
  <img alt="Book cover preview" src={previewString} style={sizingStyle} class="preview" />
  <img alt="Book cover" src={url} class="image" />
</div>

<style>
  div {
    display: inline-grid;
    grid-template-areas: "content";
    overflow: hidden;
  }
  div > * {
    grid-area: content;
  }

  .preview {
    z-index: 1;
    filter: blur(5px);
  }
  .image {
    z-index: 2;
  }
</style>

以下是本文这一部分的原始内容。


我们之前看到的图像预览可能不够小,无法与我们的 JavaScript 包一起发送。并且这些 Base64 字符串不会很好地进行 gzip 压缩。根据您拥有的图像数量,这可能是或可能不是足够好的。但是,如果您想进一步压缩内容,并且愿意做更多工作,则可以使用一个名为BlurHash的出色库。

BlurHash 使用 Base83 编码生成非常小的预览。Base83 编码允许它将更多信息压缩到更少的字节中,这是它保持预览如此小的部分原因。83 看起来像是一个任意数字,但README 对此进行了一些说明

首先,83 似乎与您可以在所有 JSON、HTML 和 Shell 中安全使用的低 ASCII 字符数量大致相同。

其次,83 * 83 非常接近于 19 * 19 * 19,并且略大于 19 * 19 * 19,使其非常适合在两个字符中编码三个 AC 分量。

README 还说明了Signal 和 Mastodon 如何使用 BlurHash

让我们看看它的实际效果。

生成blurhash预览

为此,我们需要使用 Sharp 库。


注意

要生成你的 blurhash 预览,你可能需要运行某种无服务器函数来处理你的图像并生成预览。我将使用 AWS Lambda,但任何替代方案都应该可以工作。

只需要注意最大大小限制。Sharp 安装的二进制文件会使无服务器函数的大小增加大约 9 MB。

要在 AWS Lambda 中运行此代码,你需要像这样安装库

"install-deps": "npm i && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm i --arch=x64 --platform=linux sharp"

并确保你没有进行任何捆绑以确保所有二进制文件都发送到你的 Lambda。这会影响 Lambda 部署的大小。仅 Sharp 就会占用大约 9 MB,这对冷启动时间来说不是很好。你将看到的以下代码位于一个 Lambda 中,该 Lambda 定期运行(没有任何 UI 等待它),生成 blurhash 预览。


此代码将查看图像的大小并创建一个 blurhash 预览

import { encode, isBlurhashValid } from "blurhash";
const sharp = require("sharp");

export async function getBlurhashPreview(src) {
  const image = sharp(src);
  const dimensions = await image.metadata();

  return new Promise(res => {
    const { width, height } = dimensions;

    image
      .raw()
      .ensureAlpha()
      .toBuffer((err, buffer) => {
        const blurhash = encode(new Uint8ClampedArray(buffer), width, height, 4, 4);
        if (isBlurhashValid(blurhash)) {
          return res({ blurhash, w: width, h: height });
        } else {
          return res(null);
        }
      });
  });
}

同样,为了清晰起见,我删除了所有错误处理和日志记录。值得注意的是对 ensureAlpha 的调用。这确保每个像素都有 4 个字节,每个字节分别用于 RGB 和 Alpha。

Jimp 缺少此方法,这就是我们使用 Sharp 的原因;如果有人知道其他方法,请留下评论。

另外,请注意,我们不仅保存预览字符串,还保存图像的尺寸,这在稍后会很有意义。

真正的工作发生在这里

const blurhash = encode(new Uint8ClampedArray(buffer), width, height, 4, 4);

我们正在调用 blurhash 的 encode 方法,并将我们的图像和图像的尺寸传递给它。最后两个参数是 componentX 和 componentY,根据我对文档的理解,它们似乎控制着 blurhash 对我们的图像进行多少次处理,并添加越来越多的细节。可接受的值为 1 到 9(含)。根据我自己的测试,4 是一个最佳点,可以产生最佳效果。

让我们看看这对同一张图片产生了什么

{
  "blurhash" : "UAA]{ox^0eRiO_bJjdn~9#M_=|oLIUnzxtNG",
  "w" : 276,
  "h" : 400
}

这非常小!权衡的是 使用 此预览会稍微复杂一些。

基本上,我们需要调用 blurhash 的 decode 方法并在 canvas 标签中渲染我们的图像预览。这就是 PreviewCanvas 组件之前所做的,以及为什么如果我们的预览类型不是字符串,我们会渲染它:我们的 blurhash 预览使用一个完整的对象——不仅包含预览字符串,还包含图像尺寸。

让我们看看我们的 PreviewCanvas 组件

const PreviewCanvas: FunctionComponent<CanvasPreviewProps> = ({ preview }) => {
    const canvasRef = useRef<HTMLCanvasElement>(null);
  
    useLayoutEffect(() => {
      const pixels = decode(preview.blurhash, preview.w, preview.h);
      const ctx = canvasRef.current.getContext("2d");
      const imageData = ctx.createImageData(preview.w, preview.h);
      imageData.data.set(pixels);
      ctx.putImageData(imageData, 0, 0);
    }, [preview]);
  
    return <canvas ref={canvasRef} width={preview.w} height={preview.h} />;
  };

这里没什么大不了的。我们正在解码我们的预览,然后调用一些相当具体的 Canvas API。

让我们看看图像预览是什么样子的

从某种意义上说,它比我们之前的预览细节更少。但我发现它们也更平滑,像素化程度更低。而且它们占用的空间非常小。

测试并使用最适合你的方法。

总结

有很多方法可以防止内容在你的图像在网络上加载时重排。一种方法是在图像加载进来之前阻止你的 UI 渲染。缺点是你的用户最终需要等待更长的时间才能看到内容。

一个好的折衷方案是在图像加载时立即显示图像的预览,并在加载完成后替换真实的图像。这篇文章向你介绍了实现这一目标的两种方法:使用 Sharp 等工具生成降级模糊的图像版本,以及使用 BlurHash 生成极小的 Base83 编码预览。

编码愉快!