Astro 构建再提速:把侧边栏从组件改成字符串生成器,13.4s → 6.9s

上一篇把构建从 31s 干到 13.4s 之后,剩余的大头就很明显了:每页都要重新渲染一遍 ~195KB 的侧边栏。这篇记录把侧边栏从 Astro 组件改成纯字符串生成器的过程——包括中途踩的两个坑(日期格式化居然要 14ms/页、Astro 的属性转义和文本转义规则不一样),以及如何用字节级 diff 保证输出零变化。最终 13.4s → 6.9s,侧边栏输出与改前逐字节一致

现状:侧边栏是最后的成本大头

先量化。临时把 BaseLayout 里的 <Sidebar data={sidebar} /> 注释掉,对比构建:

指标优化前(13.4s)去掉侧边栏后
文章页平均耗时23ms3.2ms
列表页平均耗时7.6ms2.1ms
整站构建13.4s6.0s

侧边栏占了 ~7.4s。原因很直白:侧边栏要在 4 个视图(文件树/搜索/时间线/分类标签)里输出 220 篇文章 × 1000+ 个链接,数据层虽然做了构建期缓存,但 Astro 模板机制每页都要完整执行一遍

方案选择:为什么不用 AstroContainer

理想方案是在构建时把侧边栏渲染一次存缓存,跨页复用。三条路:

  1. 字符串生成器:用 TS 函数拼 HTML 替换组件——无容器依赖,dev/prod 行为一致,数据现成(每页 frontmatter 里已有 SidebarData);
  2. AstroContainer 预渲染:集成 hook 里 renderToString 一次——模板零改动,但集成 hook 里拿不到 astro:content 数据(getCollection 只能在页面渲染上下文调用),dev/prod 还要双路径;
  3. 客户端补高亮:渲染一次 + inline JS 加 is-current——改动最小,但无 JS 时高亮丢失,而且同样卡在方案 2 的数据访问问题。

选 1。侧边栏 HTML 只依赖 SidebarData + currentPath(唯一差异是当前链接的 is-current class),完全确定性输出,字符串生成器天然合适。

实现:照抄模板,然后被两个坑教做人

写了 themes/ark/src/lib/sidebar-html.ts,把 Sidebar.astro 的 4 个视图逐行翻译成字符串拼接。第一版跑起来,构建……还是 13s,文章页还是 22ms?!

给生成器加分段计时,真相大白:

[DEBUG sb] search: 0.3ms    ← 文件树 + 搜索区,飞快
[DEBUG sb] timeline: 6.1ms
[DEBUG sb] taxonomy: 17.9ms
[DEBUG sidebar] buildMs=25.1  ← 整个生成器

坑 1:partsInTZ 的 6 次 .find() 搜索视图 220 次 + 时间线 440 次 fmtDate 调用,每次都要对 formatToParts() 的结果数组做 6 次线性查找,实测 21µs/次,660 次 ≈ 14ms。esc() 反而只要 0.03µs,字符串拼接 2600 段只要 0.27ms——之前给 esc 加快速路径纯属白忙。

修法分两层:

  • partsInTZ 改成单遍 switch 解析(21µs → ~4µs);
  • 更狠的一招:日期字符串在构建期缓存里预计算buildSidebarBase 本来就是每个构建算一次,顺手把每篇文章的 ymd/year/md 三个字符串存进去,生成器直接读,每页 660 次 fmtDate0 次

生成器从 21ms 掉到 ~1ms,构建 6.9s。

坑 2:Astro 的序列化细节。 想要字节级一致,还得复刻三个细节,都是我靠 diff 逐个揪出来的:

  1. 元素间无空白——组件输出里 </section><section> 之间没有任何换行缩进,Astro 会剥离模板里的空白文本节点;
  2. 空字符串属性渲染为裸属性——data-admin-page(不是 ="");
  3. 属性转义 ≠ 文本转义——属性值只转义 & < > "(双引号内 ' 安全),文本才转义全部 5 个字符。所以生成器拆成 escAttr / escText 两个函数。

验证:字节级 diff

由于输出完全确定,验证可以做得非常硬:把改前构建的 6 个代表页面(文章页/首页/标签页/404/设置/归档)存下来,提取侧边栏块,与改后逐字节对比:

old len: 263860 new len: 263860
IDENTICAL

全部 6 个页面(包括整页,不止侧边栏)完全一致。dev 模式冒烟:文件树展开、搜索、时间线、is-current 高亮定位、导航脚本、data-admin-page 全部正常。

结果

指标优化前优化后
侧边栏生成耗时21ms/页~1ms/页
文章页平均耗时23ms~3ms
列表页平均耗时7.6ms~2ms
整站构建13.4s6.9s

从最初的 31s 到 6.9s,共 4.5 倍提速。侧边栏输出 263860 字节与改前逐字节一致,Sidebar.astro 组件已删除(单实现,无漂移风险)。

经验总结

  1. 预计算要下放到缓存层,而不是渲染层。数据层缓存(Astro 模板)救不了模板层的重复执行;但把格式化结果塞进数据缓存,渲染层就能归零;
  2. 别信直觉,给代码分段计时。esc 快速路径、数组 join 这些”明显该优化”的点全是无关紧要的,真正的大头是藏在 partsInTZ 里 6 次 .find() 的 14ms;
  3. 字节级 diff 是重构安全网。字符串生成器的最大风险是标记复刻不精确,而确定性的输出让”逐字节一致”从口号变成可执行的验证脚本;
  4. Astro 的转义是分场景的。属性只转 & < > ",文本转 5 个字符,空字符串属性输出裸属性——做这类等价重写前,先翻一遍目标 HTML 确认序列化规则。