博客加速实践
# 记录一次博客完整的性能优化实践:响应式图片预生成和JS文件延迟加载。
目前博客部署在 Cloudflare Pages 服务上,通过 DnsPod 服务做了国内和海外分线路解析。
Cloudflare Pages
↑
blog.liudon.xyz
↑ ↑
回源 直接访问
│ │
腾讯云 CDN │
↑ ↑
国内 海外
\ /
DNSPod
↑
liudon.com
因为 Cloudflare 在国内属于反向加速,所以加了一层国内CDN做加速。
但总感觉博客访问不快,随便打开一个页面,页面完全处理完都要在秒级,实在是慢。
通过 Lighthouse 测试,借助 AI 的能力,又做了一轮新的优化。
1. 增加国内 CDN 节点缓存时间
腾讯 CDN 首页的响应:
cf-cache-status: DYNAMIC
cf-ray: ...-AMS
x-cache-lookup: Cache Miss
cache-control: public, must-revalidate, max-age=0
age: 0
x-nws-log-uuid: ...
请求确实经过腾讯云 CDN(x-nws-log-uuid),但腾讯边缘节点没有命中首页缓存,随后回源 Cloudflare,并落到 AMS(阿姆斯特丹)。
Cloudflare Pages 默认返回 max-age=0, must-revalidate,这是 Pages 的正常默认行为;但腾讯 CDN 如果“遵循源站”,就会频繁回源。
通过调整腾讯云 CDN 的缓存规则,增加强制缓存规则解决。
首页 缓存30分钟
/posts 缓存30分钟
/tags 缓存30分钟
/page 缓存30分钟
2. 延长指纹资源缓存时间
cache-control: public, must-revalidate, max-age=14400
age: 223
x-cache-lookup: Cache Hit
当前 CSS / JS 文件名已经包含 hash,可以设置更长的过期时间。
通过 _headers 文件配置缓存时间解决。
/assets/*
Cache-Control: public, max-age=31536000, immutable
3. 修正响应式图片效果
终于来到这次优化的重头戏了。
这里之前其实做过一次优化,见当Hugo遇上AVIF,优化图片加载。
当时引入了 AVIF 格式文件,增加了响应式图片效果。
但通过 Lighthouse 测试,仍有图片大小的问题。
比如广府古城一日游文章里,整个页面 Lighthouse 给出的图片优化空间是3,914 KiB,接近 4 MB。
当前页面的响应式代码如下:
<source
type="image/avif"
srcset="IMG_7537.PNG_1080x.avif 1080w"
sizes="(min-width: 768px) 1080px, 100vw"
>
这里只有 1080x 这一个规格,并不能发挥响应式图片的作用。
这里是我理解有误,我理解成浏览器是按页面屏幕大小来做选择了,实际上是按这个元素的占位大小来选择不同规格。
页面里多图并排的情况,也会下载 1080x 这个规格的文件,导致大量的流量浪费。
另外还有一个问题是,现在的 AVIF / WEBP 格式文件都是提前预处理的。
因为每次都是全量生成,虽然现在只生成一个规格,但每次执行耗时都要在 10 分钟以上。
如果生成多个规格文件,会导致 Github Actions 执行非常慢。
怎么优化这个耗时,一直想优化来着,苦于找不到好的方案。
将这些问题反馈给 AI,给出了如下的方案:
通过预生成多个规格的 AVIF / WEBP 文件做响应式图片;
通过增加流水线缓存,避免每次都全量生成,只做增量更新。
3.1 流水线增加媒体处理逻辑
新增.github/scripts/media/package.json文件,内容如下:
{
"name": "liudon-media-pipeline",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"dependencies": {
"sharp": "0.35.0"
}
}
新增 .github/scripts/media/process-media.mjs,负责统一处理文章中的图片和视频。脚本较长,正文只保留处理流程,完整实现可以直接查看仓库中的 process-media.mjs:
- 递归扫描文章目录中的 JPG、JPEG、PNG 和 MP4 文件。
- 根据源文件内容和处理配置生成缓存键,未变化的文件直接复用缓存。
- 按 320、480、720、1080 像素生成响应式图片,不放大尺寸较小的原图。
- 使用 Sharp 同时生成 AVIF 和 WebP,供浏览器按支持情况选择。
- 使用 FFmpeg 处理视频并生成封面,避免在 Hugo 构建阶段重复执行重任务。
这样可以把耗时的媒体转换放到 GitHub Actions 预处理阶段,Hugo 构建时只需要读取已经生成好的文件。
调整 .github/workflows/main.yml 文件,修改预处理逻辑为下面内容:
# ------------------------------------------------------
# Media cache
#
# 第一次:
#
# media-v1 不存在
# -> 全量图片 + 视频处理
#
# 后续:
#
# source 未变化
# -> exact cache hit
#
# 修改一张图片/视频
# -> restore 最近的 media-v1
# -> 只处理变化文件
# ------------------------------------------------------
- name: Restore media cache
uses: actions/cache@v5
with:
path: .cache/media
key: media-v1-${{ hashFiles('content/posts/**/*.jpg', 'content/posts/**/*.jpeg', 'content/posts/**/*.png', 'content/posts/**/*.JPG', 'content/posts/**/*.JPEG', 'content/posts/**/*.PNG', 'content/posts/**/*.mp4', 'content/posts/**/*.MP4', 'static/ArchitectsDaughter-Regular.ttf', '.github/scripts/media/package.json', '.github/scripts/media/process-media.mjs') }}
restore-keys: |
media-v1-
# ------------------------------------------------------
# Node.js
# ------------------------------------------------------
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
package-manager-cache: false
# ------------------------------------------------------
# Sharp
#
# 使用 Sharp 官方预编译 Linux 二进制。
#
# 不再需要:
#
# build-essential
# libheif-dev
# libaom-dev
# libwebp-dev
# ImageMagick configure/make
# ------------------------------------------------------
- name: Install Sharp
run: |
npm install \
--prefix .github/scripts/media \
--omit=dev \
--no-audit \
--no-fund
# ------------------------------------------------------
# FFmpeg
# ------------------------------------------------------
- name: Setup FFmpeg
shell: bash
run: |
set -euo pipefail
NEED_APT=0
if ! command -v ffmpeg >/dev/null 2>&1; then
NEED_APT=1
fi
if ! command -v ffprobe >/dev/null 2>&1; then
NEED_APT=1
fi
if ! command -v fc-match >/dev/null 2>&1; then
NEED_APT=1
fi
if [[ "$NEED_APT" == "1" ]]; then
sudo apt-get update
sudo apt-get install -y \
ffmpeg \
fontconfig
fi
echo
echo "FFmpeg:"
ffmpeg -version | head -n 1
echo
echo "FFprobe:"
ffprobe -version | head -n 1
echo
echo "Checking H.264 encoder..."
if ! ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264; then
echo "ERROR: FFmpeg does not contain libx264 encoder"
exit 1
fi
echo "libx264: OK"
# ------------------------------------------------------
# Process images + videos
# ------------------------------------------------------
- name: Process media
run: |
node .github/scripts/media/process-media.mjs
把 ImageMagick 换成了 Sharp,压缩后的文件更小一些。
3.2 Hugo 图片解析响应式调整
当前 Hugo 已经可以处理 AVIF;本站继续在 GitHub Actions 中使用 Sharp 预生成 AVIF/WebP,是为了复用媒体缓存,并避免在 Hugo 生产构建阶段集中处理大量原图。
新增 layouts/_default/_markup/render-image.html文件,内容如下:
{{- /*
响应式图片尺寸:
普通大图:
320 / 480 / 720 / 1080
如果源图片小于 1080:
还会自动加入图片实际宽度。
例如:
源图 900px
→ 320 / 480 / 720 / 900
源图 600px
→ 320 / 480 / 600
*/ -}}
{{- $respSizes := slice 320 480 720 1080 -}}
{{- /*
auto:
现代浏览器根据 CSS 布局后的实际尺寸选择图片。
fallback:
如果浏览器不支持 auto,则最大按照 1080px 处理。
*/ -}}
{{- $dataSizes := site.Params.responsiveImageSizes | default "auto, (min-width: 1080px) 1080px, calc(100vw - 32px)" -}}
{{- $filter := "box" -}}
{{- $Destination := .Destination -}}
{{- $Page := .Page -}}
{{- $Text := .Text -}}
{{- $Title := .Title -}}
{{- $responsiveImages := (.Page.Params.responsiveImages | default site.Params.responsiveImages) | default true -}}
{{- /*
development 环境只输出原图,避免本地预览时生成响应式变体。
*/ -}}
{{- if not hugo.IsProduction -}}
{{- $responsiveImages = false -}}
{{- end -}}
{{- with $src := .Page.Resources.GetMatch .Destination -}}
{{- if $responsiveImages -}}
{{- /*
根据源图片宽度生成实际候选尺寸。
最大不会超过源图。
*/ -}}
{{- $candidateSizes := slice -}}
{{- range $size := $respSizes -}}
{{- if ge $src.Width $size -}}
{{- $candidateSizes = $candidateSizes | append $size -}}
{{- end -}}
{{- end -}}
{{- /*
如果源图片实际宽度不是标准档位,
把实际宽度作为最后一档。
例如 900px:
320 / 480 / 720 / 900
*/ -}}
{{- if not (in $candidateSizes $src.Width) -}}
{{- $candidateSizes = $candidateSizes | append $src.Width -}}
{{- end -}}
{{- /*
优先 AVIF,再 WebP。
*/ -}}
{{- $imageTypes := slice "avif" "webp" -}}
<picture>
{{- range $imageType := $imageTypes -}}
{{- $srcset := slice -}}
{{- range $size := $candidateSizes -}}
{{- $compressedImage := printf "%s_%dx.%s" $Destination $size $imageType -}}
{{- $cmSrc := $Page.Resources.GetMatch $compressedImage -}}
{{- if $cmSrc -}}
{{- $url := $cmSrc.RelPermalink | absURL -}}
{{- /*
用实际图片宽度作为 descriptor,
不依赖文件名里的数字。
*/ -}}
{{- $candidate := printf "%s %dw" $url $cmSrc.Width -}}
{{- $srcset = $srcset | append $candidate -}}
{{- else if and (eq $imageType "webp") hugo.IsExtended -}}
{{- /*
生产构建没有预处理文件时,
WebP 仍然可以让 Hugo 自己生成。
AVIF 则依赖 GitHub Action 预处理。
*/ -}}
{{- $resized := $src.Resize (printf "%dx %s %s" $size $imageType $filter) -}}
{{- $url := $resized.RelPermalink | absURL -}}
{{- $candidate := printf "%s %dw" $url $resized.Width -}}
{{- $srcset = $srcset | append $candidate -}}
{{- end -}}
{{- end -}}
{{- if gt (len $srcset) 0 -}}
<source
type="image/{{ $imageType }}"
srcset="{{ delimit $srcset ", " }}"
sizes="{{ $dataSizes }}"
/>
{{- end -}}
{{- end -}}
<img
src="{{ $Destination | safeURL }}"
width="{{ $src.Width }}"
height="{{ $src.Height }}"
alt="{{ $Text }}"
{{- with $Title }}
title="{{ . }}"
{{- end }}
loading="lazy"
decoding="async"
/>
</picture>
{{- else -}}
<img
src="{{ $Destination | safeURL }}"
width="{{ $src.Width }}"
height="{{ $src.Height }}"
alt="{{ $Text }}"
{{- with $Title }}
title="{{ . }}"
{{- end }}
loading="lazy"
decoding="async"
/>
{{- end -}}
{{- end -}}
第一次执行需要处理全量文件,输出类似如下:
Images processed : N
Images cached : 0
再次执行,输出类似如下:
CACHE IMAGE ...
CACHE IMAGE ...
Images processed : 0
Images cached : N
至此,图片响应式问题搞定了,流水线的耗时问题也解决了,目前控制在3分钟左右。
模板通过 hugo.IsProduction 判断构建环境。本地预览使用 development 环境时,responsiveImages 会被设为 false,只输出带有宽高属性的原图,不会进入 $src.Resize 分支:
hugo server --environment development
生产构建前需要先运行媒体预处理脚本。否则缺少预生成文件时,主题可能回退到 Hugo 生成 WebP;原图尺寸过大或数量较多时,会明显增加构建内存。对于远大于站点最大展示尺寸的原图,建议先缩小再进入构建流程。
4. JS 文件按需加载
图片从数 MB 降下来以后,Lighthouse 中最明显的问题开始变成:
Reduce unused JavaScript
目前文章页共引入了两个 JS 文件:
view-image.min.js 用于点击图片浮窗展示;
twikoo.min.js 用于展示twikoo评论;
在页面打开后就立即加载这两个文件,而评论部分在页面最下面,这个资源的加载时机不合理。
通过 article.js 文件引入 ViewImage 和 Twikoo loader,待用户接近评论区时才真正加载Twikoo评论。
ViewImage
+
Twikoo loader
↓
article.js
Twikoo 本体
↓
用户接近评论区才加载
同时通过 Hugo 判断页面是否有插入图片,有的话才加载 ViewImage 代码,改为按需加载。
- 下载 view-image.min.js 到 assets/js/vendor目录下,不再依赖 tokinx.github.io 服务访问。
- 通过 Hugo Pipeline 构建文章页 JS文件引入。
文件目录:
assets/js/
├── article.js
└── vendor/
└── view-image.min.js
article.js 文件代码如下:
"use strict";
// ============================================================
// Hugo build-time configuration
// ============================================================
const TWIKOO_VERSION =
{{ .Params.twikoo.version | jsonify }};
const TWIKOO_ENV_ID =
{{ (.Params.twikoo.envId | default "") | jsonify }};
const TWIKOO_URL =
`https://cdnjs.cloudflare.com/ajax/libs/twikoo/${TWIKOO_VERSION}/twikoo.min.js`;
// ============================================================
// ViewImage
// ============================================================
function initViewImage() {
if (!window.ViewImage) {
return;
}
const images =
document.querySelectorAll(
".post-content img"
);
if (images.length === 0) {
return;
}
window.ViewImage.init(
".post-content img"
);
}
// ============================================================
// Twikoo state
// ============================================================
let twikooLoading = null;
let twikooInitialized = false;
// ============================================================
// Twikoo initialization
// ============================================================
function initTwikoo() {
if (twikooInitialized) {
return;
}
if (!window.twikoo) {
return;
}
const container =
document.querySelector(
"#tcomment"
);
if (!container) {
return;
}
twikooInitialized = true;
try {
const result =
window.twikoo.init({
envId:
TWIKOO_ENV_ID,
el:
"#tcomment",
lang: 'zh-CN',
region: 'ap-shanghai',
path: window.TWIKOO_MAGIC_PATH||window.location.pathname,
});
if (
result &&
typeof result.catch ===
"function"
) {
result.catch(error => {
twikooInitialized = false;
console.error(
"Twikoo initialization failed:",
error
);
});
}
} catch (error) {
twikooInitialized = false;
console.error(
"Twikoo initialization failed:",
error
);
}
}
// ============================================================
// Dynamically load Twikoo
// ============================================================
function loadTwikoo() {
if (window.twikoo) {
initTwikoo();
return Promise.resolve();
}
if (twikooLoading) {
return twikooLoading;
}
twikooLoading =
new Promise(
(resolve, reject) => {
const script =
document.createElement(
"script"
);
script.src =
TWIKOO_URL;
script.async =
true;
script.crossOrigin =
"anonymous";
script.onload =
() => {
initTwikoo();
resolve();
};
script.onerror =
() => {
twikooLoading =
null;
reject(
new Error(
`Failed to load Twikoo: ${TWIKOO_URL}`
)
);
};
document.head.appendChild(
script
);
}
);
return twikooLoading;
}
// ============================================================
// Lazy load Twikoo
// ============================================================
function initLazyTwikoo() {
const container =
document.querySelector(
"#tcomment"
);
// 当前文章没有评论区域:
// 完全不下载 Twikoo。
if (!container) {
return;
}
// 老浏览器 fallback
if (
!(
"IntersectionObserver"
in window
)
) {
loadTwikoo()
.catch(console.error);
return;
}
const observer =
new IntersectionObserver(
entries => {
const shouldLoad =
entries.some(
entry =>
entry.isIntersecting
);
if (!shouldLoad) {
return;
}
observer.disconnect();
loadTwikoo()
.catch(error => {
console.error(
error
);
});
},
{
// 用户距离评论区约 1000px
// 时开始下载 Twikoo,
// 避免滚动到评论区才等待。
rootMargin:
"1000px 0px",
}
);
observer.observe(
container
);
}
// ============================================================
// Article initialization
// ============================================================
function initArticle() {
initViewImage();
initLazyTwikoo();
}
// ============================================================
// DOM ready
// ============================================================
if (
document.readyState ===
"loading"
) {
document.addEventListener(
"DOMContentLoaded",
initArticle,
{
once: true,
}
);
} else {
initArticle();
}
把原有的 view-image.min.js 和 twikoo.min.js 加载代码全部去掉,在 layouts/partials/extend_footer.html 文件里增加下面代码:
{{- if .IsPage -}}
{{- $hasImages :=
strings.Contains
.Content
"<img"
-}}
{{- $hasComments :=
.Param "comments"
-}}
{{- if or $hasImages $hasComments -}}
{{- $articleSource :=
resources.Get
"js/article.js"
-}}
{{- $article :=
resources.ExecuteAsTemplate
"js/article.generated.js"
site
$articleSource
-}}
{{- $bundle :=
$article
-}}
{{- if $hasImages -}}
{{- $viewImage :=
resources.Get
"js/vendor/view-image.min.js"
-}}
{{- $bundle =
slice
$viewImage
$article
| resources.Concat
"js/article.js"
-}}
{{- else -}}
{{- $bundle =
slice $article
| resources.Concat
"js/article.js"
-}}
{{- end -}}
{{- $bundle =
$bundle
| minify
| fingerprint "sha256"
-}}
<script
src="{{ $bundle.RelPermalink }}"
integrity="{{ $bundle.Data.Integrity }}"
crossorigin="anonymous"
defer>
</script>
{{- end -}}
{{- end -}}

优化后的 Lighthouse 测试效果,目前自己体感快了一些,不知道是不是错觉。
博客优化永无止境,咱这次就先优化到这里。😁