Astro 构建再提速:把侧边栏从组件改成字符串生成器,13.4s → 6.9s
上一篇把构建从 31s 干到 13.4s 之后,剩余的大头就很明显了:每页都要重新渲染一遍 ~195KB 的侧边栏。这篇记录把侧边栏从 Astro 组件改成纯字符串生成器的过程——包括中途踩的两个坑(日期格式化居然要 14ms/页、Astro 的属性转义和文本转义规则不一样),以及如何用字节级 diff 保证输出零变化。最终 13.4s → 6.9s,侧边栏输出与改前逐字节一致。
现状:侧边栏是最后的成本大头
先量化。临时把 BaseLayout 里的 <Sidebar data={sidebar} /> 注释掉,对比构建:
| 指标 | 优化前(13.4s) | 去掉侧边栏后 |
|---|---|---|
| 文章页平均耗时 | 23ms | 3.2ms |
| 列表页平均耗时 | 7.6ms | 2.1ms |
| 整站构建 | 13.4s | 6.0s |
侧边栏占了 ~7.4s。原因很直白:侧边栏要在 4 个视图(文件树/搜索/时间线/分类标签)里输出 220 篇文章 × 1000+ 个链接,数据层虽然做了构建期缓存,但 Astro 模板机制每页都要完整执行一遍。
方案选择:为什么不用 AstroContainer
理想方案是在构建时把侧边栏渲染一次存缓存,跨页复用。三条路:
- 字符串生成器:用 TS 函数拼 HTML 替换组件——无容器依赖,dev/prod 行为一致,数据现成(每页 frontmatter 里已有 SidebarData);
- AstroContainer 预渲染:集成 hook 里
renderToString一次——模板零改动,但集成 hook 里拿不到astro:content数据(getCollection只能在页面渲染上下文调用),dev/prod 还要双路径; - 客户端补高亮:渲染一次 + 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 次fmtDate→ 0 次。
生成器从 21ms 掉到 ~1ms,构建 6.9s。
坑 2:Astro 的序列化细节。 想要字节级一致,还得复刻三个细节,都是我靠 diff 逐个揪出来的:
- 元素间无空白——组件输出里
</section><section>之间没有任何换行缩进,Astro 会剥离模板里的空白文本节点; - 空字符串属性渲染为裸属性——
data-admin-page(不是=""); - 属性转义 ≠ 文本转义——属性值只转义
& < > "(双引号内'安全),文本才转义全部 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.4s | 6.9s |
从最初的 31s 到 6.9s,共 4.5 倍提速。侧边栏输出 263860 字节与改前逐字节一致,Sidebar.astro 组件已删除(单实现,无漂移风险)。
经验总结
- 预计算要下放到缓存层,而不是渲染层。数据层缓存(Astro 模板)救不了模板层的重复执行;但把格式化结果塞进数据缓存,渲染层就能归零;
- 别信直觉,给代码分段计时。esc 快速路径、数组 join 这些”明显该优化”的点全是无关紧要的,真正的大头是藏在
partsInTZ里 6 次.find()的 14ms; - 字节级 diff 是重构安全网。字符串生成器的最大风险是标记复刻不精确,而确定性的输出让”逐字节一致”从口号变成可执行的验证脚本;
- Astro 的转义是分场景的。属性只转
& < > ",文本转 5 个字符,空字符串属性输出裸属性——做这类等价重写前,先翻一遍目标 HTML 确认序列化规则。