博客代码跑了几个月,内容类型从单一的 posts 扩展到 posts / essays / albums 三种,每新增一个类型就把旧的模块复制一份改改路径。复制到第三次的时候,三个文件里出现了几乎一模一样的逻辑:slug 扫描、frontmatter 解析、索引构建、React cache 包装、字段选取……
这不是技术债,这是复制粘贴债。趁周末做了一轮集中治理,核心三件事:
lib/content/module.ts 统一管理三套内容集合治理前的状态:
lib/posts.ts → 109 行,含 getPostSlugs / getAllPosts / getPostBySlug 等
lib/essays.ts → 74 行,同上逻辑,只改了几个变量名
lib/albums.ts → 165 行,同上 + R2 图片富化逻辑三个文件的共性模式:
getXxxSlugs() — 读目录、过滤 .md/.mdxbuildIndex() — 遍历文件、读取 frontmatter、建立 slug → 数据映射createContentCache() / createGetAll() / createGetBySlug() — React cache 包装toFieldsKey() / selectFields() — 手动字段选取,调用方传 fields: string[] 参数每次新增内容类型 = 复制整个文件 → 修改路径常量 → 改几个函数名。行为完全一致,差异只在目录路径和frontmatter 字段定义。
更隐蔽的问题是 fields: string[] 模式:
// 旧调用方式 — 字段名是裸字符串,拼错不报错
const posts = getAllPosts(['title', 'description', 'date', 'slug', 'content'])TypeScript 不会告诉你 'descrption' 写错了,它就是一个 string[]。
核心思路:把"相同的行为"抽象到工厂函数,"不同的配置"作为参数传入。
lib/content/types.ts)先定义每种内容实体的类型,区分索引项(不含 content)和完整项:
export interface PostIndexItem {
slug: string
title: string
date: string
description: string
tags: string[]
lang: string
pdfUrl?: string
author?: string
}
export interface Post extends PostIndexItem {
content: string
}Essay 和 Album 同理。调用方现在拿到的是 Post[] 而不是 Record<string, unknown>[]。
lib/content/validation.ts)用 Zod schema 在数据入口做运行时校验,非法 frontmatter 在读取阶段就被拒绝并输出警告:
export const postFrontmatterSchema = z.object({
title: z.string().min(1, 'title is required'),
date: z.union([z.string(), z.date().transform(d => d.toISOString())]),
description: z.string().optional().default(''),
tags: z.array(z.string()).optional().default([]),
lang: z.string().optional().default('zh-CN'),
pdfUrl: z.string().optional(),
author: z.string().optional(),
})注意 date 字段的联合类型处理 —— gray-matter 可能返回字符串,YAML 解析器可能返回 Date 对象,这里统一归一化为 ISO 字符串。
lib/content/module.ts)工厂接收一个配置对象,返回完整的模块实例:
export interface ContentModuleConfig<TIndex, TFull extends TIndex & { content: string }> {
directory: string
indexFields: string[]
validate: (data: ContentData, slug: string) => TIndex | null
enrich?: (item: TIndex) => Promise<TFull>
}每个内容模块现在只需提供 validate 函数和 indexFields 列表:
// lib/posts.ts — 从 109 行缩减到 38 行
const postsModule = createContentModule({
directory: postsDirectory,
indexFields: ['title', 'date', 'description', 'tags', 'lang', 'pdfUrl', 'author'],
validate: validatePost,
})
export const getAllPosts = postsModule.getAll
export const getAllPostsFull = postsModule.getAllFull
export const getPostBySlug = postsModule.getBySlugslug 字段由工厂自动追加到字段列表头部,调用方无需关心 —— 这也直接防止了最近修复的那个"忘记写 slug 导致 generateStaticParams 崩溃"的 bug。
lib/about.ts原 posts.ts 里还夹带了一个 getAboutContent() 函数 —— 它读取 content/about/index.md,跟 posts 集合没有任何关系,纯粹是"post 模块最早就存在所以顺手塞进去"的历史遗留。拆到独立文件。
| 指标 | 治理前 | 治理后 |
|---|---|---|
| posts.ts + essays.ts + albums.ts | 348 行 | 285 行(含新增 types/validation/module/about) |
| 内容模块重复逻辑 | 3 份独立实现 | 1 份泛型工厂 |
| 类型安全 | fields: string[] 裸字符串 | Post / Essay / Album 类型化返回 |
| 校验时机 | 运行时隐式崩溃 | Zod 边界校验 + 结构化警告 |
| 新增内容类型成本 | 复制 100+ 行 → 改路径 | 写 validate 函数 + 配置对象 |
buildFeed() 每次执行时对所有文章调用 renderContentToHtml() —— 这是 MDX → HTML 的完整渲染管线,涉及 rehype 插件、Shiki 语法高亮等。文章数量增长后,每次构建都全量重渲染是不必要的。
新增 lib/feed-cache.ts,基于内容哈希的增量缓存:
setCachedHtml(slug, content, html) → 写入 .feedcache/{slug}.{sha256前16位}.htmlgetCachedHtml(slug, content) → 计算当前内容哈希,扫描 .feedcache/ 下匹配 {slug}.*.html 的文件,比对哈希,命中则返回缓存 HTMLcleanupStaleCache(activeSlugs) → 构建完成后删除所有不属于当前活跃 slug 集合的缓存文件在 lib/feed.ts 中引入 renderWithCache() 包装函数:
async function renderWithCache(content: string, slug: string): Promise<string | undefined> {
if (!content?.trim()) return undefined
const cached = getCachedHtml(slug, content)
if (cached !== null) return cached
const html = await renderContentToHtml(content)
if (html !== undefined) setCachedHtml(slug, content, html)
return html
}.feedcache/ 已 gitignore,不会污染版本控制治理过程中顺带修了两个小问题:
e7df66d):indexFields 配置遗漏 slug,工厂未自动追加前,所有条目 slug 为 undefined → generateStaticParams 崩溃。修复后工厂强制追加 slug。836b909):部分文章 frontmatter 无 description 字段,Zod schema 最初设为 z.string().min(1),导致合法文章被拒绝。改为 optional().default('')。两者都说明:校验应该严格,但不能比实际数据更严格。Zod 的 .optional().default() 正好表达这个语义。
这次治理没有改变任何用户可见的功能,纯粹是代码质量投资:
Cannot read property of undefined 变成 [posts] Validation failed for "xxx": title: title is required代码治理这种事,做的时候看不见收益,不做的时候天天被摩擦。这句话写在这里,提醒未来的自己。