Sukka's Blog

童话只美在真实却从不续写

图片 lazyload 的学问和在 Hexo 上的最佳实践

Sukka's Avatar 2019-08-04 笔记本

  1. 1. SEO 和 RSS 阅读器
  2. 2. 布局抖动,占位图和宽高比盒子
  3. 3. 图片 lazyload 在 Hexo 上的最佳实践

lazyload 已经是一个前端性能优化的老生常谈的话题了。让图片不要和其它关键资源争抢带宽和性能早在雅虎军规中就有所涉及。其实任何资源包括媒体资源、DOM 节点、非关键 CSS 和 JS,都可以做 lazyload,不必多谈。不过即使简单地 lazyload 图片,也有很多值得深究的学问。

SEO 和 RSS 阅读器

为图片设置 lazyload 非常简单。<img> 标签不设置 src 属性,而将图片的 URL 扔在 data-src 或者类似的属性里,利用 JS 当页面滚动到位置的时候动态地为 <img> 标签添加 src 属性。就和听起来那样简单,不是嘛?

注意,宁肯不做占位小图直接不要 src 属性也不可以留一个空的 src。因为一个空的 src 属性会使大部分现代的浏览器向页面本身产生一个请求(RFC3986)。

先让我们穿越时空,回到互联网的远古时代。在那个爬虫们和 curl 还没有什么区别的时候,如果 <img> 标签没有 src 属性,爬虫就不会意识到这里有一张图片。
在那个默认关闭 JavaScript 的 Opera 浏览器还在大行其道的时代,大家都知道要使用 <noscript> 包裹一个带 src 属性的 <img> 标签做 fallback。所以爬虫还是能够意识到这里有张图片的,所以这不是什么问题。

让我们回到现代。搜索引擎的爬虫已经不再是简单的 curl 了。现在搜索引擎爬虫自带 JavaScript 解析器,Google 的爬虫现在甚至用起了 Headless Chrome。目前至少 Google 已经支持索引 lazyload 的图片了
但对于 RSS 阅读器来说,我们又回到了原点。没有 src 属性的 <img> 标签不会显示,不同的 RSS 阅读器对于 <noscript> 的行为也不可预测。

不过在响应式图片和 srcset 属性出现以后,这也不再是什么问题了。下面直接抛出解决代码。

<img src="original.jpg"
srcset="thumbnail.jpg"
data-srcset="original.jpg">

虽然 <img> 标签定义了 src 属性,但是由于有 srcset 属性,所以现代浏览器会优先使用 srcset 的属性加载缩略图 thumbnail.jpg (如果愿意,你也可以使用空白小图 blank.gif)。同时负责 lazyload 的 JS 会在页面滚动的时候将 data-srcset 属性赋值给 srcset 展示原图。而在不支持 srcset 的老旧浏览器或者 RSS 阅读器上srcset 属性都会被忽略、直接读取 src 加载图片。

使用 srcset 属性的好处还有更多,比如对响应式设计友好、支持 WebP 图片及其 Fallback、对那些 curl 爬虫也很友好。
srcset 这个属性的关键之处在于它的优先级比 src 属性要高,这就是为什么我们可以在不对 src 属性动刀的前提下还可以做到不立刻加载原图。

当然,这么美妙的设计肯定不是我能想得出来的。这个方案是我在 h404bi 的博客 「结合响应式图片实现更好的图片懒加载方案」里看到的,而他则是在 responsively-lazy 这个 lib 的 README 里看到的。

你可以在 这里 查看一个使用 srcset 和 vanilla-lazyload 的 lazyload 的 demo。

布局抖动,占位图和宽高比盒子

图片 lazyload 引发的问题是布局抖动——浏览器并不能预先知道图片的大小并给出占位,在图片加载成功之后会引发布局抖动。

img/lazy-img-without-placeholder.gif

在不考虑页面 reflow 的性能消耗的前提下,布局抖动的负面影响主要集中在 UX 方面。

如果预先设置好图片的大小,或者加载相同大小的占位图可以解决这一个问题。但是即使占位图体积很小,也有可能在网络或性能较差的设备上加载缓慢从而引发抖动。如果使用 data URI 内联输出小图则带来缓存问题,这个也不多谈。预先给图片设置宽高属性的方法在响应式设计中也并不合适。实际上,许多文章 都提到了创建 Aspect Ratio Boxes 响应式的容器的方法:

<div class="lazy-image-wrapper">
<img class="lazy-image" src="images/original.jpg">
</div>
.lazy-image-wrapper {
width: 90%;
height: 0;
padding-bottom: 66.67%;
border: 2px solid white;
position: relative;
}
.lazy-image {
width: 100%;
position: absolute;
}

不论是直接显性指定图片的 widthheight 还是响应式的长宽比盒子,页面布局就都不会抖动了。

