静态网站图片优化

静态博客就是好,所有资源都能提前预处理:图片压缩、转 webp, avif 、懒加载等等。

这是一个老生常谈的话题,关于优化博客图片资源,包括但不限于图片压缩、使用更优的文件格式以及浏览器兼容性处理。今天我打算重新整理和归纳一下。以前,本站采用了基于 gulp 的图片压缩和 webp 格式转换,但效果不佳,最终转而使用了腾讯云 CDN 的数据万象服务。

如今,随着消费降级,我们还是减少对付费服务的依赖吧。在浏览博客时,我看到 Heo 的一篇 实现全站图片使用 avif 格式,替代臃肿的 webp 教程 。新的图片格式真是如雨后春笋般层出不穷。Whatever,那么重新加入本地处理能力,除了 webp,现在还要支持 avif 格式。

一、图片压缩转换

我们有两个目标:首先是压缩原始图片,其次是将其转换为 webp 和 avif 格式。考虑到 Hexo 基于 Node.js 引擎,我们选择使用 lovell/sharp 这个前端库。这个库不仅能压缩图片,还能转换图片格式,并且可以在 Node.js 环境中运行。不二之选,完美至极。

1.1 处理思路

  • 首先压缩原始图片,生成体积小于原始文件的压缩版本。
  • 仅转换特定格式的图片,通常只处理 jpgjpegpng 等格式。
  • 性能优化:如果待处理的目录中已经存在同名的 webp 或 avif 文件,就跳过该文件,不再进行处理。
核心代码示例
const processFolder = async (folder) => {
const files = await fs.promises.readdir(folder);
const filePromises = files.map(async (file) => {
const inputFilePath = path.join(folder, file);
const stats = await fs.promises.stat(inputFilePath);

if (stats.isDirectory()) {
await processFolder(inputFilePath); // 递归处理子目录
} else if (stats.isFile()
&& includeFormats.includes(path.extname(inputFilePath).toLowerCase())) {
//...

if (!fs.existsSync(webpPath) || !fs.existsSync(avifPath)) {
await compressImage(inputFilePath);
await convertImage(inputFilePath);
} else {
logger.info(`跳过 ${file},已转换为 WebP 和 AVIF`);
}
}
});

await Promise.all(filePromises); // 并行处理文件
}

1.2 补充内容

Sharp 库支持 GIF 压缩,但会丢失动画信息,并且不支持将 GIF 转换为 webp 和 avif 格式。因此,额外添加一个判断,如果当前系统中安装了 ffmpeg,则利用它来完成 GIF 的格式转换。

git to webp
ffmpeg -i ${inputFilePath} -c:v libwebp -lossless 0 -q:v 80 -loop 0 -an -vsync 0 ${webpPath}
gif to avif
ffmpeg -i ${inputFilePath} -c:v libsvtav1 -qp 40 ${avifPath}

1.3 完整代码

完整脚本内容如下,使用时需安装对应依赖[1]

1.4 使用指南

假设脚本位于 .tools/sharp.js 文件中,并且所有图片文件都存放在 ./source/image 目录下,可是使用如下命令实现自动图片转换:

node ./tools/sharps.js

转换后的文件会保存在同一路径下,这可能会稍微增加您的仓库体积。如果您介意空间占用但不介意在部署时花费更多时间(或者在自动化部署脚本中使用),则可以选择对 ./public/image 目录进行处理:

node ./tools/sharps.js ./public/image

1.5 压缩效果

看看效果,这是三张图片:

二、图片资源调用

只关心 Chromium 系的兼容性,webp 32 支持,avif 85 支持。avif 的兼容性有那么一点点欠佳。传统技能,使用 picture 标签提供回退图像(Chromium 38)。

HTML <picture> 元素 通过包含零或多个 source 元素和一个 img 元素来为不同的显示/设备场景提供图像版本。浏览器会选择最匹配的子 <source> 元素,如果没有匹配的,就选择 <img> 元素的 src 属性中的 URL。然后,所选图像呈现在 <img> 元素占据的空间中。

因此,对于常规的 <img> 标签,可以替换为:

<picture>
<source srcset="diagram.avif" type="image/avif" />
<source srcset="diagram.webp" type="image/webp" />
<img src="diagram.png" alt="数据通道示意图" />
</picture>

