给博客补上 favicon 后,一个自然的问题是:页面访问还有哪里慢?2026 年 10 月 30 日的检查中,首页 HTML 很小,服务器本机响应也快;但浏览器搜索仍可能等上两分钟,索引就绪后的查询也会卡顿。仅凭“首页能打开”或一个下载总耗时,很难发现这些差异。
这是“博客访问速度优化”专栏的第一篇。先把性能数据怎么取得、每个指标回答什么问题讲清楚,后续再逐项记录优化。本文数值来自当天会话及仓库 docs/PERFORMANCE-2026-10-30.md 的测量记录;代码是整理后的复现示例。当天实际完成了图标修复和性能诊断,搜索拆分、Worker 与缓存调整仍是后续方案。
先定义“快”:三个时钟不能混在一起
访问博客至少有三个值得测的阶段:收到文档和资源;看到可阅读的内容;操作后得到可用结果。下载完成不等于绘制完成,搜索框出现也不等于搜索已经可用。
| 层次 | 指标 | 回答的问题 | 获取方式 |
|---|---|---|---|
| 连接与响应 | DNS、TCP、TLS、TTFB | 时间花在建立连接,还是等待响应? | curl、Network Timing |
| 传输 | 总耗时、响应体大小、压缩编码 | 是资源大,还是这条线路传输慢? | curl、响应头、Resource Timing |
| 首屏 | FCP、LCP | 首次出现内容、最大内容何时绘制? | Chrome Performance |
| 稳定性 | CLS | 内容是否在阅读时意外移动? | Performance、真实用户采样 |
| 交互 | INP、长任务 | 用户操作后页面能否及时绘制反馈? | Performance、真实用户采样 |
| 业务可用性 | 索引就绪时间、输入到结果耗时 | 搜索何时能用,输入后多久有结果? | 自定义计时、DOM 观察 |
Core Web Vitals 当前关注 LCP、INP、CLS。“良好”参考线分别为 2.5 秒、200 毫秒、0.1,网站评价看真实访问的第 75 百分位,并区分移动端和桌面。一次本机 LCP 达标,不代表全站用户都达标;FCP 与 TTFB 则帮助解释加载过程。指标口径见 Web Vitals 官方说明。
实验数据适合定位和比较改动;真实用户数据适合观察不同设备、地区与访问路径的分布。这次没有 CrUX 或全站 p75,也没有 Lighthouse Performance 分数。后续若接入真实用户采样,应同时记录页面类别、设备类别与版本,避免平均值掩盖最慢的一组用户。
建立一份能重做的测量记录
开始前记录 URL、时间、页面版本、浏览器、设备、视口、CPU 与网络模拟、代理路径、缓存设置和并行下载情况。比较前后版本时,内容规模也要固定。
这次 HTTP 采样从 Windows 写作机直连域名,明确禁用环境代理并验证 TLS;服务器对照保留同一域名和 SNI,把地址解析到本机 Caddy。浏览器另用 Windows Chrome 141 的独立 context,桌面视口为 1280×684,默认 CPU,没有网络限速,使用浏览器现有网络配置。它与 curl 的直连路径不能未经核实就视为相同。
“冷加载”在本次浏览器记录中表示绕过 HTTP 缓存;DNS、TLS 会话和连接池仍可能复用。热加载则要允许缓存,并确认响应是否来自内存、磁盘或协商验证。只把同一个 URL 再请求一次,不足以称为热缓存实验。
后续复测可采用以下记录表;每个条件先做数次重复测量,保存全部样本及中位数、范围。少量样本不适合包装成稳定的尾延迟结论。
| 字段 | 示例或填写内容 |
|---|---|
| 版本与内容规模 | generation / commit、索引条数、解压体积 |
| 访问环境 | 地点、网络、代理、浏览器版本、设备 |
| 条件 | 缓存、视口、CPU 降速、网络限速、并行任务 |
| 目标 | 首页 / 长文 / 搜索页 / 固定查询词 |
| 原始结果 | 每次耗时、字节数、状态码、错误 |
| 产物 | 响应头、HAR、trace、计时 JSON |
HAR 或 trace 分享前需检查 Cookie、URL 参数和请求内容。此次 trace 文件导出受工具路径限制,保存的是工具返回摘要和 Timing 数值,没有可下载的完整原始 trace。
用 curl 把一次访问拆成阶段
在 Windows PowerShell 中执行下面的命令。显式写 curl.exe ,避免旧版 PowerShell 的别名影响行为。先用 curl.exe --version 确认版本和压缩支持。
curl.exe --noproxy '*' -sS --connect-timeout 10 --max-time 60 `
-o NUL -D response-headers.txt `
-w 'status=%{http_code} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} bytes=%{size_download}\n' `
https://blog.envoys9.top/
这测的是一次 GET,请求体被丢弃,响应头留在当前目录。不要用 HEAD 请求的耗时代替真实下载。如果实际用户通过代理访问,就另做代理条件的采样;这里的 --noproxy 只用于明确直连基线。
原会话的一次未压缩结果为:
status=200 dns=0.125 tcp=0.313 tls=0.766
ttfb=0.969 total=1.157 bytes=20445
这些时间单位是秒,且多数是从请求开始累计的时间点,不能直接相加。对于这次无重定向、独立 HTTPS 连接的样本,可以近似分解为:
| 阶段 | 计算 | 本次结果 |
|---|---|---|
| DNS | time_namelookup | 125 ms |
| TCP 建连 | time_connect − time_namelookup | 188 ms |
| TLS 握手 | time_appconnect − time_connect | 453 ms |
| TLS 后至首字节 | time_starttransfer − time_appconnect | 203 ms |
| 首字节后传输 | time_total − time_starttransfer | 188 ms |
其中 203 ms 包含请求发送、网络往返和服务端处理,不能直接叫“服务器计算耗时”。复用连接、代理或重定向会改变这种拆分的解释,应结合响应头和连接信息分析。字段定义以 curl 的 write-out 文档 为准。
首次尝试 --compressed 时,现场 Windows curl 7.55.1 报不支持,于是改用 Python 显式请求 gzip。这个失败值得保留:工具本身的能力不同,测到的传输字节也会不同。支持该功能的 curl 可以增加 --compressed ,但必须把响应的 Content-Encoding 一起记下。
做一个服务器本机对照
在服务器 shell 上执行,前提是本机 443 确实由目标 Caddy 监听:
curl --noproxy '*' --resolve blog.envoys9.top:443:127.0.0.1 \
-sS --compressed -o /dev/null \
-w 'status=%{http_code} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://blog.envoys9.top/
--resolve 保留 URL 中的域名、Host 和 TLS SNI,不必用跳过证书验证来访问 IP。它移除了公网路径和正常 DNS 查询,适合作对照,不能代表读者端速度。
首轮采样中,首页 gzip 为 9,329 B、解压 20,445 B,客户端一次下载约 0.64 s;服务器本机首页约 0.072 s。搜索索引客户端两次下载约 32.08 / 42.89 s,服务器本机约 0.208 s。这支持继续检查公网连接与传输成本,而不是先猜静态站存在动态计算瓶颈。
字节数也有三种口径
浏览器 Resource Timing 中, encodedBodySize 是编码后的响应体, decodedBodySize 是解码后的响应体, transferSize 还涉及响应头。它们不是同一个数,也不是完整链路上所有协议开销的统计。
这次首轮索引有 334 条记录,gzip 约 2.31 MB、解压约 9.46 MB;同日晚些时候,内容增加到 1,064 条,浏览器协商 zstd 后为 3,644,023 B,解压 19,392,498 B。条数、内容和编码都变化了,这两组不能用来计算“优化提升比例”。
在待测页打开 Chrome DevTools Console,资源完成后运行:
const rows = performance.getEntriesByType('resource').map(r => ({
url: r.name,
startMs: +r.startTime.toFixed(1),
durationMs: +r.duration.toFixed(1),
waitMs: r.requestStart ? +(r.responseStart - r.requestStart).toFixed(1) : null,
bodyMs: r.responseStart ? +(r.responseEnd - r.responseStart).toFixed(1) : null,
transferBytes: r.transferSize,
encodedBytes: r.encodedBodySize,
decodedBytes: r.decodedBodySize,
protocol: r.nextHopProtocol
}));
console.table(rows);
这段代码只读取已记录的数据,不发新请求。跨域资源可能因 Timing-Allow-Origin 限制而返回零值;缓存也会影响传输大小。看到零不能脱离 Network 面板就断言“资源为空”或“速度无限快”。长时间采样可在开始前扩大资源记录缓冲区。字段与限制见 PerformanceResourceTiming。
当天一个搜索冷导航样本中,索引在导航后 6.700 s 才开始,到 142.424 s 完成,请求本身耗时 135.724 s,其中首字节等待约 8.121 s、正文传输约 127.602 s。即使 HTML 很早出现,全文搜索仍要等这段传输完成。
另外一次禁缓存的诊断性读取耗时 145.218 s。那是人为加的一次请求,不是页面自行重复下载索引的证据,也不能用作热缓存数据。
用 Performance 看首屏,而不是盯着 load
打开 DevTools 后,可通过 Ctrl+Shift+P 搜索并打开 Performance 面板;确认 CPU、网络、缓存条件,录制一次重新加载。用 Network 的瀑布图定位慢请求,再沿 Performance 时间线查看 FCP、LCP、布局和长任务。具体界面和能力见 Chrome Performance 文档。
优先看 LCP 对应的具体元素,以及它之前的文档等待、资源发现与渲染阻塞。 load 只能告诉你页面加载事件何时发生,无法代替“主要内容何时可读”。
| 页面与条件 | LCP | TTFB | 补充观察 |
|---|---|---|---|
| 首页,绕缓存,第 1 次 | 2.601 s | 1.752 s | CLS 0 |
| 首页,绕缓存,第 2 次 | 1.439 s | 0.615 s | CLS 0 |
| 首页,允许缓存 | 0.232 s | 0.002 s | 文档命中缓存 |
| ABAP 长文,另一个标签下载索引 | 5.803 s | 0.170 s | 存在并行下载干扰 |
| ABAP 长文,停止其他下载后 | 3.309 s | 1.063 s | load 5.299 s |
| 首页,390×844、CPU 4 倍降速 | 1.771 s | 0.217 s | 未做网络限速 |
小视口加 CPU 降速只是模拟条件,不是实机手机或 4G 测试。两次长文测量也不是严格 A/B;停止干扰后仍存在网络波动。
首页只有 156 个 DOM 元素,没有首屏图片或第三方脚本。两份 CSS 解压后分别约 17.9 KB 和 2.7 KB;Chrome 的 RenderBlocking 分析给出约 95 / 47 ms 的潜在 LCP 节省。这是分析估计,不是实施后的收益,不能把请求里的全部等待都算成“删除 CSS 就能省掉的时间”。
长文则有约 6,287 个 DOM 元素,HTML 解压约 743 KB,zstd 后约 191 KB,一次布局约 110 ms;无并行下载的复测中,两份 CSS 各耗时约 1.83 s,其中响应等待约 1.79 s。相较单次布局,网络与资源等待更值得继续追踪。
favicon 在这两次首页采样中于 load 之后请求,且命中缓存。补齐图标解决了原来的 404,但这些证据不支持把首屏速度变化归功于图标修复。
自动录制超时后的补充证据
一次搜索页冷加载的 FCP 达到 15.316 s,超过工具的 10 秒导航等待,自动 trace 没有产生完整报告。工具超时不等于浏览器停止请求。待请求结束后,可以读取 Navigation Timing:
const n = performance.getEntriesByType('navigation')[0];
console.table({
ttfbMs: n.responseStart - n.startTime,
responseEndMs: n.responseEnd,
domContentLoadedMs: n.domContentLoadedEventEnd,
loadMs: n.loadEventEnd || null
});
console.table(performance.getEntriesByType('paint').map(p => ({
name: p.name, startMs: p.startTime
})));
这些记录可以补充导航与 FCP,不能重建丢失的完整 CPU 火焰图,也不包含完整的 LCP、INP 评价。以后测这种长请求,应增加录制窗口、保存原始结果,而不是反复刷新制造更多并行下载。
搜索要测“就绪”和“响应”两个阶段
搜索页首次导航的 FCP 曾为 1.156 s,但索引下载远远没有结束。导航后 56.9 s 输入 ABAP ,索引完成后仍没有结果;再次触发输入才显示 584 条结果。
对应的主题源文件为 themes/PaperMod/assets/js/fastsearch.js :当时版本约 110–113 行在 Fuse 未就绪时直接返回,140–143 行创建 Fuse 后没有补执行当前查询,131 行却提前启用了输入框。用户看到的是可以输入的搜索框,但操作没有被完整处理。这个“就绪状态”问题,单看 LCP 很难发现。
索引就绪之后,另一个问题是同步匹配消耗主线程。测量时给真实输入框发送 input 事件,用 MutationObserver 观察结果列表更新,并临时包装实际 Fuse.prototype.search ,把总等待和纯匹配成本分开。
| 查询与条件 | 输入到结果 DOM 更新 | Fuse 同步计算 | 结果数 |
|---|---|---|---|
| ABAP,默认 CPU | 661.6 ms | 494.6 ms | 584 |
| SAP,默认 CPU | 691.8 ms | 516.5 ms | 998 |
| Emacs,默认 CPU | 1,255.6 ms | 1,091.4 ms | 595 |
| Emacs,CPU 4 倍降速 | 6,257.6 ms | 5,950.3 ms | 595 |
总耗时包含主题现有的 150 ms debounce。DOM 更新时刻也早于真正绘制;通过脚本触发的事件不等于可信用户交互,因此这张表不能标成 INP。另观察到一次 568 ms 的搜索长任务,说明需要检查主线程占用。
下面是包装匹配函数的复现示例,在索引已就绪且当前主题公开了 Fuse 构造器时使用。先安装探针,再在页面输入固定词,取完数据立即恢复;DOM 总耗时应另外测量。
const originalSearch = Fuse.prototype.search;
const searchSamples = [];
Fuse.prototype.search = function (...args) {
const start = performance.now();
try {
return originalSearch.apply(this, args);
} finally {
searchSamples.push({ query: args[0], ms: performance.now() - start });
}
};
// 在页面输入固定查询词,待结果出现后运行:
console.table(searchSamples);
// 结束测量,恢复原方法:
Fuse.prototype.search = originalSearch;
如果结果区会先显示“加载中”,MutationObserver 不能把第一次 DOM 变化当作最终完成。应以目标查询对应的结果状态为结束条件,单独记录无结果、超时和报错。记录查询词与结果数也很重要:把完整全文搜索改成仅标题搜索后,耗时下降并不自动意味着等价优化。
从证据排优先级,再设计下一次实验
这次测量把“慢”分成了几个独立问题,后续每次改动都应有对应的验收项。
| 已观察的问题 | 后续方案 | 复测重点 |
|---|---|---|
| 输入早于索引就绪时查询丢失 | 加载、失败、重试状态;就绪后执行最新输入 | 早输入、失败重试、键盘操作 |
| 全文索引体积与下载时间大 | 轻量标题/摘要索引,按需加载全文分片 | 首次可搜索时间、传输字节、检索完整性 |
| Fuse 同步计算耗时高 | Worker 与检索结构改进 | 主线程长任务、结果延迟、旧结果覆盖 |
| 缓存行为缺乏明确约定 | 版本化索引与缓存策略 | 冷/热访问、更新可见性、304 行为 |
| 长文与远距离连接成本明显 | 继续分解资源等待,评估区域缓存或分节策略 | 同版本、多网络复测,锚点与全文阅读能力 |
对同一批 1,064 条数据,标题、permalink 与最多 160 字摘要的 JSON 试算为 403,744 B,gzip 后 61,193 B。这说明轻量入口值得验证,但它尚未部署,也不能替代完整正文检索。Worker 可以减轻主线程争用,不会自动减少下载量或算法总计算量。
当时索引没有显式 Cache-Control,但有 ETag;这不能推出“每次访问都会下载全量”。缓存实验应使用正常浏览器导航核实。后续需要兼顾资源更新,不宜给会被同名覆盖的索引直接设置一年 immutable。
第一轮优化最需要留下的不是一个漂亮分数,而是一套可以反复执行的测量方法:固定条件,保留各次样本;分开网络、渲染和业务就绪;每次只检验明确的假设,再用同样的指标判断改动是否有效。