用Sharp、BlurHash和Lambda函数进行内联图像预览

658 阅读9分钟

当你加载一个网站或网络应用时,一些内容被显示出来*,然后*一些图像被加载--导致内容左右移动,你不讨厌这样吗?这就是所谓的内容回流并会给访问者带来非常恼人的用户体验。

我以前写过关于用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改进事情

我们之前看到的图片预览可能不够小,不能和我们的JavaScript捆绑包一起发送。而且这些 Base64 字符串也不能很好地压缩。取决于你有多少这样的图片,这可能是也可能是不够好的。但是,如果你想把东西压缩得更小,而且你愿意做更多的工作,有一个很棒的库,叫做BlurHash

BlurHash使用Base83编码生成了令人难以置信的小预览。Base83编码允许它将更多的信息压缩到更少的字节中,这也是它如何保持预览如此小的部分原因。83可能看起来是一个任意的数字,但README对此有一些说明

首先,83似乎是关于你能找到多少低ASCII字符,可以安全地用于所有的JSON、HTML和外壳。

其次,83*83非常接近,也比19*19*19多一点,使其成为在两个字符中编码三个交流成分的理想选择。

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

让我们看看它的运行情况。

生成blurhash 预览

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


注意

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

只是要注意最大尺寸的限制。夏普安装的二进制文件使无服务器函数的大小增加了约9MB。

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

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

并确保你没有做任何形式的捆绑,以确保所有的二进制文件被发送到你的Lambda。这将影响Lambda部署的大小。仅仅是夏普就会达到9MB左右,这对冷启动时间来说不是很好。你将在下面看到的代码是在一个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'sencode 方法,把我们的图像和图像的尺寸传给它。最后两个参数是componentXcomponentY ,根据我对文档的理解,这两个参数似乎是控制blurhash 在我们的图像上做多少次,增加越来越多的细节。可接受的值是1到9(包括)。从我自己的测试来看,4是一个能产生最佳效果的甜蜜点。

让我们看看这对同一图像产生了什么。

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

这是令人难以置信的小!弊端是,使用这个预览的过程有点复杂。

基本上,我们需要调用blurhash'sdecode 方法并在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 APIs。

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

从某种意义上说,它没有我们之前的预览那么详细。但我也发现它们更光滑一些,像素化程度更低。而且它们所占的尺寸很小。

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

收尾工作

有许多方法可以防止你的图片在网络上加载时出现内容回流。一种方法是防止你的用户界面在图片进来之前呈现。其缺点是,你的用户需要等待更多的内容。

一个好的中间方法是立即显示图片的预览,并在图片加载后将其换成真实的图片。这篇文章向你介绍了实现这一目标的两种方法:使用Sharp这样的工具生成退化的、模糊的图像版本,以及使用BlurHash生成一个极小的、Base83编码的预览。

编码愉快!