Wendy's Blog

Wendy's Blog:个人博客的工程化实践

7 分钟技术向项目

Wendy's Blog:个人博客的工程化实践

写博客的博客。本文不讲"怎么用 Next.js",而是讲决策:从"先确定写什么"到 "push 之后 1 分钟自动上线并被搜索引擎收录"之间,我做了哪些技术选择,为什么, 以及踩了哪些真实的坑。写给想要动手搭第二个个人站的人。


0. 起点:需求先于代码

搭博客之前,我先花半天做了一件看起来与技术无关的事:内容规划

  • 博客定位一句话、标签草案(510 个起步)、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 缺 titledate 非法、标签为空——这些错误如果等到用户访问时 才 404,就太晚了。我的选择是构建直接抛错,并指出文件:

[content] content/posts/tmp-invalid-no-title.md: missing or invalid required field "title"

同样的哲学也沿用到作品集板块和之后的一切数据层: 实体不合法 = 构建失败,而不是"上线后静默地空着"。


4. 主题感知的完整链路:从 class 到资产

主题切换看起来只是"一个按钮",实际是一条链:

  1. 无闪白:<head> 内联脚本在任何渲染前读 localStorage / 系统偏好, 给 <html>dark class;
  2. 状态源:CSS 用 class 策略(@custom-variant dark),React 侧用 useSyncExternalStore 订阅 class 变化——这是被 lint 规则教育出来的标准写法, 还顺带解决了"手动切换 vs 系统偏好"的双轨问题;
  3. 资产跟随:吉祥物(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. 可以带走的清单

  1. 内容先于代码:数据结构由内容决定;
  2. 一个值只写一次:单点修改,全局派生;
  3. 让机器在构建时说话:实体不合法 → 构建失败并指出文件;
  4. 主题是一条链:class 是唯一状态源,资产跟随,无闪白;
  5. 动效要克制而一致:一条缓动、几个基元、尊重 reduced-motion;
  6. 门禁先于上线:CI 第一次跑就抓到 bug,说明它真的在工作;
  7. 静态化到极致:构建期做一切(校验、派生、OG 卡),线上零服务端进程;
  8. 发布成本趋零:推送即部署,写完文章就是发布;
  9. 域名与 SEO 也能工程化:灰云解析、TXT 验证、sitemap 提交、www 跳转,都是一次配置长期免维护。

祝你也能在周末搭出自己的一个小站——写文章,然后让机器替你发布。