博客跑了一段时间,内容量慢慢起来了——50 多篇文章、170 多个标签页、若干相册。某天看了一眼构建日志,发现 bun run build 要跑两分钟,标签页生成阶段的 frontmatter 读取次数高达 17,150 次。
两分钟不算久,但这个数字让我意识到,数据层的重复计算已经远超合理范围。
于是趁周末做了一轮系统性优化,涵盖后端数据加载和前端渲染两个层面。本文记录具体做了什么、为什么这么做、以及实测效果。
先测量,后动手。用 Codex 跑了一遍基准测试:
| 指标 | 优化前 |
|---|---|
| 构建总时长 | 2m 0s |
| 标签页 frontmatter 读取次数 | 17,150 次 |
| Gallery 列表页加载 | ~2,220ms |
| Feed 构建 | ~840ms/次 |
问题的根源很清楚:每个需要文章列表的地方,都在从头遍历文件系统。
lib/posts.ts 是整个博客的数据层核心。原来的调用链是这样的:
getPostsByTag(tag)
→ getAllPosts(['slug', 'title', 'date', 'description', 'tags'])
→ getPostSlugs() // fs.readdirSync
→ slugs.map(slug => getPostBySlug(slug, fields))
→ readPostFile(fullPath, false) // 每次读 8KB frontmatter看起来有 React.cache() 做去重,但问题在于 cache key 是数组引用。['slug', 'title', 'date'] 和 ['slug', 'title', 'date'] 是两个不同的数组对象,永远不会命中缓存。
更致命的是级联调用:
getAllTags() 调用 getAllPosts(['tags'])getPostsByTag(tag) 对每个标签调用 getAllPosts(['slug', 'title', 'date', 'description', 'tags'])引入 PostIndexEntry 缓存层,将"扫描文件系统 + 解析 frontmatter"这一步做成单例:
type PostIndexEntry = {
slug: string
fullPath: string
data: PostData // frontmatter 数据
}
let postIndexCache: PostIndexEntry[] | null = null
function getPostIndex(): PostIndexEntry[] {
if (!isProduction || postIndexCache === null) {
postIndexCache = buildPostIndex()
}
return postIndexCache
}同时修复 React.cache() 的 key 问题——把 fields 数组转成排序后的字符串:
function toFieldsKey(fields: string[] = []): string {
return Array.from(new Set(fields)).sort().join(',')
}
const getAllPostsCached = cache((fieldsKey: string) => {
const fields = fromFieldsKey(fieldsKey)
return getPostIndex().map((entry) =>
selectPostFields(entry.slug, fields, entry.data),
)
})这样,无论多少个调用方请求相同的字段组合,索引只构建一次,React.cache() 的命中率也从几乎为零变成接近 100%。
重构过程中还发现一个隐藏 bug:gray-matter 会把未加引号的日期字符串解析为 Date 对象,而不是字符串。原来的日期排序代码假设 date 一定是 string,在 Date 对象上会静默失败。修复后的 parseDateValue 兼容了三种类型:
function parseDateValue(value: unknown): number {
if (value instanceof Date) return value.getTime()
if (typeof value === 'number') return value
if (typeof value === 'string') {
const timestamp = new Date(value).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
return 0
}Gallery 列表页原来请求 images 字段,仅仅为了显示"X photos"这个数字。但获取 images 会触发 listAlbumImages(r2Prefix)——一次完整的 Cloudflare R2 列表请求,耗时 2 秒多。
修复方式很简单:新增一个 imageCount 字段,直接从 frontmatter 的 data.images 数组长度获取,不走 R2:
if (field === 'imageCount') {
if (r2Images.length > 0) {
items[field] = r2Images.length
} else if (Array.isArray(data.images)) {
items[field] = data.images.length
} else {
items[field] = 0
}
return
}Gallery 页面改为请求 imageCount 而非 images。冷启动从 2,220ms 降到几毫秒。
/feed.xml 和 /atom.xml 原来是动态路由,每次请求都重新构建完整的 feed(~840ms)。但 feed 内容只在发布新文章时才会变化,没必要每次实时生成。
加两行配置即可:
export const dynamic = 'force-static'
export const revalidate = 3600 // 1 小时后重新生成构建时生成一次静态文件,之后 1 小时内直接返回缓存。
display: swap原来的字体配置没有设置 display,意味着浏览器在字体加载完成前不显示文字(FOIT, Flash of Invisible Text)。对于 Noto Sans SC 这种中文字体来说,加载时间可能不短。
const notoSansSC = Noto_Sans_SC({
subsets: ['latin'],
weight: ['400', '500', '700'],
variable: '--font-noto-sans-sc',
display: 'swap', // 先用后备字体显示,加载完成后替换
})加上 display: 'swap' 后,用户至少能立刻看到文字内容,不会盯着空白页等字体。
文章页的 ReadingProgress 组件原来是这样监听滚动的:
window.addEventListener('scroll', handleScroll)
// handleScroll 里直接 setScrollProgress(...)每次滚动事件都触发一次 React 状态更新和重渲染。60fps 滚动意味着每秒最多 60 次 setState——虽然 React 会批处理,但完全没必要这么频繁。
改用 requestAnimationFrame + ticking 模式:
const handleScroll = () => {
if (!ticking.current) {
ticking.current = true
rafId.current = requestAnimationFrame(updateProgress)
}
}
window.addEventListener('scroll', handleScroll, { passive: true })passive: true 告诉浏览器这个监听器不会调用 preventDefault(),允许浏览器优化滚动性能。ticking 标志确保同一帧内只触发一次状态更新。
原来的 AppHeader 整个标记为 'use client',包括 Logo、导航链接、分隔线、主题切换按钮——所有这些都被打进客户端 JS 包。
但实际需要客户端能力的只有两部分:
usePathname() 来高亮当前路由useTheme() 来切换暗色模式拆分后,AppHeader 本身变成服务器组件,Logo 和分隔线不再需要客户端 JavaScript:
// components/AppHeader.tsx — 现在是服务器组件
export default function AppHeader() {
return (
<header className="...">
<Link href="/"><AppLogo /></Link>
<div className="...">
<NavLinks /> {/* 客户端组件 */}
<div className="..." /> {/* 纯 HTML 分隔线 */}
<ThemeToggle /> {/* 客户端组件 */}
</div>
</header>
)
}ThemeToggle 还有一个小改进:用 useSyncExternalStore 替代 useEffect(() => setMounted(true), []) 来处理水合安全——这避免了 React Compiler 对"effect 内同步 setState"的 lint 警告,也更符合 React 19 的最佳实践。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 静态页面生成 | 22.6s | 7.5s | -67% |
| 标签页 frontmatter 读取 | 17,150 次 | ~50 次 | -99.7% |
| Gallery 列表加载 | ~2,220ms | ~1ms | -99.95% |
| Feed 端点 TTFB | ~840ms | 静态缓存 | -100%(首次后) |
| AppHeader | 全客户端 | 服务器 + 两个小客户端组件 | JS 体积减少 |
| 滚动监听 | 每事件 setState | rAF 节流 + passive | CPU 占用降低 |
构建通过,245 个页面正常生成。
这次优化的核心教训是一句老话:先测量,后优化。
17,150 次文件读取这个数字,不看基准测试根本不会想到。而修复的方式也不复杂——一个进程级的索引缓存 + 一个字符串化的 cache key,就把重复计算降了三个数量级。
前端那几项(字体 swap、rAF 节流、组件边界)单个影响不大,但加在一起对用户体感有明显提升。特别是 display: swap——这是一个几乎零成本但经常被忽略的优化。
如果你的 Next.js 博客也用文件系统存储内容,值得检查一下数据加载路径是否存在类似的重复扫描问题。文章数量少的时候感觉不到,但一旦内容量上去了,构建时间会以超线性的速度增长。