给博客补上 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 连接的样本,可以近似分解为:

阶段计算本次结果
DNStime_namelookup125 ms
TCP 建连time_connect − time_namelookup188 ms
TLS 握手time_appconnect − time_connect453 ms
TLS 后至首字节time_starttransfer − time_appconnect203 ms
首字节后传输time_total − time_starttransfer188 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 只能告诉你页面加载事件何时发生,无法代替“主要内容何时可读”。

页面与条件LCPTTFB补充观察
首页,绕缓存,第 1 次2.601 s1.752 sCLS 0
首页,绕缓存,第 2 次1.439 s0.615 sCLS 0
首页,允许缓存0.232 s0.002 s文档命中缓存
ABAP 长文,另一个标签下载索引5.803 s0.170 s存在并行下载干扰
ABAP 长文,停止其他下载后3.309 s1.063 sload 5.299 s
首页,390×844、CPU 4 倍降速1.771 s0.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,默认 CPU661.6 ms494.6 ms584
SAP,默认 CPU691.8 ms516.5 ms998
Emacs,默认 CPU1,255.6 ms1,091.4 ms595
Emacs,CPU 4 倍降速6,257.6 ms5,950.3 ms595

总耗时包含主题现有的 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。

第一轮优化最需要留下的不是一个漂亮分数,而是一套可以反复执行的测量方法:固定条件,保留各次样本;分开网络、渲染和业务就绪;每次只检验明确的假设,再用同样的指标判断改动是否有效。