您是否讨厌加载网站或 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 提供的这张图像。

以下是预览的效果:

如果您想仔细查看,可以在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 编码预览。
编码愉快!
你可以简单地为你的图像提供高度和宽度属性,以防止恼人的重排。
我创建了 rescript-blurhash 库。
使用它,你可以通过简单地向
<img>
元素添加data-blurhash
属性来使用 blurhash