img/lazy-img-with-placeholder.gif

图片 lazyload 在 Hexo 上的最佳实践

如果直接在搜索引擎上搜索「Hexo Lazyload」大概能看到这些搜索结果:两三个 Hexo 插件,还有一堆教你怎么在 Hexo 里引入 jQueryjQuery.lazyload (而且是从百度公共库引用文件)的神奇教程。
大部分 Hexo 的 lazyload 的插件都没有使用 srcset 的方法、并且还“十分贴心地”引入额外的 lazyload 库。但是,我的 Hexo 主题是自己写的,我自己非常清楚主题已经包含了 vanilla-lazyload 的库,不需要再额外引入了。

既然现有的插件都不能满足我的需求,我只好自己实现一个 lazyload 的 helper 了。

首先是图片占位问题。因为我插入博客的图片大小不一且和博客分开放置,因此不能简单的读取每一张图片并计算大小、也不能直接使用宽高比盒子。我投机取巧的方法就是算出我博客中所有图片的长宽比例求出平均值:

#!/bin/bash

identify **/*.png **/*.jpg **/*.gif **/*.jpeg | grep -oP '\d+x\d+(?=\+)' > ./tmp.list

sum=0
num=$(awk '{print NR}' ./tmp.list | tail -n1)

while read SIZE
do
arrsize=($(echo $SIZE | sed 's|x|\n|g'))
x=${arrsize[0]}
y=${arrsize[1]}
i=$(echo | awk "{print $y/$x}")
tmp=$(echo | awk "{print $sum+$i}")
sum=${tmp}
done < ./tmp.list

rm -rf ./tmp.list
echo | awk "{print $sum/$num}"

求出来的宽高比是 0.52286。意味着我的占位空间的宽高比就定为 52.286% 了。

接着我在主题的 layout 文件中设置了一段样式,使用 padding-bottom<img> 标签的图片在加载成功之前先占据一部分高度、并用 filtertransition 实现一个从模糊到清晰的效果:

article img.lazyload  {
transition: filter .35s ease 0s; //
}
article img.lazyload:not(.loaded) {
-webkit-filter: blur(5px);
filter: blur(5px);
}
article img.lazyload:not(.loaded):not(.loading) {
padding-bottom: 52.286%;
}

接下来在 theme/script 下面写一个 lazyload 的 helper:

// theme/script/lazyload/index.js

'use strict';
if (!hexo.config.lazyload || !hexo.config.lazyload.enable) {
return;
}
if (hexo.config.lazyload.onlypost) {
hexo.extend.filter.register('after_post_render', require('./lib/process').processPost);
} else {
hexo.extend.filter.register('after_render:html', require('./lib/process').processSite);
}
// theme/script/lazyload/lib/process.js

'use strict';
const fs = require('hexo-fs');
function lazyProcess(htmlContent) {
let loadingImage = this.config.lazyload.loadingImg || 'https://cdn.jsdelivr.net/npm/skx@0.0.9/img/lazy.gif';
return htmlContent.replace(/<img(\s*?)src="(.*?)"(.*?)>/gi, (str, p1, p2, p3) => {
if (/data-src/gi.test(str)) {
return str;
}
if (/class="(.*?)"/gi.test(str)){
str = str.replace(/class="(.*?)"/gi, (classStr, p1) => {
return classStr.replace(p1, `${p1} lazyload`);
})
return str.replace(p3, `${p3} srcset="${loadingImage}" data-srcset="${p2}"`);
}
return str.replace(p3, `${p3} class="lazyload" rcset="${loadingImage}" data-srcset="${p2}"`);
});
}

module.exports.processPost = function(data) {
data.content = lazyProcess.call(this, data.content);
return data;
};

module.exports.processSite = function (htmlContent) {
return lazyProcess.call(this, htmlContent);
};

这个 helper 直接源于 hexo-lazyload-image 插件,我在其基础上添加了 <img> 标签是否存在 class 属性的判断逻辑。经过测试,这个 helper 不会影响 Hexo 的页面编译的速度。

给自己的博客上线 lazyload 策略以后效果是非常明显的,监测数据表明 DOMContentLoaded 的触发直接提前了 400 毫秒。虽然 Chrome 浏览器已经从 Native 层面实现了 lazyload(Stable 通道目前仍然需要在 chrome://flags 中需要手动开启)、Chrome 和 Firefox 都有了资源优先级微调和并发数限制、以及 HTTP/2 Prioritization(服务端资源优先级策略),但是 lazyload 带来的巨大性能提升仍然是我没有预料到的。同时,这也深刻表明了——不论移动设备怎么发展,移动端的带宽和性能都不能和桌面端相提并论;在桌面端可以忽视的问题,在移动端上必须重视起来。

本文最后更新于 天前,文中所描述的信息可能已发生改变