Wendy's Blog:个人博客的工程化实践
Wendy's Blog:个人博客的工程化实践
写博客的博客。本文不讲"怎么用 Next.js",而是讲决策:从"先确定写什么"到 "push 之后 1 分钟自动上线并被搜索引擎收录"之间,我做了哪些技术选择,为什么, 以及踩了哪些真实的坑。写给想要动手搭第二个个人站的人。
0. 起点:需求先于代码
搭博客之前,我先花半天做了一件看起来与技术无关的事:内容规划。
- 博客定位一句话、标签草案(5
10 个起步)、35 篇种子文章选题; - 明确的"范围冻结":首版不做评论、搜索、统计、多语言、管理后台;
- 一篇 349 行的"测试文章"作为内容管道验收样本(长文 + 大量表格)。
为什么要先做这个?因为数据结构由内容决定:标签体系决定聚合逻辑,种子文章 的类型多样性(长文/短文/代码/表格)决定渲染器要覆盖什么。先把内容想清楚, 代码只是把它变成容器——反过来先写页面再补内容,必然返工。
1. 内容管道先行,页面后置
项目的第一行业务代码不是页面,而是 src/lib/content.ts:
// 内容目录:生产固定 content/posts;测试可通过 BLOG_CONTENT_DIR 注入 fixture
function postsDir(): string {
return process.env.BLOG_CONTENT_DIR
? path.resolve(process.cwd(), process.env.BLOG_CONTENT_DIR)
: path.join(process.cwd(), "content", "posts");
}它把 content/posts/*.md 变成四件套:getAllPosts(列表)、getPostBySlug(详情)、
getAllTags(标签聚合)、getPostsByTag(标签过滤)。页面只是这层 API 的消费者。
顺序上的关键点:先让"写一篇 Markdown → 出现在列表"跑通,再画页面。 数据层稳定之前,任何 UI 都可能白做。
2. 单一事实源,其余全部派生
站点名、简介、导航、URL 都收在一个 siteConfig 对象里:
export const siteConfig = {
name: "Wendy's Blog",
tagline: "Happiness",
github: "https://github.com/Bluetrae",
url: "https://wendylala.com",
nav: [{ href: "/", label: "Home" }, /* ... */],
};这个决策的收益兑现了两次:先用 Vercel 的占位域名上线,后来换成自有域名
wendylala.com——同样只改了这一行 url,sitemap、robots、RSS、OG 标签、
JSON-LD 的 canonical 全部自动修正,没有第二个地方需要改。
同理,把 "Wendy 的博客"换成 "Wendy's Blog" 也是一行。
规则:凡是"同一个值出现在多处"的信号,一律抽成单一来源。
3. 构建期校验:让坏数据失败在构建时
frontmatter 缺 title、date 非法、标签为空——这些错误如果等到用户访问时
才 404,就太晚了。我的选择是构建直接抛错,并指出文件:
[content] content/posts/tmp-invalid-no-title.md: missing or invalid required field "title"同样的哲学也沿用到作品集板块和之后的一切数据层: 实体不合法 = 构建失败,而不是"上线后静默地空着"。
4. 主题感知的完整链路:从 class 到资产
主题切换看起来只是"一个按钮",实际是一条链:
- 无闪白:
<head>内联脚本在任何渲染前读localStorage/ 系统偏好, 给<html>打darkclass; - 状态源:CSS 用 class 策略(
@custom-variant dark),React 侧用useSyncExternalStore订阅 class 变化——这是被 lint 规则教育出来的标准写法, 还顺带解决了"手动切换 vs 系统偏好"的双轨问题; - 资产跟随:吉祥物(blobatar)两套配色交叉淡入淡出、favicon 两版 SVG 随 class 切换、阅读进度条/下划线用同色系渐变,深浅各有其位。
值得强调的是 useSyncExternalStore 这个选择:它把"外部系统状态"(DOM class)
变成 React 可订阅的 store,避免在 effect 里同步 setState(React 19 新 lint 规则
会拦截),并天然支持 MutationObserver 的细粒度更新。
5. 动效与可访问性:统一语言
全站动效只有几个基元,但处处一致:
- 一条缓动:
cubic-bezier(0.22, 1, 0.36, 1)(先快后慢的"落地感"); - 四个关键帧:
fade-up/fade-in/fade-down/pop-in; - 缩放/位移全部用 transform(GPU 合成,不触发重排),颜色用 transition;
prefers-reduced-motion全局降级为瞬时——尊重系统设置是动效的底线;- 导航下划线、卡片上浮、箭头滑入、吉祥物弹跳……都服务于"有反馈感"而不是"炫技"。
小提醒:Next.js 16 默认不再接管 scroll-behavior,想平滑锚点跳转要在
<html> 上加 data-scroll-behavior="smooth"——这种"版本行为差异"文档里都写着。
6. 门禁体系:让机器守住质量
package.json 里四道命令,任何一道红就不允许上线:
{
"build": "tsc --noEmit && next build",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types --test-isolation=none \"src/lib/content.test.ts\"",
"lint": "eslint"
}- 类型检查外置为构建前置(Next 的
typescript.ignoreBuildErrors+ 自行tsc); - 单元测试用 node:test——Node 24 直接跑 TS,零额外依赖,8 个用例
覆盖排序 / draft 过滤 /
_前缀忽略 / 标签聚合 / 校验报错; - CI(GitHub Actions)每次 push 跑全部四步,第一次运行就抓到一个真实 bug (React 19 新 lint 规则),这本身就是门禁价值的证明;
CHANGELOG.md记录构建耗时基线——防止"某天构建悄悄变慢"不被察觉。
7. 发布闭环:推送即部署,部署即被收录
最后是让整个系统自己转起来:
git push → GitHub Actions(门禁)→ Vercel 构建并部署(生产域名 wendylala.com)
→ 搜索引擎经 sitemap / robots 自动发现 → 收录- 域名:
wendylala.com在 Cloudflare 托管,两条 CNAME(apex/www)以 DNS only(灰云) 指向 Vercel——灰云是 Vercel 自动签发证书的前提, 代理模式(橙云)反而会卡死证书申请; - sitemap/robots/RSS 全部由内容实时生成;Google Search Console 以 网域属性 + DNS TXT 验证(零代码),sitemap 提交后 22 个 URL 全部发现; Bing 走 Google 导入一键同步;
- OG 分享卡由构建期图像生成(next/og):吉祥物卡自带站点域名文字, 另备 4 幅浅色卡供未来按时间段轮换(VPS 上以 cron 切换文件即可);
- 之后每写一篇新文章:写 Markdown → push → 1 分钟内上线 → 搜索引擎稍后跟进。
内容运营从"手工发布"变成了"合入 Git"——写作的发布成本趋近于零。
8. 踩坑清单(真实,可复用)
| 坑 | 症状 | 解法/结论 |
|---|---|---|
| 项目目录名大写 | create-next-app 拒绝 |
npm 包名不许大写;先脚手架到子目录再合并 |
| pnpm 被 Electron shim 劫持 | crashpad 报错、缓存 EPERM | 换官方 npm;缓存目录重定向到可写区 |
| Next 16 构建 spawn 子进程 | spawn EPERM(tsc、worker、dev fork) |
experimental.workerThreads + tsc 外置 + 自定义 dev 启动 |
| Turbopack dev 传中文参数不解码 | 中文标签页 404 | 入口处安全解码(生产幂等) |
| 删路由后 typegen 残留 | Cannot find module |
清 .next + 页面 props 用显式类型,少依赖生成物 |
| lint 新版规则 | set-state-in-effect |
useSyncExternalStore 订阅外部状态 |
| GitHub 仓库私有化 | 担心影响部署 | Vercel 走 GitHub App 授权,与公开/私有无关,站点不受影响 |
| OG 图无扩展名 | 浏览器打开直接下载、平台拒收 | 生成带 .png 扩展名的静态文件约定(构建期生成+提交),不要输出无扩展名路由 |
| 废弃配色的 TS 数据 | 构建红(tsc 多余属性) |
类型联合与数据对象同步清理,删一项就要两头删 |
| Vercel 默认不跳 www | www 与 apex 双站重复 | Domains → www 行 → Redirect 手动设置为跳转 apex |
| 换域名后旧 URL 残留 | feed 里"悄悄"出现旧域名 | 全仓库搜旧域名:数据、代码示例、文档一处都别漏 |
| 搜索引擎验证选型 | 想验证新域名 | 网域属性 + DNS TXT 零代码;URL 前缀属性才需要 HTML 文件 |
前四个坑都与特定环境有关,但后三个是通用教训: 不要依赖生成物/隐式状态,尽量显式;工具链升级后先跑一遍 lint 再写新代码。
9. 可以带走的清单
- 内容先于代码:数据结构由内容决定;
- 一个值只写一次:单点修改,全局派生;
- 让机器在构建时说话:实体不合法 → 构建失败并指出文件;
- 主题是一条链:class 是唯一状态源,资产跟随,无闪白;
- 动效要克制而一致:一条缓动、几个基元、尊重 reduced-motion;
- 门禁先于上线:CI 第一次跑就抓到 bug,说明它真的在工作;
- 静态化到极致:构建期做一切(校验、派生、OG 卡),线上零服务端进程;
- 发布成本趋零:推送即部署,写完文章就是发布;
- 域名与 SEO 也能工程化:灰云解析、TXT 验证、sitemap 提交、www 跳转,都是一次配置长期免维护。
祝你也能在周末搭出自己的一个小站——写文章,然后让机器替你发布。