The goal of this step is to reduce the time spent transferring the bytes of the resource over the network to the user’s device.
Load Time 通常与传输的字节数成正比,提升该指标的建议也往往是从减少字节数出发,此外还可以加快传输速度,或者减少传输距离
传输速度这里就不展开了,套 CDN,国内外分流等措施都可以试一下
比较容易的操作方案,图片使用新一代的编码格式,例如 avif
, WebP
; Google 推出了一个 CLI 工具叫做 cwebp
,另外个人之前的博客中,介绍的 gowall 工具也可以将图片转为 WebP
格式
日常写作可以使用 PicList 作为上传图片的工具,工作流可以稍微简化一些
搜集图片->上传图片(PicList自动将格式转为avif/WebP)->自动复制链接到剪切板
Responsive images are a set of techniques used to load the right image based on device resolution, orientation, screen size, network connection, and page layout. The browser should not stretch the image to fit the page layout, and loading it shouldn’t result in wasted time or bandwidth. This improves user experience, as images load quickly and look crisp to the human eye.
在DevTools中,可以查看某一图片的Rendered Size & Intrinsic Size,如果他们之间相差过大,那么在跑LightHouse时,可能会遇到Properly size images的警告,例如 This image file is larger than it needs to be(6144 x 2630) for its displayed dimensions(1199x800). Use responsive images to reduce the image download size;
关于响应式图片的原理/概念介绍,参考responsive_image
基本原理 : 根据用户设备的屏幕尺寸、分辨率等条件,动态提供最适合的图片版本,避免在小屏设备上加载过大的图片。
实现方式:
sharp
等工具,自动生成不同尺寸的图片<img>
等资源设置 srcset
属性对比效果
我测试时最新的文章是vscode_easymotion, hero image 是从 catppuccin discord 下的壁纸,资源大小对比如下,优化效果还是不错的
Format | Resolution | Size |
---|---|---|
png | 6144 x 4096 | 6.8MB |
WebP | 6144 x 4096 | 3.5MB |
WebP(2000w) | 2000 x 1333 | 68.0kB |
一些图床服务的提供商会提供自动生成不同尺寸的图片的功能,但我是使用的 Cloudflare R2 作为图床,存储博客中的图片。而对于 Hexo 博客的封面图/缩略图等资源,就是直接放在博客下的 source/assets/cover
和 source/assets/thumbnails
目录下;Blog 的 YAML Front Matter 类似于下面这样
---title: VSCode VIM Part I - EasyMotiondate: 2025-06-26categories: [Miscellaneous, ToolChain]thumbnail: /assets/thumbnails/vscode_vim.webpcover: /assets/cover/ToolChain/easymotion.webptags: [VSCode, Vim, Catppuccin, EasyMotion]---Your blog post content goes here...
针对个人的图片存放方式,可以在 <your_blog>/scripts
目录(当然也可以放其他地方)下创建一个 responsive_images.js
脚本,用来生成不同尺寸的图片
const sharp = require("sharp");const fs = require("fs");const path = require("path");// NOTE 这里替换成存放图片的目录// 例如:'../source/assets/cover' 和 '../source/assets/thumbnails'const imageDirs = [ path.join(__dirname, "../source/assets/cover"), path.join(__dirname, "../source/assets/thumbnails"),];const widths = [128, 256, 800, 1500, 2000];// Recursive function to find all files in a directoryasync function getFiles(dir) { const dirents = await fs.promises.readdir(dir, { withFileTypes: true }); const files = await Promise.all( dirents.map((dirent) => { const res = path.resolve(dir, dirent.name); return dirent.isDirectory() ? getFiles(res) : res; }) ); return Array.prototype.concat(...files);}async function processImages() { try { let allFiles = []; for (const dir of imageDirs) { if (fs.existsSync(dir)) { console.log("Scanning for images in:", dir); const files = await getFiles(dir); allFiles = allFiles.concat(files); } } const imageFiles = allFiles.filter( (file) => /\.(jpg|jpeg|png|webp|avif)$/i.test(file) && !/-\d+w\.(jpg|jpeg|png|webp|avif)$/i.test(file) ); if (imageFiles.length === 0) { console.log("No new images to process."); return; } console.log(`Found ${imageFiles.length} images to process.`); for (const imagePath of imageFiles) { const { dir, name, ext } = path.parse(imagePath); for (const width of widths) { const newFileName = `${name}-${width}w${ext}`; const newFilePath = path.join(dir, newFileName); if (fs.existsSync(newFilePath)) { // console.log(`Skipping, already exists: ${newFileName}`); continue; } try { await sharp(imagePath).resize(width).toFile(newFilePath); console.log(`✅ Generated: ${newFileName}`); } catch (err) { console.error( `❌ Failed to process ${imagePath} for width ${width}:`, err ); } } } console.log("\nImage processing complete!"); } catch (error) { console.error("An error occurred during image processing:", error); }}// Only run image processing in CI environmentif (process.env.RESPONSIVE_IMAGES === "true") { console.log("CI environment detected. Running image processing script..."); processImages();} else { console.log("Local environment detected. Skipping image processing.");}
一般来说,托管静态博客的 Pipe Line 会让用户分别指定
修改博客的 package.json
文件,scripts 部分的 build
脚本中添加 node scripts/generate_responsive_images.js
,使其在生成静态页面之前运行脚本
{ "name": "hexo-site", "version": "0.0.0", "private": true, "scripts": {- "build": "hexo generate && gulp",+ "build": "node scripts/generate_responsive_images.js && hexo generate && gulp", "clean": "hexo clean", "deploy": "hexo deploy", "server": "hexo server" }, ...}
脚本会读取 RESPONSIVE_IMAGES
环境变量,如果为 true
,则会执行图片处理逻辑;否则跳过处理;这样可以只在 CI/CD 环境中运行脚本,而在本地测试时不执行图片处理;
以 Cloudflare Pages 为例,可以在项目设置中添加环境变量 RESPONSIVE_IMAGES
,并设置为 true
> <img src="https://vluv-space.s3.bitiful.net/cf_page_responsive_image.avif" alt="cf_page_responsive_image"/>
如何在主题中应用 srcset
,以个人使用的 Icarus 主题为例,可以参考本人的这两个 git commit:
-perf(cover): improve LCP performance by using srcset attribute in img… · Efterklang/hexo-theme-icarus@ad65085 -perf(fix): use responsive images for cicd only · Efterklang/hexo-theme-icarus@92cb5e2
Making Web Fast —— LCP Optimization