This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
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,会比现在拍脑袋靠谱得多。
内容层已经预留了扩展点:标签聚合、相邻文章导航都基于同一份元信息,加功能不需要改动存储格式。
@@ -0,0 +1,144 @@
---
title: "设计令牌:把设计规范变成可执行的代码"
date: "2025-02-20"
summary: "设计系统最容易失效的地方不是设计稿,而是设计与代码之间的翻译过程。用 Tailwind v4 的 @theme 让令牌成为唯一事实来源。"
tags: ["设计系统", "Tailwind", "CSS"]
draft: false
---
每个团队都遇到过这种对话:
> 「这个按钮的圆角怎么是 8px?设计稿是 12px。」
> 「我随手写的,没注意。」
问题不在于谁写错了,而在于**规范停留在文档里,没有变成代码**。
## 令牌是设计与代码之间的契约
一份设计规范通常包含颜色、字体、间距、圆角、阴影。这些值如果只写在 Figma 和 Markdown 里,它们就只是**建议**;只有当它们变成代码里有名字的变量,才成为**约束**。
关键在于给值起名,而不是记住值:
| 不要这样写 | 要这样写 |
| --- | --- |
| `border-radius: 12px` | `rounded-lg` |
| `color: #0a0a0a` | `text-ink` |
| `padding: 24px` | `p-xl` |
前者的语义是「12 像素」,后者的语义是「标准卡片圆角」。当设计决定把标准圆角改成 14px 时,前者需要全局搜索替换,后者只需要改一行。
## Tailwind v4 的 @theme
Tailwind v4 把配置从 JavaScript 搬回了 CSS。用 `@theme` 声明的变量会同时具备两种身份:**CSS 自定义属性**和**生成工具类的依据**。
```css
@theme {
--color-ink: #0a0a0a;
--color-brand-green: #00d4a4;
--radius-lg: 12px;
--spacing-xl: 24px;
}
```
声明之后,`text-ink`、`bg-brand-green`、`rounded-lg`、`p-xl` 这些工具类会自动可用,同时也可以在原生 CSS 里直接引用:
```css
.card {
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
border: 1px solid var(--color-hairline);
}
```
这就是单一事实来源的价值——工具类和手写 CSS 读的是**同一份变量**,不可能对不上。
## 颜色:先定语义,再定色值
新手容易直接把调色板搬进来:`--color-gray-100`、`--color-gray-200`、`--color-blue-500`。这能工作,但它没有表达**用途**。
更好的方式是按角色命名:
```text
--color-ink 主文字
--color-steel 三级文字
--color-canvas 页面背景
--color-surface 次级表面
--color-hairline 分割线
--color-brand-green 品牌强调色
```
这样命名之后,暗色模式的实现会变得非常自然——同一个语义变量换一组值即可,组件代码一行都不用改。反过来,如果组件里写的是 `bg-gray-100`,切暗色时就只能靠 `dark:` 前缀逐处覆盖。
## 字号的陷阱:行高和字距
排版令牌如果只存字号,会丢掉一半信息。同一个 16px,正文需要 1.5 倍行高,标题可能只需要 1.2。
Tailwind v4 允许把行高和字距直接绑定到字号上:
```css
@theme {
--text-heading-2: 36px;
--text-heading-2--line-height: 1.2;
--text-heading-2--letter-spacing: -0.5px;
}
```
现在 `text-heading-2` 一次带出行高和字距,不需要每次手写 `text-4xl leading-tight tracking-tight`。
还有一个细节值得注意:**负字距应该随字号递增**。大标题需要更紧的字距才显得结实,小字号则应该放松甚至归零。
```text
72px → letter-spacing: -2px
36px → letter-spacing: -0.5px
18px → letter-spacing: 0
```
## 间距:八点网格之外的例外
八点网格(8px 递增)是常见约定,但直接用它做类名会很难读:`p-3` 到底是 12px 还是 16px?
按尺码命名的可读性更好:
```css
@theme {
--spacing-xxs: 4px;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 20px;
--spacing-xl: 24px;
--spacing-section: 64px;
--spacing-hero: 120px;
}
```
注意最后两个:`section` 和 `hero` 是**语义间距**。区块之间 64px、Hero 区 120px,这类值在页面上反复出现,给它们名字比记数字可靠得多。
## 用类型约束收口
令牌再多,也需要有人保证组件只从令牌里取值,而不是随手写魔法数字。`class-variance-authority` 能把变体定义收敛到一处:
```ts
const buttonVariants = cva("inline-flex items-center rounded-full", {
variants: {
variant: {
primary: "bg-primary text-on-primary",
secondary: "border border-hairline text-ink",
accent: "bg-brand-green text-primary",
},
size: {
md: "px-lg py-[10px] text-button-md",
sm: "px-md py-xs text-body-sm",
},
},
defaultVariants: { variant: "primary", size: "md" },
});
```
组件使用者只能在 `primary | secondary | accent` 里选,写不出 `bg-[#0a0a0a]` 这种绕过令牌的代码。**把约束交给类型系统,比交给代码评审更可靠。**
## 一条经验
设计令牌的价值不在于「变量很多」,而在于**边界清晰**。真正需要严格约束的是颜色和间距——它们决定了视觉的一致性;而字号阶梯可以适度宽松,因为排版本来就需要灵活性。
最后一点:令牌的命名应该描述**意图**,而不是外观。`brand-green` 比 `mint` 好,`danger` 比 `red` 好。外观会变,意图通常不会。
+143
View File
@@ -0,0 +1,143 @@
---
title: "React Server Components 的心智模型"
date: "2025-01-12"
summary: "服务端组件不是「服务端渲染的组件」,它是一次关于数据边界的重新划分。理解这条边界,比记住 API 更重要。"
tags: ["React", "Next.js", "性能"]
draft: false
---
第一次接触 Server Components,最容易带着旧框架的直觉去理解它:**「这不就是 SSR 吗?」**
不是。SSR 是把同一个组件在服务端渲染成 HTML,然后在客户端**再跑一遍**完成 hydration。Server Components 是另一回事——它**只在服务端运行**,代码永远不会发送到浏览器。
## 两种组件,一条边界
在 App Router 里,组件默认是服务端组件。只有当文件顶部写了 `"use client"`,它才会成为客户端组件。
这条边界的关键含义是:
| | Server Component | Client Component |
| --- | --- | --- |
| 运行位置 | 仅服务端 | 服务端(首屏)+ 浏览器 |
| 能否用 `useState` | 否 | 是 |
| 能否直接读数据库 | 是 | 否 |
| 代码是否进 bundle | 否 | 是 |
所以选择不是「哪个更好」,而是「这个组件需要什么」:
- 需要读取数据、访问文件系统、使用密钥 → **Server**
- 需要 `useState`、事件处理、浏览器 API → **Client**
> 默认留在服务端,只在真正需要交互时才越过边界。
## 边界是单向的
数据只能从服务端流向客户端,不能反向。服务端组件可以渲染客户端组件,但客户端组件不能 `import` 服务端组件。
```tsx
// ✅ 服务端组件渲染客户端组件,可以把数据作为 props 传下去
export default async function Page() {
const posts = await getPosts();
return (
<main>
<PostList posts={posts} />
<ScrollProgress /> {/* 客户端组件 */}
</main>
);
}
```
反过来不行,因为客户端 bundle 里没有服务端组件的代码。
需要注意的是,**props 必须可序列化**。函数、类实例、Date 之外的复杂对象都传不过去。
```tsx
// ❌ 函数无法跨过边界
<ClientChart format={(v) => v.toFixed(2)} />
// ✅ 传数据,让客户端自己格式化
<ClientChart values={values} precision={2} />
```
## 把服务端代码物理隔离
一个常见的 bug 是:某个工具函数本来只在服务端用,结果被链式 `import` 进了客户端组件,**连同数据库凭据一起**打进了浏览器产物。
`server-only` 包能在构建阶段拦住这类事故:
```ts
import "server-only";
export function getPosts() {
// 这里可以安全地读取内容目录或查询数据库
}
```
只要有任何客户端组件(直接或间接)导入这个模块,构建就会失败。这比靠代码评审去发现可靠得多。
## 交互要沉到叶子节点
既然客户端组件会进 bundle,就应该让**边界尽可能靠近叶子**。
```tsx
// ❌ 整个页面变成客户端组件,所有文章数据都要走 props 传
"use client";
export default function ArticlePage({ post }) {
const [liked, setLiked] = useState(false);
return (
<article>
<MDXContent source={post.content} />
<LikeButton liked={liked} onClick={() => setLiked(!liked)} />
</article>
);
}
```
问题在于:MDX 渲染器、语法高亮、整个文章正文——全部被拖进了客户端 bundle,只为了一个点赞按钮。
正确的拆法是让正文留在服务端:
```tsx
// ✅ 页面是服务端组件,只有按钮是客户端组件
export default async function ArticlePage({ params }) {
const post = await getPost(params.slug);
return (
<article>
<MDXContent source={post.content} />
<LikeButton />
</article>
);
}
```
`ArticlePage` 和 `MDXContent` 都不进 bundle,`LikeButton` 才是唯一需要下载的交互代码。**交互的粒度决定了 bundle 的大小。**
## 常见误区
**误区一:客户端组件只在浏览器运行。**
客户端组件同样会在服务端做一次预渲染,用来产出首屏 HTML。所以它里面不能直接访问 `window`——除非放进 `useEffect`。
**误区二:服务端组件能保留状态。**
服务端组件每次请求都会重新执行,没有状态、没有生命周期,也不能用 `useEffect`。
**误区三:加了 `"use client"` 就「更安全」。**
恰恰相反——它意味着代码会发送给用户。加上这个指令应该是**有意识的选择**,而不是遇到报错就顺手加的补丁。
## 一个判断口诀
拿不准组件该放哪边时,问三个问题:
1. 需要 `useState` / `useEffect` 吗?→ 需要就是客户端
2. 有事件处理(`onClick` 等)吗?→ 有就是客户端
3. 涉及密钥、数据库、文件系统吗?→ 涉及就必须是服务端
三个都不沾,就**留在服务端**。这通常是默认且正确的选择。
## 小结
Server Components 真正改变的不是渲染性能,而是**数据的归属**。它让「这份数据该在哪里被读取」重新成为一个需要思考的设计问题,而不是把所有逻辑都堆到客户端再想办法优化。
想清楚边界画在哪里,比记住哪个 API 可用更重要。
@@ -0,0 +1,15 @@
---
title: "(草稿)Web 性能优化的优先级"
date: "2025-04-01"
summary: "这是一篇用于验证草稿过滤的示例文章,不应出现在首页、文章列表或 sitemap 中。"
tags: ["性能"]
draft: true
---
这篇文章的 `draft: true`,用于验证草稿功能:
- 不出现在首页文章列表
- 不出现在 sitemap.xml
- 直接访问其在线的 URL 应返回 404
如果它在任何地方出现了,说明草稿过滤逻辑有 bug。