This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "用 Next.js App Router 搭建个人博客"
|
||||
date: "2025-03-08"
|
||||
summary: "从零搭一个可写、可读、可部署的博客:内容层怎么设计、Server Component 怎么分工、MDX 与代码高亮怎么接。"
|
||||
tags: ["Next.js", "MDX", "架构"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
这个博客的第一版目标很克制:**能写、能读、能部署**。没有后台、没有数据库、没有登录。所有的复杂度都收敛到一件事上——把 Markdown 文件变成页面。
|
||||
|
||||
## 为什么不用 CMS
|
||||
|
||||
第一版最容易犯的错误,是一上来就选一个「以后肯定用得上」的方案。数据库 + 后台管理听起来很美,但它会带来一整套额外的负担:
|
||||
|
||||
- 需要维护 schema 与迁移
|
||||
- 需要处理鉴权、草稿权限、并发编辑
|
||||
- 需要为内容管理单独做一套 UI
|
||||
- 本地开发必须先起一个数据库
|
||||
|
||||
而写作本身的需求其实非常朴素:打开编辑器,写 Markdown,提交。Git 已经是一个成熟的内容版本管理系统了——它自带历史记录、分支、协作和回滚。
|
||||
|
||||
> 用文件系统当数据库,用 Git 当版本管理。第一版不需要更多东西。
|
||||
|
||||
## 内容层设计
|
||||
|
||||
文章放在 `content/posts/` 目录,文件名即 slug:
|
||||
|
||||
```text
|
||||
content/posts/
|
||||
├── building-a-blog-with-nextjs.mdx
|
||||
├── react-server-components.mdx
|
||||
└── tailwind-v4-design-tokens.mdx
|
||||
```
|
||||
|
||||
每篇文章顶部是一段 YAML frontmatter,声明标题、日期、摘要、标签和草稿状态:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "用 Next.js App Router 搭建个人博客"
|
||||
date: "2025-03-08"
|
||||
summary: "从零搭一个可写、可读、可部署的博客。"
|
||||
tags: ["Next.js", "MDX"]
|
||||
draft: false
|
||||
---
|
||||
```
|
||||
|
||||
读取层只有一个职责:**扫描目录 → 解析 frontmatter → 校验 → 排序**。草稿在读取时就被过滤掉,因此它既不会出现在列表里,也无法通过直接访问 URL 被读到。
|
||||
|
||||
### 校验放在读取层
|
||||
|
||||
内容错误最容易的暴露时机是构建阶段,而不是线上。所以 frontmatter 的校验写在读取函数里:缺少 `title`、`date` 格式不对、`tags` 不是数组,都会直接抛出错误并终止构建。
|
||||
|
||||
```ts
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!title) {
|
||||
problems.push("缺少必填字段 `title`(标题)");
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new PostValidationError(slug, problems);
|
||||
}
|
||||
```
|
||||
|
||||
这样一来,错误信息会明确指向**哪个文件**、**哪个字段**,而不是在页面上渲染出一个空白标题。
|
||||
|
||||
## Server Component 的分工
|
||||
|
||||
App Router 的默认是 Server Component。博客恰好是它的理想场景:内容在构建时就已经确定,没有任何需要客户端状态的东西。
|
||||
|
||||
分工原则很简单:
|
||||
|
||||
1. **数据获取全部在服务端** —— 用 `server-only` 标记读取层,物理上防止它被打进客户端 bundle
|
||||
2. **交互才下沉到客户端** —— 滚动进度、目录高亮、移动端菜单这类需要 `useState` 的部分单独拆成客户端组件
|
||||
3. **列表页不传递正文** —— 列表只需要元信息,正文留在详情页按需读取
|
||||
|
||||
`server-only` 这个包很小,但价值很大:
|
||||
|
||||
```ts
|
||||
import "server-only";
|
||||
```
|
||||
|
||||
如果哪次重构不小心把内容读取层 `import` 进了客户端组件,构建会立刻失败,而不是把整个 `content/` 目录打进浏览器产物。
|
||||
|
||||
## 排序要稳定
|
||||
|
||||
按日期倒序是最直观的排序方式,但只按日期排序会有一个隐患:**同一天发布的两篇文章,顺序可能在不同机器上不一致**。
|
||||
|
||||
```ts
|
||||
function byDateDescending(a: PostMeta, b: PostMeta): number {
|
||||
const diff = new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
return diff !== 0 ? diff : a.slug.localeCompare(b.slug);
|
||||
}
|
||||
```
|
||||
|
||||
用 slug 兜底之后,排序结果就是确定性的,构建产物也不会因为环境不同而漂移。
|
||||
|
||||
## 日期时区陷阱
|
||||
|
||||
YAML 里的 `2025-03-08` 会被解析成 Date 对象,而这个 Date 默认是本机时区的午夜。如果服务器在 UTC+8 而构建机在 UTC,同一篇文章可能显示成 3 月 7 日。
|
||||
|
||||
解决办法是**始终把日期当作 UTC 处理**:
|
||||
|
||||
```ts
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T00:00:00Z`));
|
||||
```
|
||||
|
||||
读取时统一转成 `YYYY-MM-DD` 字符串,格式化时显式指定 `timeZone: "UTC"`。日期在整条链路上就不再漂移了。
|
||||
|
||||
## 下一步
|
||||
|
||||
第一版到这里就足够了。接下来真正值得做的事,是**先写几篇文章**,让真实的使用暴露出真实的需求——那时候再决定要不要加标签页、搜索或者 RSS,会比现在拍脑袋靠谱得多。
|
||||
|
||||
内容层已经预留了扩展点:标签聚合、相邻文章导航都基于同一份元信息,加功能不需要改动存储格式。
|
||||
Reference in new issue
Block a user