2.1 处理思路

  • 遍历所有 HTML 文件,利用 JSDOM 将其转换为 NodeList 对象,便于后续处理。
  • 过滤出需要处理的 img 标签并进行替换。如果转换后的文件体积比原文件更大,则不进行替换。
核心代码示例
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
const dom = new JSDOM(fileContent);

[...document.querySelectorAll('img')].forEach(img => {
const picture = document.createElement('picture');

picture.appendChild(sourceAvif);
picture.appendChild(sourceWebp);
picture.appendChild(img.cloneNode(true));

img.replaceWith(picture);
})
校验文件大小
const [originalStats, targetStats] = await Promise.all([
fs.promises.stat(originalFilePath),
fs.promises.stat(targetFilePath),
]);
return targetStats.size < originalStats.size;

2.2 完整代码

完整脚本内容如下,使用时需安装对应依赖[2]

2.3 使用指南

代码片段
// 从命令行参数获取输入文件夹
const inputFolder = path.resolve(process.cwd(), process.argv[2] || './public');
// 判断用条件
const targetDomain = 'https://static.inkss.cn/img/';
// 占位图
const placeholder = "https://static.inkss.cn/img/default/transparent-placeholder-1x1.svg";

修改 targetDomain 字段为你的图片资源前缀,程序会查找 html 文件中的所有 img 标签,并将其引用地址与该前缀进行匹配。匹配成功则进行处理,使用 placeholder 的值作为默认占位图。

假设脚本位于 .tools/sharp_html.js 文件中,调用方式与上一小节类似,但如果不指定路径,默认处理 ./public 路径下的 HTML 文件。

node ./tools/sharp_html.js ./public/image

三、图片时间戳

由于 CDN 的缓存时间设置较长,例如浏览器图片缓存的过期时间为七天,这导致如果图片发生变动,除非强制刷新,否则客户端在二次访问时无法立即获取最新的图片内容。

3.1 处理思路

  • 从 git 中读取最近七天的提交记录,过滤出所有涉及图片文件变动的记录。
  • 遍历 HTML 文件中的所有图标标签,并与变动记录进行比较。如果匹配,则在图片地址后追加时间戳。
const getRecentCommits = async () => {
try {
const { stdout } = await execPromise('git log --since="7 days ago" --name-only --pretty=format: -z');
return stdout.split('\0').map(file => file.trim()).filter(file => file);
} catch (err) {
logger.error(`获取最近提交时出错: ${err.message}`);
return [];
}
}

3.2 完整代码

3.3 调用方式

node ./tools/timestamp.js ./public

四、图片懒加载

4.1 懒加载实现

在减少文件大小之后,接下来尝试为图片添加懒加载处理:在 “图片资源调用” 部分,同步将图片的 src 属性修改为 data-src,并将内容替换为占位图地址。接着,为 picture 标签添加懒加载 lazy 标志,方便进行样式修饰。为了兼容 SEO,增加 <noscript> 标签,在其中存放真实的图片资源:

元素预处理
{
const placeholder = "https://static.inkss.cn/img/default/loading.svg";

img.setAttribute('data-src', src);
img.setAttribute('src', placeholder);

const noScript = document.createElement('noscript');
const noScriptImg = document.createElement('img');
noScriptImg.setAttribute('src', src);
noScriptImg.setAttribute('alt', img.getAttribute('alt'));
noScript.appendChild(noScriptImg);

picture.classList.add("lazy")
picture.appendChild(noScript);

img.replaceWith(picture);
}

接着编写懒加载的具体实现,利用 IntersectionObserver API,仅当图片显示在“视口”(viewport)中时,才加载图片资源。

🌰使用举例
// 实例化并监听
const lazyLoader = new LazyLoader("picture.lazy img");

// pjax 等回调使用
lazyLoader.reinitObserver();

4.2 主题兼容处理

在主题中,大多数滚动事件(目录、锚点、搜索词定位)都是通过Window.scrollTo()完成的。当属性 behavior 被设置为 smooth(平滑滚动)时,页面在滚动到目标元素的过程中会加载图片资源。因此,需要额外判断:由程序引发的滚动事件不应触发图片的懒加载,暂停对目标元素是否可见的判断。

