Astro 构建提速 2.3 倍:从构建日志里揪出 Intl.DateTimeFormat 的隐形开销
这两天给博客加了不少新文章,发现 npm run build 越来越慢(31 秒)。盯着构建日志看,有个现象很蹊跷:带 /published/ 前缀的跳转页生成飞快(+1ms),而 /posts/ 下一堆页面却要 50~130ms。本来以为是 aliases 跳转页拖慢的,查到最后发现——慢的根本不是 aliases,而是一个藏在日期格式化里的隐形开销。完整记录一下排查过程,顺便把 Intl.DateTimeFormat 这个坑分享出来。
起因:先量化”慢”
站点结构大致是:
astro.config.mjs里把 842 条旧链接别名配进redirects,构建时生成静态跳转页;- 文章页由
src/pages/posts/[slug].astro生成; - 首页、分类、标签、归档、设置页等是常规列表页。
直接把构建输出抓下来,按 URL 形态分组统计每页耗时:
| 页面形态 | 数量 | 平均耗时 |
|---|---|---|
/published/年份/slug/ 跳转页 | 424 | 1.3ms |
/posts/年份/月/slug/ 别名跳转页 | 209 | 1.7ms |
/posts/slug.html 别名跳转页 | 209 | 1.2ms |
/posts/slug/真实文章页 | 212 | ~70ms |
/settings、/tags/*、首页等列表页 | ~90 | ~70ms |
结论很清楚:所有别名/跳转页都是 1~3ms,慢的全是真实页面。212 篇文章页 × 70ms ≈ 15s,再加列表页,基本吃掉了 “generating static routes” 阶段的 27 秒。之前直觉上”aliases 慢”其实是看走眼了——日志里 /posts/slug/ 长得和别名很像,但它们是正经文章页。
深挖:70ms 到底花在哪
给 node_modules/astro/dist/core/build/generate.js 里的 renderPath 临时加了几段计时,把一次页面生成拆成”渲染”和”写文件”两步:
DEBUG [/posts/test-astro-encrypted] total=74.9ms render=72.9 fs=1.5
DEBUG [/posts/scrollbar-butterfly-style] total=52.1ms render=49.8 fs=1.9
DEBUG [/posts/windows-setting-priority-ipv4] total=50.2ms render=48.6 fs=1.3
写文件只有 ~1.5ms,render 占了 95%。再在 routing/handler.js 里打一行日志,看到更关键的信息:
DEBUG2 [/posts/[slug]] handleTotal=74.6 pre=0.0 render=74.6
渲染的 routeData 是 /posts/[slug](type=page),也就是说每个文章页都是完整走了一遍 Astro 模板渲染,这不是 bug,是页面本身的成本。
为什么一页要 70ms:侧边栏比正文还重
量了一下输出文件构成,一篇正文只有 9.8KB 的小文章,整页 HTML 却有 270KB:
<article>正文:9.8KB;- 头部到正文之前(侧边栏 + 顶栏 + 脚本):~195KB;
- 正文之后(底部栏等):~60KB。
侧边栏 Sidebar.astro 里,220 篇文章在”文件树/搜索/时间线”三个视图各出现一次,加上分类、标签、终端数据,每页要输出 1000+ 个链接。这些数据虽然已经做了构建期缓存,但模板每页都要重新执行一遍。
而在这些模板执行里,有一个非常可疑的调用:侧边栏每页要调 fmtDate 约 660 次。看看它的实现:
function partsInTZ(date: Date) {
const fmt = new Intl.DateTimeFormat('zh-CN', { timeZone: TZ, /* ... */ }); // ← 每次调用都 new!
const get = (type) => fmt.formatToParts(date).find(...)?.value ?? '00';
...
}
每次格式化都新建一个 Intl.DateTimeFormat 实例。实测:
- 每次新建:
43.6µs/次(Windows + Node 24) - 复用同一个实例:
3.1µs/次
660 次 × 43.6µs ≈ 29ms/页,而文章页总共才 70ms——这个毫不起眼的函数占了大头。列表页更夸张,一页要格式化几十篇文章的日期,所以它们也从 ~70ms 掉到个位数。
修复:把 formatter 提升到模块级
const TZ = 'Asia/Shanghai';
const tzDateFmt = new Intl.DateTimeFormat('zh-CN', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
function partsInTZ(date: Date) {
const get = (type: string) =>
tzDateFmt.formatToParts(date).find((p) => p.type === type)?.value ?? '00';
// ...
}
formatToParts 是无状态的纯函数(对同一个实例并发调用也安全),改完后输出完全一致——文章日期、时区、跳转页 location 逐项对比过,没有任何变化。
结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 文章页平均耗时 | ~70ms | 23ms |
| 列表页平均耗时 | ~70ms | 7.6ms |
| 跳转页平均耗时 | ~1.5ms | ~1.5ms(不变) |
| 整站构建 | 31.0s | 13.4s |
2.3 倍提速,只改了一个文件的一小段。构建日志里 842 个跳转页依旧飞快,/published/ 依旧 1ms,但全站生成时间直接腰斩。
顺带验证:build.concurrency 没用
Astro 7 支持 build: { concurrency: N } 并行生成页面,顺手测了一下(6 并发、32 核机器):
- 单页平均耗时从 70ms 涨到 ~380ms(并发互相挤占);
- 总耗时几乎没变,甚至略慢(28.6s vs 27.7s)。
原因:页面渲染是单线程 CPU 密集任务,Node 一个进程内并发只是时间片切换,不产生并行收益。所以这个方向可以直接排除。
经验总结
- 先量化,再优化。凭日志印象判断”哪个页面慢”很容易被误导——我把耗时按 URL 形态分组统计后,才发现慢的跟 aliases 没关系;
- 别小看”每次调用都 new”的模式。
Intl.DateTimeFormat、正则字面量以外的new RegExp、Date解析……在循环/模板里被高频调用时,对象构造开销会被放大成秒级差距; - 侧边栏等共享 UI 是隐形成本大头。每页重渲染
195KB 的侧边栏,数据层缓存救不了模板层的重复执行。如果要再进一步,可以把侧边栏 HTML 在构建期预渲染一次、跨页复用,预计还能再省 58s,但需要和模板保持同步,属于”按需再做”的优化; - 排查时给
node_modules里打日志是快准狠的办法,记得改完还原(这次全程只改了generate.js和handler.js,最后git checkout恢复)。