2078 字
10 分钟
博客前端性能大优化: 首图快一点,再快一点

起因是看月度总结那篇文章时发现,直接打开文章链接居然加载不出评论区,排查问题途中顺手做了个全站性能调优。Lighthouse 评分从 34 一路拉到桌面端 95、移动端 76。

起因:评论区加载不出来#

写完 2026年4月总结 之后,把链接发给了朋友,他说页面打不开评论区。我点开试了一下——从首页点进去正常,但是直接复制链接到新标签页打开,评论区空荡荡,控制台没有任何请求。

神了,Swup 页面切换就正常,直接 SSR 渲染就不行。

排查:set + 模板字面量踩坑#

F12 看了半天,发现评论区有个巨大的内联脚本(快一千行),用了 Astro 的 set:html 语法把整段 JS 当字符串塞进去:

// ❌ 旧写法
<script is:inline type="module" set:html={
`(function() {
const articleKey = ${JSON.stringify(article)};
// ...
})();
`}>

这个写法我以前翻过一次车:Astro 的 set:html 里嵌套 JS 模板字面量 ${} 会破坏解析器。跟项目里正常的 CommentForm.astro 对比,它用的是 define:vars

最后干脆放弃变量注入,直接让脚本从 DOM 的 data-article 属性读取参数,彻底绕开 Astro 的模板魔法:

// ✅ 最终方案:从 DOM 读取,零依赖
var section = document.querySelector('section[data-comment-section-id]');
var articleKey = section.dataset.article;

照着改了,还是不行。继续往深了挖 —— 日志显示脚本确实跑起来了,卡在了 loadMarkdownIt() 里面。

真凶:CDN 脚本加载竞态#

comment-utils.js 里的 loadMarkdownIt() 通过动态创建 <script> 标签从 CDN 加载 markdown-it 和 highlight.js:

U.loadMarkdownIt = () => {
if (window.__md) return Promise.resolve(window.__md);
// 动态创建 script 标签加载 CDN
// 等两个脚本都 onload 了才 resolve Promise
};

问题来了:Swup 预加载(prefetch)时已经把 CDN 标签塞进 <head> 了,但 onload 还没触发。直接打开新标签页时,标签已存在,window.__md 却没定义,Promise 永远不 resolve,评论区卡死。

根本原因是这个函数的 else 分支缺失 —— 它只处理了”没有标签就创建”的情况,没有处理”标签已存在但未 onload”的情况。

graph LR
A[页面加载] --> B{CDN标签已存在?}
B -->|Swup导航| C[window.__md已缓存]
C --> D[✅ 直接返回]
B -->|直接打开| E[标签存在但未onload]
E --> F[❌ Promise卡死]

修复:超时 + fallback#

loadMarkdownIt() 加了三个容错机制:

  1. 10 秒超时兜底 —— CDN 加载不上也 resolve,评论正常显示(只是没有语法高亮)
  2. else 分支补全 —— 标签已存在则直接检查 window.markdownit 并 resolve
  3. resolved 防重入 —— 防止 onload/onerror/超时多次 resolve
var timeoutId = setTimeout(function() {
if (resolved) return;
resolved = true;
resolve(null); // 降级,继续加载评论
}, 10000);
// 补全的分支:标签已存在但 __md 未定义
if (window.markdownit) {
tryInit(); tryInit(); // 两次触发 loaded >= 2
} else {
resolved = true; resolve(null);
}

顺手把 CommentSection 改为懒加载 —— IntersectionObserver 在评论区距视口 400px 时才开始请求数据。文章正文渲染完全不受影响了。

Lighthouse 来了个下马威#

评论修好之后跑了一次 Lighthouse 看看整体情况。这一跑不要紧…

优化前

指标数值评级
Performance34🔴
FCP1,633ms🟢
LCP4,043ms🔴
TBT1,356ms🔴
Total Size4,148KB🔴

主线程长任务排行:

729ms ← AliyunCaptcha.js (验证码 SDK)
354ms ← Layout 内联脚本 bundle
297ms ← AliyunCaptcha.js 再次
218ms ← feilin006 (验证码字体渲染)
217ms ← feilin006 再次
203ms ← feilin006 再次
197ms ← cx.031 (验证码动态脚本)

阿里云验证码相关长任务合计 1,760ms,占整个 TBT 的 130%。

逐个击破#

1. 图片过大 → sharp 缩放#

Lighthouse 报告了 “Properly size images” 警告,预计能省 2,567KB

罪魁祸首是文章封面图:原图 5760×3240,实际在卡片上只需要 405×228。Astro 内置了 Image 组件,但 ImageWrapper 没传 width,等于原画质输出。

// ❌ 旧:没传宽高,原画质
<Image src={img} />
// ✅ 新:指定宽度 + 2x 视网膜
<Image src={img} width={1600} /> // 800px 显示 × 2x 密度

各场景的尺寸设定:

场景宽度说明
文章封面1600内容区 800px × 2
卡片封面400侧栏 ~200px × 2
头像19296px × 2
Banner2400全宽 1200px × 2

astro.config.mjs 开启 sharp 图片处理:

image: {
service: { entrypoint: "astro/assets/services/sharp" },
}

写了一个 rehype 插件,给 markdown 里的 <img> 自动加上 loading="lazy" decoding="async"

rehype-image-lazy.mjs
export default function rehypeImageLazy() {
return (tree) => {
visit(tree, "element", (node) => {
if (node.tagName !== "img") return;
node.properties.loading = node.properties.loading || "lazy";
node.properties.decoding = node.properties.decoding || "async";
});
};
}

