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/ 跳转页4241.3ms
/posts/年份/月/slug/ 别名跳转页2091.7ms
/posts/slug.html 别名跳转页2091.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+ 个链接。这些数据虽然已经做了构建期缓存,但模板每页都要重新执行一遍

而在这些模板执行里,有一个非常可疑的调用:侧边栏每页要调 fmtDate660 次。看看它的实现:

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 逐项对比过,没有任何变化。

结果

指标优化前优化后
文章页平均耗时~70ms23ms
列表页平均耗时~70ms7.6ms
跳转页平均耗时~1.5ms~1.5ms(不变)
整站构建31.0s13.4s

2.3 倍提速,只改了一个文件的一小段。构建日志里 842 个跳转页依旧飞快,/published/ 依旧 1ms,但全站生成时间直接腰斩。

顺带验证:build.concurrency 没用

Astro 7 支持 build: { concurrency: N } 并行生成页面,顺手测了一下(6 并发、32 核机器):

  • 单页平均耗时从 70ms 涨到 ~380ms(并发互相挤占);
  • 总耗时几乎没变,甚至略慢(28.6s vs 27.7s)。

原因:页面渲染是单线程 CPU 密集任务,Node 一个进程内并发只是时间片切换,不产生并行收益。所以这个方向可以直接排除。

经验总结

  1. 先量化,再优化。凭日志印象判断”哪个页面慢”很容易被误导——我把耗时按 URL 形态分组统计后,才发现慢的跟 aliases 没关系;
  2. 别小看”每次调用都 new”的模式Intl.DateTimeFormat、正则字面量以外的 new RegExpDate 解析……在循环/模板里被高频调用时,对象构造开销会被放大成秒级差距;
  3. 侧边栏等共享 UI 是隐形成本大头。每页重渲染 195KB 的侧边栏,数据层缓存救不了模板层的重复执行。如果要再进一步,可以把侧边栏 HTML 在构建期预渲染一次、跨页复用,预计还能再省 58s,但需要和模板保持同步,属于”按需再做”的优化;
  4. 排查时给 node_modules 里打日志是快准狠的办法,记得改完还原(这次全程只改了 generate.jshandler.js,最后 git checkout 恢复)。