起因是看月度总结那篇文章时发现,直接打开文章链接居然加载不出评论区,排查问题途中顺手做了个全站性能调优。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() 加了三个容错机制:
- 10 秒超时兜底 —— CDN 加载不上也 resolve,评论正常显示(只是没有语法高亮)
- else 分支补全 —— 标签已存在则直接检查
window.markdownit并 resolve 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 看看整体情况。这一跑不要紧…
优化前:
| 指标 | 数值 | 评级 |
|---|---|---|
| Performance | 34 | 🔴 |
| FCP | 1,633ms | 🟢 |
| LCP | 4,043ms | 🔴 |
| TBT | 1,356ms | 🔴 |
| Total Size | 4,148KB | 🔴 |
主线程长任务排行:
729ms ← AliyunCaptcha.js (验证码 SDK)354ms ← Layout 内联脚本 bundle297ms ← 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 |
| 头像 | 192 | 96px × 2 |
| Banner | 2400 | 全宽 1200px × 2 |
astro.config.mjs 开启 sharp 图片处理:
image: { service: { entrypoint: "astro/assets/services/sharp" },}写了一个 rehype 插件,给 markdown 里的 <img> 自动加上 loading="lazy" decoding="async":
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/2HTTP/2 相比 HTTP/1.1 的核心优势:
- 多路复用 —— 一个连接并行传输所有资源,不再需要”CSS Sprites”和域名分片
- 头部压缩 (HPACK) ——
Cookie等头部信息只传差异部分 - 对静态资源多的博客来说,HTTP/2 的多路复用优势非常明显
静态资源缓存
顺手把 _astro/ 目录的缓存拉到 365 天:
规则:URL 路径 通配符 */_astro/*执行:缓存 TTL = 31536000sAstro 构建的静态文件带内容哈希(如 C6BFPrR2_1wNbLv.webp),内容变了哈希就变,放心缓存。
最终效果
部署后跑了两轮 Lighthouse:
| 指标 | 优化前 | 优化后 (桌面) | 优化后 (移动) |
|---|---|---|---|
| Performance | 34 | 95 | 76 |
| FCP | 1,633ms | 582ms | 2,229ms |
| LCP | 4,043ms | 1,452ms | 5,604ms |
| TBT | 1,356ms | 0ms | 0ms |
| Total KB | 4,148KB | 797KB | ← |
| Speed Index | 7,720ms | 1,109ms | 2,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,评论区在滚动到附近时才请求。一劳永逸,好耶!
以下是可爱的评论们:

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