主题对滚动事件提供了一个封装函数volantis.scroll,其中包含一个滚动判断方法handleScrollEvents(),它的作用是是持续监控页面的滚动事件并根据滚动状态触发相应的逻辑。我们新增handleScrollStop方法,主要检测页面滚动何时停止,并在滚动停止时触发图片懒加载操作,确保只有在页面滚动停止后才进行图片懒加载。最后在handleScrollEvents()中调用即可。

volantis.scroll.isScrolling 会在开始滚动时设置为 true
volantis.scroll = {
isScrolling: false,
lastScrollTop: 0,
scrollingTimeOut: null,

handleScrollStop: () => {
clearTimeout(volantis.scroll.scrollingTimeOut);
volantis.scroll.scrollingTimeOut = setTimeout(() => {
if (volantis.scroll.lastScrollTop === window.pageYOffset) {
if (typeof lazyLoader.reinitObserver === 'function'
&& volantis.scroll.isScrolling) {
volantis.scroll.isScrolling = false;
lazyLoader.reinitObserver();
}
}
}, 100);
}
}

相应的,修改LazyLoader的实现:

判断 volantis.scroll.isScrolling
if (entry.isIntersecting
&& (!volantis?.scroll || !volantis?.scroll?.isScrolling)) {
this.loadImage(entry.target);
this.lazyPictureObserver.unobserve(entry.target);
this.observedElements.delete(entry.target);
}

4.3 灯箱兼容处理

主题所使用的灯箱插件为 Fancybox,无论是图片懒加载还是 <picture> 标签,都容易存在一些问题。根据 fancyapps/ui/issue#379v5.0.13 版本开始支持 <picture> 标签。结合文档,做如下修改:

判断 Fancybox 配置
{
Images: {
content: (_ref, slide) => {
const imgElement = slide.thumbEl;
const pictureElement = imgElement.closest('picture');
if (imgElement.hasAttribute('data-src')) {
imgElement.setAttribute('src', imgElement.getAttribute('data-src'));
}
if (pictureElement) {
pictureElement.classList.remove("lazy");
let sources = pictureElement.getElementsByTagName('source');
for (let source of sources) {
if (source.hasAttribute('data-srcset')) {
source.setAttribute('srcset', source.getAttribute('data-srcset'));
}
}
return pictureElement.outerHTML;
} else {
return imgElement.outerHTML;
}
}
}
}

4.4 运行时修改

以上对图片的压缩、转换和加载方式的替换,都是在生成静态文件后(hexo generate)进行的。某种意义上来说,本地测试时无法观测到图片懒加载的处理行为。因此,建议新建一个 Hexo 过滤插件,以满足在运行时(hexo server)观察到图片懒加载的处理行为。

scripts/picture-wrapper.js
const cheerio = require('cheerio');
hexo.extend.filter.register('after_render:html', function (htmlContent) {
if (this.env.cmd !== 'server') return htmlContent;

const $ = cheerio.load(htmlContent);

$('img').each(function () {
const img = $(this);
img.attr('data-src', img.attr('src'));
img.attr('src', '/img/default/transparent-placeholder-1x1.svg');
img.attr('loading', 'lazy');

const picture = $('<picture class="lazy"></picture>');
img.wrap(picture);
});

return $.html();
});

五、附录内容

一些胡思乱想的思路,仅供参考。

首先,我们依旧生成同名的 webpavif 版本的图片,然后将所有图片引用地址全局替换为 .avif 格式。接着,增加图片加载失败的判断逻辑,检查文件格式和资源来源,如果加载失败则回退到 webp 格式重新加载。倘若回退后仍然加载失败,那么再见吧您嘞,还用这么古老的浏览器

avif 回退为 webp
const handleImageErrors = (event) => {
const imgElement = event.target;

if (imgElement.tagName === 'IMG') {
const imageUrl = imgElement.dataset.src || imgElement.src;

if (imageUrl.startsWith('XXXX') && imageUrl.endsWith('.avif')) {
imgElement.src = imageUrl.replace(/\.avif$/, '.webp');
}
}
};

document.addEventListener('error', handleImageErrors);

  1. 终端下安装依赖 sharp

    npm install sharp --save
    ↩︎
  2. 终端下安装依赖 jsdom

    npm install jsdom --save
    ↩︎

评论