使用嵌入式图像预览更快地加载图像
已发表: 2022-03-10低质量图像预览 (LQIP) 和基于 SVG 的变体 SQIP 是延迟图像加载的两种主要技术。 两者的共同点是您首先生成低质量的预览图像。 这将显示模糊,稍后被原始图像替换。 如果您可以向网站访问者展示预览图像而无需加载其他数据会怎样?
大多数使用延迟加载的 JPEG 文件有可能根据规范以先显示粗略然后显示详细图像内容的方式存储其中包含的数据。 不是在加载过程中从上到下构建图像(基线模式),而是可以非常快速地显示模糊的图像,逐渐变得越来越清晰(渐进模式)。
除了通过更快地显示的外观提供更好的用户体验之外,渐进式 JPEG 通常也比其基线编码的对应物小。 根据 Yahoo 开发团队的 Stoyan Stefanov 的说法,对于大于 10 kB 的文件,当使用渐进模式时,有 94% 的可能性是较小的图像。
如果您的网站包含许多 JPEG,您会注意到即使是渐进式 JPEG 也会一个接一个地加载。 这是因为现代浏览器只允许六个同时连接到一个域。 因此,仅靠渐进式 JPEG 并不是给用户最快的页面印象的解决方案。 在最坏的情况下,浏览器会在开始加载下一张图片之前完全加载一张图片。
此处提出的想法现在是从服务器仅加载这么多字节的渐进式 JPEG,以便您可以快速获得图像内容的印象。 稍后,在我们定义的时间(例如,当当前视口中的所有预览图像都已加载时),应加载图像的其余部分,而无需再次请求已请求预览的部分。
不幸的是,您无法告诉属性中的img
标记应该在什么时间加载多少图像。 但是,使用 Ajax,这是可能的,前提是提供图像的服务器支持 HTTP 范围请求。
使用 HTTP 范围请求,客户端可以在 HTTP 请求标头中通知服务器,请求文件的哪些字节将包含在 HTTP 响应中。 每个较大的服务器(Apache、IIS、nginx)都支持此功能,主要用于视频播放。 如果用户跳到视频的结尾,那么在用户最终看到想要的部分之前加载完整的视频不是很有效。 因此,服务器只请求用户请求的时间前后的视频数据,以便用户尽可能快地观看视频。
我们现在面临以下三个挑战:
- 创建渐进式 JPEG
- 确定第一个 HTTP 范围请求必须加载预览图像的字节偏移量
- 创建前端 JavaScript 代码
1. 创建渐进式 JPEG
渐进式 JPEG 由几个所谓的扫描段组成,每个扫描段都包含最终图像的一部分。 第一次扫描仅非常粗略地显示图像,而文件后面的扫描将越来越详细的信息添加到已加载的数据中,最终形成最终外观。
单个扫描的外观如何由生成 JPEG 的程序确定。 在mozjpeg项目中的 cjpeg 等命令行程序中,您甚至可以定义这些扫描包含哪些数据。 但是,这需要更深入的知识,这超出了本文的范围。 为此,我想参考我的文章“终于了解 JPG”,它讲授了 JPEG 压缩的基础知识。 mozjpeg 项目的wizard.txt 中解释了必须在扫描脚本中传递给程序的确切参数。 在我看来,mozjpeg 默认使用的扫描脚本(七次扫描)的参数是快速渐进结构和文件大小之间的一个很好的折衷,因此可以采用。
为了将我们的初始 JPEG 转换为渐进式 JPEG,我们使用了jpegtran
项目中的 jpegtran。 这是对现有 JPEG 进行无损更改的工具。 可在此处获得适用于 Windows 和 Linux 的预编译版本:https://mozjpeg.codelove.de/binaries.html。 如果您出于安全原因更喜欢安全地使用它,最好自己构建它们。
我们现在从命令行创建渐进式 JPEG:
$ jpegtran input.jpg > progressive.jpg
我们想要构建渐进式 JPEG 的事实由 jpegtran 假设,不需要明确指定。 图像数据不会以任何方式更改。 仅更改文件内图像数据的排列。
与图像外观无关的元数据(例如 Exif、IPTC 或 XMP 数据)最好从 JPEG 中删除,因为只有在图像内容之前,元数据解码器才能读取相应的片段。 由于这个原因我们不能将它们移动到文件中的图像数据后面,它们已经与预览图像一起交付并相应地放大第一个请求。 使用命令行程序exiftool
,您可以轻松删除这些元数据:
$ exiftool -all= progressive.jpg
如果您不想使用命令行工具,您还可以使用在线压缩服务 compress-or-die.com 生成没有元数据的渐进式 JPEG。
2. 确定第一个 HTTP 范围请求必须加载预览图像的字节偏移量
JPEG 文件分为不同的段,每个段包含不同的组件(图像数据、IPTC、Exif 和 XMP 等元数据、嵌入的颜色配置文件、量化表等)。 这些段中的每一个都以十六进制FF
字节引入的标记开始。 后面跟着一个字节,指示段的类型。 例如, D8
将标记完成到 SOI 标记FF D8
(图像开始),每个 JPEG 文件都以此开始。
每次扫描的开始都由 SOS 标记(扫描开始,十六进制FF DA
)标记。 由于 SOS 标记后面的数据是熵编码的(JPEG 使用 Huffman 编码),因此在 SOS 段之前还有另一个段需要解码,其中包含 Huffman 表(DHT,十六进制FF C4
)。 因此,我们在渐进式 JPEG 文件中感兴趣的区域由交替的霍夫曼表/扫描数据段组成。 因此,如果我们想要显示图像的第一次非常粗略的扫描,我们必须从服务器请求直到第二次出现 DHT 段(十六进制FF C4
)的所有字节。
在 PHP 中,我们可以使用以下代码将所有扫描所需的字节数读入一个数组:
<?php $img = "progressive.jpg"; $jpgdata = file_get_contents($img); $positions = []; $offset = 0; while ($pos = strpos($jpgdata, "\xFF\xC4", $offset)) { $positions[] = $pos+2; $offset = $pos+2; }
我们必须将值 2 添加到找到的位置,因为浏览器仅在遇到新标记时才呈现预览图像的最后一行(它由刚才提到的两个字节组成)。
由于我们对本示例中的第一张预览图像感兴趣,因此我们在$positions[1]
中找到了正确的位置,我们必须通过 HTTP 范围请求来请求文件。 要请求具有更好分辨率的图像,我们可以使用数组中较晚的位置,例如$positions[3]
。
3. 创建前端 JavaScript 代码
首先,我们定义一个img
标签,我们给它刚刚评估的字节位置:
<img data-src="progressive.jpg" data-bytes="<?= $positions[1] ?>">
与延迟加载库的常见情况一样,我们不直接定义src
属性,这样浏览器在解析 HTML 代码时不会立即开始从服务器请求图像。
使用以下 JavaScript 代码,我们现在加载预览图像:
var $img = document.querySelector("img[data-src]"); var URL = window.URL || window.webkitURL; var xhr = new XMLHttpRequest(); xhr.onload = function(){ if (this.status === 206){ $img.src_part = this.response; $img.src = URL.createObjectURL(this.response); } } xhr.open('GET', $img.getAttribute('data-src')); xhr.setRequestHeader("Range", "bytes=0-" + $img.getAttribute('data-bytes')); xhr.responseType = 'blob'; xhr.send();
此代码创建一个 Ajax 请求,该请求在 HTTP 范围标头中告诉服务器将文件从开头返回到data-bytes
中指定的位置……仅此而已。 如果服务器理解 HTTP 范围请求,它会在 HTTP-206 响应(HTTP 206 = 部分内容)中以 blob 的形式返回二进制图像数据,我们可以使用createObjectURL
从中生成浏览器内部 URL。 我们将此 URL 用作img
标签的src
。 因此,我们已经加载了预览图像。
我们将 blob 额外存储在属性src_part
中的 DOM 对象中,因为我们将立即需要这些数据。
在开发者控制台的网络选项卡中,您可以检查我们没有加载完整的图像,而只是加载了一小部分。 此外,blob URL 的加载应该以 0 字节的大小显示。
由于我们已经加载了原始文件的 JPEG 标头,因此预览图像具有正确的大小。 因此,根据应用程序,我们可以省略img
标签的高度和宽度。
替代方案:内联加载预览图像
出于性能原因,也可以直接在 HTML 源代码中将预览图像的数据作为数据 URI 传输。 这为我们节省了传输 HTTP 标头的开销,但 base64 编码使图像数据增大了三分之一。 如果您使用gzip或brotli等内容编码交付 HTML 代码,这是相对化的,但您仍应将数据 URI 用于小型预览图像。
更重要的是预览图像立即可用,并且用户在构建页面时没有明显的延迟。
首先,我们必须创建数据 URI,然后在img
标记中将其用作src
。 为此,我们通过 PHP 创建数据 URI,此代码基于刚刚创建的代码,它确定了 SOS 标记的字节偏移量:
<?php … $fp = fopen($img, 'r'); $data_uri = 'data:image/jpeg;base64,'. base64_encode(fread($fp, $positions[1])); fclose($fp);
创建的数据 URI 现在直接作为src
插入到 `img` 标记中:
<img src="<?= $data_uri ?>" data-src="progressive.jpg" alt="">
当然,JavaScript 代码也必须进行适配:
<script> var $img = document.querySelector("img[data-src]"); var binary = atob($img.src.slice(23)); var n = binary.length; var view = new Uint8Array(n); while(n--) { view[n] = binary.charCodeAt(n); } $img.src_part = new Blob([view], { type: 'image/jpeg' }); $img.setAttribute('data-bytes', $img.src_part.size - 1); </script>
不是通过 Ajax 请求来请求数据,在这种情况下我们会立即收到一个 blob,在这种情况下,我们必须自己从数据 URI 中创建 blob。 为此,我们从不包含图像数据的部分中释放 data-URI: data:image/jpeg;base64
。 我们使用atob
命令解码剩余的 base64 编码数据。 为了从现在的二进制字符串数据创建 blob,我们必须将数据传输到 Uint8 数组中,以确保数据不会被视为 UTF-8 编码文本。 从这个数组中,我们现在可以使用预览图像的图像数据创建一个二进制 blob。
因此我们不必为这个内联版本修改以下代码,我们在img
标记上添加属性data-bytes
,在前面的示例中包含必须加载图像第二部分的字节偏移量.
在开发者控制台的网络选项卡中,您还可以在这里查看加载预览图像不会产生额外的请求,而 HTML 页面的文件大小有所增加。
加载最终图像
在第二步中,我们以两秒后加载图像文件的其余部分为例:
setTimeout(function(){ var xhr = new XMLHttpRequest(); xhr.onload = function(){ if (this.status === 206){ var blob = new Blob([$img.src_part, this.response], { type: 'image/jpeg'} ); $img.src = URL.createObjectURL(blob); } } xhr.open('GET', $img.getAttribute('data-src')); xhr.setRequestHeader("Range", "bytes="+ (parseInt($img.getAttribute('data-bytes'), 10)+1) +'-'); xhr.responseType = 'blob'; xhr.send(); }, 2000);
这次我们在 Range 头中指定我们要请求从预览图的结束位置到文件末尾的图像。 第一个请求的答案存储在 DOM 对象的属性src_part
中。 我们使用两个请求的响应为每个new Blob()
创建一个新的 Blob,其中包含整个图像的数据。 由此生成的 blob URL 再次用作 DOM 对象的src
。 现在图像已完全加载。
此外,现在我们可以再次在开发者控制台的网络选项卡中检查加载的大小..
原型
在以下 URL 中,我提供了一个原型,您可以在其中试验不同的参数:https://embedded-image-preview.cerdmann.com/prototype/
可以在此处找到原型的 GitHub 存储库:https://github.com/McSodbrenner/embedded-image-preview
最后的考虑
使用此处介绍的嵌入式图像预览 (EIP) 技术,我们可以在 Ajax 和 HTTP 范围请求的帮助下从渐进式 JPEG 加载质量不同的预览图像。 这些预览图像中的数据不会被丢弃,而是重新用于显示整个图像。
此外,无需创建预览图像。 在服务器端,只需确定并保存预览图像结束的字节偏移量。 在 CMS 系统中,应该可以将此数字保存为图像上的属性,并在img
标签中输出时将其考虑在内。 甚至可以设想工作流,其通过偏移量补充图片的文件名,例如progressive-8343.jpg ,以便不必将偏移量与图片文件分开保存。 这个偏移量可以由 JavaScript 代码提取。
由于预览图像数据被重用,这种技术可能是加载预览图像然后是 WebP(并为不支持 WebP 的浏览器提供 JPEG 后备)的通常方法的更好替代方法。 预览图往往会破坏 WebP 的存储优势,不支持渐进模式。
目前,普通 LQIP 中的预览图像质量较差,因为假设加载预览数据需要额外的带宽。 正如 Robin Osborne 在 2018 年的一篇博文中已经明确指出的那样,显示无法让您了解最终图像的占位符没有多大意义。 通过使用此处建议的技术,我们可以毫不犹豫地向用户展示渐进式 JPEG 的后续扫描,从而将更多最终图像显示为预览图像。
如果用户的网络连接较弱,根据应用程序的不同,不加载整个 JPEG,但例如省略最后两次扫描可能是有意义的。 这会产生一个小得多的 JPEG,其质量只会略微降低。 用户会感谢我们,我们不必在服务器上存储额外的文件。
现在,我希望您在试用原型时玩得开心,并期待您的评论。