博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
- 1导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
- 2博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)本文
- 3Firefly 博客实战:从零搭建任意设备可用的发布台(Vercel + GitHub API)
- 4评论区回复时表情面板被截断:一次 overflow 裁剪排查实录
- 5Firefly 博客实战:给文章页加阅读进度条与圆环百分比
- 6Firefly 博客接入友链朋友圈:从零开始的完整部署教程
- 7从零开始搭建个人图床
- 8文章中添加音乐播放器
- 9基于Firefly主题的列表封面位置改造
- 10基于rehype的博客PDF嵌入组件实现
- 11Twikoo评论系统部署踩坑记录:Vercel域名无法访问的解决之路
- 12这是我的第一篇BLOG

博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
2026-10-03,用 Chrome / Edge 无痕模式各跑了一份本博客的 Lighthouse 报告。两份报告揭出一批共性问题,外加一个诡异现象:同一个站,Chrome 滚动掉帧、Edge 完全正常(两者同为 Chromium 154)。随后完成了 A–F 六项优化/排查,最终 Lighthouse 的强制重排审计从稳定 0 分变为多次满分,Chrome 掉帧也以「浏览器设置」定案。
本文是完整复盘:每项改了什么、为什么这么改、怎么验证的、踩了哪些坑。既是给未来维护者的存档,也希望能成为一份可照抄的性能排查教程。
〇、初始状态(两份报告的关键数字)
| 指标 | Chrome | Edge | 说明 |
|---|---|---|---|
| 性能分 | 0.71 | 0.70 | 两边一致 |
| LCP | 4.6s | 5.1s | 最大失分项 |
| FCP / TBT / CLS | 1.5s / 0ms / 0.003 | 1.6s / 0ms / 0.007 | 主线程本身很干净 |
| 总传输量 | 5,349 KiB | 同 | 严重超标 |
| 渲染阻塞 CSS | 估省 220ms | 估省 250ms | 3 个 stylesheet link |
| 强制重排 | ~92ms(score 0) | ~200ms | 有内联脚本热点 |
| 图片可省 | ~1,160 KiB | ~1,163 KiB | 封面原图直出 |
问题清单(即本文的 A–F):
- A 字体:一个 3.1MB 的 TTF 在预加载
- B 封面图:330KB 原图 JPEG 直出,尺寸超显示需求 3 倍
- C 三个第三方源没有 preconnect
- D 3 个渲染阻塞的 stylesheet
- E 加载期强制重排(forced reflow)
- F 只有 Chrome 掉帧、Edge 不掉帧
A、字体子集化:3.1MB TTF → 212KB WOFF2
现象
Lighthouse 列出的最大单体资源:
/_astro/fonts/db738086d23374c1.woff2resourceSize: 3,293,520 B(3.1MB)transferSize: 约 2,088,846 B(压缩后仍有 2MB)且带 rel="preload" 高优先级,与首屏图片抢带宽根因
项目里本来就有一整套字体子集化流水线(scripts/subset-fonts.ts,pnpm build 会执行),它扫描 dist/ 全部 HTML 收集实际用到的字符,生成 woff2 子集并替换引用、删除原文件。但配置里只登记了一个装饰字体:
subsetFonts: { "--font-greatvibes": { extraChars: "" }, // 只有它}// 而全站正文用的是(fontConfig.selected):selected: ["--font-hanyi-wenhei"], // ← 没登记,直接漏网子集脚本会跳过「未在 selected/区域字体中引用」的项,于是每次构建都打印 skip,原始 TTF 原样进了部署包。
修复
src/config/fontConfig.ts 增加一行:
subsetFonts: { "--font-greatvibes": { extraChars: "" }, // 全站正文主字体(selected 引用),不配子集化会原样输出 3.1MB TTF "--font-hanyi-wenhei": { extraChars: "" },},无需改脚本——pnpm build 链路里已有 astro build && npx tsx scripts/subset-fonts.ts。
验证与注意点
- 构建日志出现
Generating subset ... 211.7 KB, original: 3216.3 KB, saved 93.4% dist/_astro/fonts/下生成e3c41cafdc6bb33b.woff2(211.7KB),原始 TTF 被脚本删除- 全站 53 个页面的
@font-face与rel=preload均已替换为type="font/woff2",HTML 中.ttf引用为 0 - 浏览器实测:
document.fonts加载成功、中文渲染无豆腐块
已知取舍:子集只包含构建时 HTML 里可见的字符。密码/加密文章解密后的字符不在其中,这些页面正文会回退到
fallbacks(PingFang/雅黑)。介意可在extraChars手动补字。
C、preconnect:三个第三方源提前建连(顺手先做了)
现象
报告写明「未预连接任何源」:图床 tu.mstzuomu.space、统计 cloud.umami.is、static.cloudflareinsights.com 都是 HTML 解析到一半才发现的,连接建立偏晚。
修复
src/layouts/Layout.astro 的 <head> 最顶部(<meta charset> 之后):
<!-- 提前建立第三方连接:图床图片、umami 统计、Cloudflare beacon 都在 HTML 解析中途才被发现 --><link rel="preconnect" href="https://tu.mstzuomu.space" /><link rel="preconnect" href="https://static.cloudflareinsights.com" />{analyticsConfig?.umamiAnalytics?.websiteId && ( <link rel="preconnect" href="https://cloud.umami.is" />)}注意:这三行会改变 HTML 的行号——后文 E 项定位 Lighthouse 坐标时会看到所有行号整体偏移,此为预期内现象。
B、封面图:接入自建图床的尺寸处理 API
现象与根因
封面走远程 <img> 直出:src/components/common/CoverImage.astro 对所有 http(s) 图片输出裸 <img>,不经过 Astro 的优化管道。单张封面 2169×569 JPEG 约 330KB,实际显示只有 1350×300——格式(该转 WebP)和尺寸(超 3 倍)双重浪费,Lighthouse 估算 5 张可省 1.16MB。
排查:图床支持什么转换?
图床是自建的 CloudFlare-ImgBed(CF Workers + Telegram 存储)。探测过程:
# 1. 直接试尺寸参数 → 403curl "https://tu.mstzuomu.space/file/文章/xxx.jpg?width=1656&fallback=original"# → 403 "Image resizing is disabled" # 功能存在但默认关闭!
# 2. 查官方文档(cfbed.sanyue.de/api/file.html)# 支持 width/height/fit/fallback 参数;JPEG/PNG/WebP 源 Worker 部署可处理;# 开启路径:dashboard → 系统设置 → 安全设置 → 访问管理 → 图片尺寸处理# 允许尺寸:留空 = 任意合法值
# 3. 后台开启后复测width=1656 → 141KB (原 330KB,-57%)width=828 → 48.5KB (-85%)format=webp → 被忽略(该版本只改尺寸不转格式)博客端实现(4 个文件)
1. 配置开关(src/config/siteConfig.ts):
imageOptimization: { // ...原有配置 // 支持尺寸处理的远程图床域名(后台需开启"图片尺寸处理") resizeHosts: ["tu.mstzuomu.space"],},2. 工具函数(src/utils/image-utils.ts):
export const RESIZE_WIDTHS = [828, 1656] as const;
export function canResizeRemote(src: string): boolean { if (!/^https?:\/\//.test(src)) return false; const hosts = siteConfig.imageOptimization?.resizeHosts || []; if (hosts.length === 0) return false; try { return hosts.includes(new URL(src).hostname); } catch { return false; }}
export function buildResizeUrl(src: string, width: number): string { try { const u = new URL(src); u.searchParams.set("width", String(width)); u.searchParams.set("fallback", "original"); return u.toString(); } catch { return src; }}3. 渲染(CoverImage.astro 远程分支):白名单域名输出 srcset 双档 + 真实显示宽度的 sizes + 回退数据属性:
const resizeSizes = preview ? "(max-width: 768px) 100vw, 770px" // 列表卡桌面约 680-770px : "(max-width: 768px) 100vw, 100vw"; // 详情页封面全宽
<img src={remoteImgSrc} /* 1656 档 */ srcset={resizeSrcset} /* "…828 828w, …1656 1656w" */ sizes={useResize ? resizeSizes : undefined} data-resize-fallback={useResize ? remoteSrc : undefined} .../>
sizes有个坑:本地图沿用的旧值写死了桌面320px,单档产物无影响,但双档 srcset 下 Retina 屏会错选 828w 导致发糊——所以尺寸变体必须用真实显示宽度(770px)单独给一套。
4. 保险丝(CoverImage.astro 脚本的 onError):尺寸版加载失败(如图床后台开关被关)时一次性回退无参数原图:
const resizeFallback = img.dataset.resizeFallback;if (resizeFallback && !img.dataset.resizeRetried) { img.dataset.resizeRetried = 'true'; img.addEventListener('load', hideLoading, { once: true }); img.addEventListener('error', onError, { once: true }); img.removeAttribute('srcset'); img.src = resizeFallback; return;}验证
- 浏览器 Network:封面请求全部带
?width=828(DPR1 选小档),加载成功 - 保险丝实测:手动把 src 改成 404 → 自动回退原图 → 加载成功、无错误态、不循环
- mi-fds 域名的 2 张封面不在白名单,保持原样输出(预期行为)
D、CSS 内联:3 个渲染阻塞 link 归零
现象
首页 head 里 3 个阻塞请求:Layout.css(原始 196KB / 传输 28.7KB)、MainGridLayout.css、全局 CSS,Lighthouse 估算拖慢首屏 220–250ms——浏览器必须等它们下载解析完才画第一笔。
修复
astro.config.mjs 顶层(Astro 配置,不是 vite.build!):
// Astro 构建行为:全部 CSS 内联进 HTML(非 vite.build)build: { inlineStylesheets: "always",},踩坑记录:我第一次把这个键写进了
vite: { build: { ... } }块里——那是 Vite 的配置,不认识inlineStylesheets,静默无效,白构建一轮。判断依据是检查产物里<link rel="stylesheet">是否归零。
前置安全检查
内联前必须确认三个 CSS 没有 url() 引用(否则内联到嵌套路径页面会相对路径断裂):
# 扫描 dist/_astro/*.css 的 url( 引用# 结果:三个阻塞 CSS 均为 0 处 → 内联无风险# (带 url() 的 KaTeX/fancybox 是按需加载的非阻塞样式,不在范围)验证与取舍
- 首页
<link rel="stylesheet">数量:3 → 0 - HTML 原始体积 370KB → 608KB(+237KB ≈ 三者之和;gzip 后约 +38KB)
- 文章页直连、swup 切页、视觉截图均正常
- 取舍:CSS 不再单独缓存,每多看一页 HTML 多带约 38KB——换来首屏少 3 个请求,对博客访问形态是划算的
- 残余:文章页 body 尾部还有个 8.9KB 的
twikoo-custom.css(评论区样式,解析位置在正文后,不挡首屏)和 fancybox 两个运行时注入的 link,都不在阻塞路径上
E、强制重排归零(本文最重的一节)
方法论:怎么抓到强制重排的真凶
强制重排 = JS 在脚本执行中读取几何/样式,浏览器被迫当场同步全量结算。坐标解读是最大的坑,先立规矩:
1. 本地跑 Lighthouse 单审计(线上报告的行号会随每次构建漂移,必须在当前产物上重测):
# 用 Edge 当 Lighthouse 的浏览器(本机没装独立 Chrome 时)$env:CHROME_PATH = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"npx --yes lighthouse "http://127.0.0.1:8931/" ` --only-audits=forced-reflow-insight --output=json --output-path=lh.json ` --chrome-flags="--headless=new"2. 以 CDP Tracing 全栈为准(Lighthouse 只给聚合后的 line/column,且行号 0-based、列号时而脚本内相对——单独解坐标会绕死):
// Playwright run_code:抓带堆栈的 Layout/UpdateLayoutTree 事件const cats = 'disabled-by-default-devtools.timeline,' + 'disabled-by-default-devtools.timeline.stack,' + 'disabled-by-default-devtools.timeline.frame';// Tracing.start(ReportEvents) → goto + 等待 → Tracing.end// 过滤 name 为 Layout/UpdateLayoutTree 且有 stackTrace 的事件,// 按 dur 排序;stack[0] 是最内层帧 = 真正读取者3. 模拟 Lighthouse 的测量条件(否则数字对不上):Lighthouse 默认移动视口 + 4 倍 CPU 降速——
await client.send('Emulation.setDeviceMetricsOverride', { width: 412, height: 823, deviceScaleFactor: 2.625, mobile: true });await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });六个根因与对应修复
E1. 解析期读 window.innerWidth(移动端最大单点)
读视口宽度需要先确定滚动条状态,移动模拟下会触发首次全量布局。三个热点全是这一个病:
| 位置 | 代价(移动+4x) | 修复 |
|---|---|---|
| PostPage 立即执行脚本 | ~84ms | 移动/桌面默认布局本就相同,宽度分支冗余,只按 localStorage 偏好恢复 |
壁纸 sync() | ~26ms | window.matchMedia('(min-width: 1024px)').matches |
ScrollDownIndicator updatePosition | ~31ms | 同上改 matchMedia |
E2. window.pageYOffset || document.documentElement.scrollTop 老式写法
加载时 pageYOffset 为 0(假值)→ 短路求值走到 documentElement.scrollTop → 几何读取强制布局。5 处统一改 window.scrollY(滚动位置干净时读它不强制):
ScrollDownIndicator.astro/Navbar.astro/BackToTop.astrofullscreen-wallpaper-utils.ts(×2)- 另外
scroll-utils.ts和grid-layout-utils.ts里还有不带||的直接读法,一并处理
E3. document.fonts.ready getter 本身就强制样式结算
移动端单次可达 300ms+。注意 typeof document.fonts.ready 这个 guard 本身就访问了 getter——把判断整体挪进首绘后:
if (document.fonts) { // 只判断对象存在(不触发) afterFirstPaint(() => { document.fonts.ready.then(...) // getter 访问推迟到 FCP 后 });}E4. PostPage 的 transition 开关体操
原代码注释自己写着「强制重排后恢复过渡动画」——transition:none → 换类 → container.offsetHeight(故意强制)→ 恢复。
关键认知:脚本在解析期执行,元素还没有计算样式,transition 根本不可能触发——体操在初始加载场景纯属多余。改为纯类名写入;即便补上 rAF 恢复过渡的版本,Lighthouse 仍会把异步归因的帧级工作记账 ~84ms(实测两版都 ~84)。首绘后的切页注入场景由 performance.getEntriesByType('paint') 检测后跳过体操。
E5. 读写交错(改了 DOM 又立刻查询)
- 壁纸
sync():拆两阶段——读阶段用getElementsByClassName/getElementsByTagName(纯 DOM 查询,不触发样式结算)收集全部 slot+tpl,写阶段只插入;materialize改为插入前从frag取占位层,删掉插入后的slot.querySelector - 歌词高亮
updateLrcHighlight:先缓存offsetTop/clientHeight几何值,再改 class,滚动用缓存值 - 封面动画重启的
void ui.cover.offsetWidth、PRIMARY_COLOR顶层getComputedStyle:都挪到首绘门闩后
E6. 新工具 src/utils/after-first-paint.ts(本轮核心基建)
// 设计要点(缺一不可,都有实测依据):// 1. 轮询 performance.getEntriesByType('paint') 中的 first-contentful-paint,// 而不是双 rAF——弱性能设备首帧可能被丢弃,rAF 链会在真实绘制前跑完;// 2. 每帧只放行一个回调(串行队列)——所有回调挤同一帧会互相弄脏:// 前一个的写入让后一个的读取再次强制结算(实测 Waves 读前序回调的脏样式 29-31ms);// 3. 超时兜底 120 帧,防 paint 条目异常时回调永不执行。export function afterFirstPaint(cb: () => void): void { /* 见仓库源码 */ }加载期读取者全部改走它:滚动初始化、分类栏、波浪、壁纸视差/模糊、侧栏几何缓存、网格列数、fonts.ready、音乐封面动画重启……(共 14 个文件,见 commit d6ceae0a)
排查过程中的三个大坑(后人必读)
- MCP 浏览器窗口被遮挡 = 测量全部失真:遮挡窗口的 Chromium 会暂停绘制 + rAF 压到 2fps,表现为 paint 条目永远为空、所有门闩「不触发」、滚动功能「全坏」。
page.bringToFront()一秒复活(101fps + paint 条目齐全)。做帧率/绘制相关测量前必须先置前窗口。 - 先做基线 A/B 再怀疑自己:修到中途滚动类效果全灭,用
git stash丢弃改动构建基线版同环境实测——基线一模一样,证明非本次回归(是环境问题),避免了整段回滚。 - PowerShell 引号吞噬:复杂内联脚本(python
-c、正则带引号)会被啃得面目全非,一律落盘成临时脚本文件执行。
结果
| 基线 | 最终 | |
|---|---|---|
| Lighthouse forced-reflow | 每次 score 0,~121ms | 多次 score 1 / 0.0ms,最差 18.6ms |
| 移动+4x CDP 实测 | 381–481ms | 主要热点归零 |
| 残余 | — | 仅 Astro 水合运行时(client.js,8–16ms,库代码) |
改动:14 文件修改 + 1 新文件,+280/−121,commit d6ceae0a。
F、只有 Chrome 掉帧?——浏览器设置级根因
症状与排查设计
同机、同站、同引擎(Chromium 154),只有真 Chrome 掉帧,Edge 正常。这个「同引擎不同表现」本身就是最强线索:嫌疑必然在两者各自的运行环境,而非站点代码。
第一轮:站点侧压力矩阵(本机 Chromium,前台窗口)
| 组合 | 结果 |
|---|---|
| 1440×900 DPR1 滚动 | 99.9 FPS,0 掉帧 |
| 1728×1080 DPR1.5 + 文章页 | 99.9 FPS,0 掉帧 |
| + CPU 4 倍降速 + 全屏模糊渐变 | 99.9 FPS,0 掉帧 |
站点在默认设置的 Chrome 系浏览器下压不出掉帧。
第二轮:真 Chrome vs 真 Edge 同机对打(决定性实验)
用 Playwright 分别以临时 profile 启动两个真实浏览器 exe,跑完全相同的滚动帧率 harness(4 秒三角波滚动,统计帧间隔):
// 核心:launchPersistentContext + executablePath,各自临时 user-data-dirconst ctx = await chromium.launchPersistentContext(tempDir, { executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe", // 或 msedge.exe headless: false, viewport: { width: 1728, height: 1080 }, deviceScaleFactor: 1.5,});// 页面内 harness:rAF 记录帧间隔,每帧 scrollTo 三角波滚动 4s,// 统计 fps / jank(>25ms) / jank(>50ms) / worst / p95结果:Chrome 99.9 FPS / Edge 99.9 FPS,双双零掉帧。
这一步同时排除了「Windows 每应用 GPU 指派」(系统级设置对 exe 生效,测试用的就是你的真 Chrome)。
第三轮:读真 Chrome 的配置文件(只读排查,不用开浏览器)
# Chrome: %LOCALAPPDATA%\Google\Chrome\User Data\Local State# Edge: %LOCALAPPDATA%\Microsoft\Edge\User Data\Local Statejson.load(...)["hardware_acceleration_mode"]| hardware_acceleration_mode | |
|---|---|
| 你的 Chrome | {"enabled": false} ← 硬件加速是关的! |
| 你的 Edge | 键不存在(未动过 = 默认开),previous: true |
扩展数 0、退出状态正常——唯一差异就是这个开关。
根因与修复
Chrome 关闭了硬件加速 → 6 层 backdrop-filter 毛玻璃、全屏壁纸滚动模糊渐变(blurRamp)、波浪 canvas 的全部合成工作由 CPU 软渲染 → 滚动掉帧;Edge 默认开 → GPU 合成 → 流畅。
修复(用户操作,一分钟):
chrome://settings/system→ 开启「使用硬件加速模式」→ 重启 Chromechrome://gpu确认 Canvas / Compositing / Rasterization / Video Decode 全部 Hardware accelerated- 实测确认恢复正常 ✅
为什么我的自动化测试一直测不出问题:新开的 profile 用默认值(硬件加速开),测的是健康态的 Chrome。真凶藏在你日常 profile 的 Local State 里。
五、成果总表
| 项 | 根因 | 修复 | 效果 |
|---|---|---|---|
| A 字体 | 主字体没登记子集化 | subsetFonts +1 行 | 3.1MB → 212KB(-93%) |
| C 连接 | 三源无 preconnect | head +3 行 | 图床/统计早建连 |
| B 封面 | 远程图裸出 + 图床功能未开 | 图床开关 + srcset 双档 + 回退保险丝 | 单张 330KB → 48.5–141KB |
| D CSS | 3 个阻塞 link | inlineStylesheets: "always" | 阻塞请求 3 → 0,首屏约 -250ms |
| E 重排 | innerWidth/scrollTop/fonts.ready/体操/读写交错 | FCP 串行门闩 + 14 文件修复 | forced-reflow score 0 → 多次 1 / 0ms |
| F 掉帧 | Chrome 硬件加速被关 | 浏览器设置一键开启 | Chrome 恢复流畅 ✅ |
相关提交:3691383a(A+C) → f875aef9(D) → d6ceae0a(E),另有若干 chore 数据刷新。
六、可复用的工具箱
# 1. 单审计 Lighthouse(本机没独立 Chrome 时用 Edge)$env:CHROME_PATH = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"npx --yes lighthouse "http://127.0.0.1:8931/" --only-audits=forced-reflow-insight ` --output=json --output-path=lh.json --chrome-flags="--headless=new"// 2. CDP 带栈追踪骨架(Playwright run_code)// cats 含 disabled-by-default-devtools.timeline.stack;// Tracing.start(ReportEvents) → goto → end;// 事件过滤 Layout/UpdateLayoutTree + stackTrace,stack[0]=最内层帧// 3. 滚动帧率 harness(浏览器控制台可直接粘贴)// rAF 记帧间隔 + 4s 三角波 scrollTo → fps/jank25/jank50/worst/p95# 4. 浏览器硬件加速状态(只读)import jsonls = json.load(open(r"C:\Users\<你>\AppData\Local\Google\Chrome\User Data\Local State"))print(ls.get("hardware_acceleration_mode")) # {"enabled": False} 即被关闭七、测量经验清单
- Lighthouse 默认移动视口 + 4x CPU 降速,数字比桌面裸测大数倍——跨环境对比先对齐测量条件
- 做绘制/帧率测量前
page.bringToFront()——被遮挡的 Chromium 窗口暂停绘制、rAF 降到 2fps、paint 条目为空 - Lighthouse 的 line/column 只当粗定位,真凶以 CDP Tracing 的完整栈为准
- 改性能代码中途若行为异常,先
git stash建基线同环境对照,再决定是不是自己的锅 - 复杂内联脚本别塞 PowerShell 命令行——引号会被啃,落盘执行
- 同引擎双浏览器表现不一致 → 优先怀疑各自的设置与 profile,直接读
Local State/PreferencesJSON
(完)
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