2. 封面图 LCP 优化#

对于首屏封面图,加上最高优先级:

<ImageWrapper
loading="eager"
decoding="sync"
fetchpriority="high"
width={1600}
/>

浏览器在发现图片 URL 第一时间就全速下载,LCP 从 4,043ms 压到了 1,452ms。

3. 验证码 SDK 阻塞主线程 → 懒加载#

验证码 1.7 秒阻塞是最大痛点。之前 CommentForm.astro 页面一加载就初始化:

setTimeout(initCaptcha, 50);

改成 IntersectionObserver,不滚到评论区就不加载:

var captchaObserver = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) {
captchaObserver.disconnect();
initCaptcha(); // 现在才开始下载 AliyunCaptcha.js
}
}, { rootMargin: '500px' });
captchaObserver.observe(form);

4. KaTeX CSS 按需加载#

Layout.astro 全局引入了 KaTeX CSS(约 60KB),实际上只有少数文章(比如 markdown 语法测试那篇)用到数学公式。把它移到了 Markdown.astro —— 只有文章页才加载。

// Layout.astro ❌ 移除
// import "katex/dist/katex.css";
// Markdown.astro ✅ 仅文章页
import "katex/dist/katex.css";

5. 说说页面也修了#

修复过程中顺手发现说说页(/moments/)有同样的问题。moments.js 也是 IIFE 入口就拿了 var U = window.__commentUtils,而 comment-utils.js<slot /> 之后同步加载,但 moments.js 通过动态插入 script 标签加载,时序不确定。

最简单的方案是保证 comment-utils.js 的加载顺序在所有其它脚本之前 —— 保持同步 <script is:inline src="/comment-utils.js"> 不变。虽然 Lighthouse 会报一个小警告,但实际只有 5KB,在生产环境 HTTP/2 下影响几乎为零。

6. 不删的 CSS#

还有一部分 178KB “Unused CSS” —— @tailwindcss/typography 的 prose 变体、expressive-code 代码块样式、photoswipe 灯箱、overlayscrollbars 滚动条。这些都是给特定内容类型准备的(代码块、移动端图片缩放),不能随便删,也没有懒加载 CSS 的简单方案,保留了。

HTTP/2 加速:阿里云 ESA#

我的博客托管在阿里云 ESA(全站加速),开启了 HTTP/2。

ESA 控制台 → 站点 → 边缘规则 → 添加规则:

规则名称:启用 HTTP/2
目标类型:所有请求
执行动作:HTTP/2

HTTP/2 相比 HTTP/1.1 的核心优势:

  • 多路复用 —— 一个连接并行传输所有资源,不再需要”CSS Sprites”和域名分片
  • 头部压缩 (HPACK) —— Cookie 等头部信息只传差异部分
  • 对静态资源多的博客来说,HTTP/2 的多路复用优势非常明显

静态资源缓存#

顺手把 _astro/ 目录的缓存拉到 365 天:

规则:URL 路径 通配符 */_astro/*
执行:缓存 TTL = 31536000s

Astro 构建的静态文件带内容哈希(如 C6BFPrR2_1wNbLv.webp),内容变了哈希就变,放心缓存。

最终效果#

部署后跑了两轮 Lighthouse:

指标优化前优化后 (桌面)优化后 (移动)
Performance349576
FCP1,633ms582ms2,229ms
LCP4,043ms1,452ms5,604ms
TBT1,356ms0ms0ms
Total KB4,148KB797KB
Speed Index7,720ms1,109ms2,829ms

TBT 归零是最爽的,验证码懒加载把主线程 1.7 秒的阻塞全消掉了。

为什么移动端 76#

Lighthouse 的移动端模拟用 4 倍 CPU 降速 + Slow 4G (1.6Mbps),相当于 2015 年的低端安卓。对一篇 20+ 张插图的长文来说,76 分非常健康。同类内容型博客(Medium、Dev.to)一般也就 60-80 分。

桌面 95 分说明代码本身已经充分优化。

小结#

首图快才是真的快。压缩图片大小谁都会,关键是调节加载顺序。封面图高优先级全速下载,正文插图懒加载不抢带宽,评论区脚本等人滚到了再初始化。

一个很反直觉的经验:改 defer 的确能在 Lighthouse 上好看几分,但在生产环境里这点收益远小于它带来的维护成本。comment-utils.js 直接同步加载只有 5KB,HTTP/2 多路复用下几乎没有感知延迟,但加上 defer 后所有引用它的脚本都要改成惰性访问,反而引入了新的边界条件和 bug。性能优化还是要以用户体验为准,不要为了评分而优化。

以后再写新文章,这些优化会自动生效 —— ImageWrapper 会输出合理尺寸的图片,rehype 插件给插图加 lazy loading,评论区在滚动到附近时才请求。一劳永逸,好耶!

博客前端性能大优化: 首图快一点,再快一点
https://www.mintlab.top/posts/tries/blog_enhance/
作者
Mint
发布于
2026-05-12
许可协议
CC BY-NC-SA 4.0
发表评论

输入用户名和邮箱后自动检查登录状态。登录后用户名和邮箱将被绑定, 只可以修改头像和主页链接。

未登录
昵称
邮箱
填写头像链接与主页链接

头像链接为空默认使用gravatar头像

头像
主页
人机验证
评论列表

以下是可爱的评论们:

暂无评论, 呜呜, 快来评论喵!