FIELD NOTE
把博客做成一份安静的阅读界面:风不止个人博客的设计说明
从内容结构、视觉令牌、阅读交互到静态部署,记录这个博客为什么这样设计,以及实现时踩过的坑。
正文宽度
做博客最容易陷入的误区,是先堆功能,再想怎么把它们放在一起。
这个站的顺序反过来:先确定它是一份以文字为中心的阅读界面,再决定哪些功能值得出现。搜索、标签、目录、主题切换和评论都服务于阅读,而不是为了让首页看起来「功能很多」。
这篇文章记录当前版本的设计取舍,也方便以后改主题时知道哪些数值和实现细节不能随手删掉。
内容优先,而不是组件优先
文章是站点的核心对象。每篇文章是 src/content/ 下的一个 MDX 文件,元信息和正文放在一起:
- 文件名直接成为 URL 的 slug,发布新文章不需要改路由。
- 列表页只读取元信息,不提前编译整篇正文。
- 文章页在构建时生成静态 HTML,并根据标题生成目录。
date负责日期排序,publishedAt用来解决同一天多篇文章的顺序。- 草稿通过
draft隐藏,不需要额外的数据库状态。
MDX 保留了 Markdown 的低门槛,同时允许文章在需要时使用 React 组件。代码高亮在构建期由 Shiki 完成,读者打开文章时不需要再下载一套高亮引擎。
文章页顶部已经渲染了标题,所以正文不再重复写一级标题。这样标题、摘要、日期和标签的结构始终一致,目录也只收录正文中的二级和三级标题。
视觉语言:克制的灰阶和一处强调色
整体使用中性灰阶、留白和细分割线组织信息,蓝色只承担交互强调:链接、当前目录项、焦点状态和可点击控件。页面不依赖阴影或多色装饰制造层次,而是用背景、边框和间距表达层级。
内容面板是圆角亚克力面板:半透明底色叠加 backdrop-filter,背景图存在时可以透出环境纹理,没有背景图时仍然是干净的纯色面板。面板分三档:
| 面板 | 用途 |
|---|---|
.panel | 文章卡片、目录侧栏 |
.panel-strong | 正文和页面标题区 |
.panel-raised | 代码块、标签和上下篇导航 |
三档不是为了装饰,而是为了处理叠层关系。正文需要更稳定的底色,代码块又需要在正文面板上再抬一层。标题行和可折叠内容属于同一个面板,不嵌套两个带圆角的面板,避免接缝处出现两个互相顶住的圆角。
面板样式放在 Tailwind 的 @layer components 中,让 @layer utilities 里的工具类可以覆盖它们的状态。背景图只需要放入 public/,再在 globals.css 的 :root中设置:
--bg-image: url("/bg.jpg");无障碍优先于透明效果:用户设置 prefers-reduced-transparency: reduce,或浏览器不支持 backdrop-filter 时,面板会退化为实色。
顶栏和底栏是例外。它们横跨屏幕边缘,因此使用直角和单侧边框,不使用会露出背景的圆角;毛玻璃效果仍然保留。
明暗主题要在首帧就正确
主题有两个来源,优先级是「手动选择 > 系统偏好」:
| 来源 | 实现 |
|---|---|
| 系统偏好 | prefers-color-scheme: dark |
| 手动选择 | <html data-theme="light|dark"> 和 localStorage |
主题状态放在 <html> 的属性上,而不是只放在 React state 里。src/lib/theme.ts里的初始化脚本被内联在 <head>,会在浏览器首次绘制前读取偏好并设置data-theme 和 color-scheme。因此深色用户刷新时不会先看到一帧白色页面。
React 组件只负责开关的可访问状态和写入偏好,真正的颜色由 CSS 变量决定。改配色时只改 globals.css 顶部的变量,并同步检查系统深色媒体查询和手动主题选择器的两组值。
主题开关的触控热区是 44×44px,轨道和滑块复用现有颜色令牌,不额外制造一套颜色。所有动画都遵守 prefers-reduced-motion: reduce。
文章页的阅读工具
目录
文章目录只收录 h2 和 h3。服务端的 getTableOfContents() 使用和rehype-slug 相同的 github-slugger,因此目录链接和真正渲染出来的标题锚点保持一致;pnpm test 会用真实 HTML 检查这一点。
有足够空间时,目录固定在正文右侧并跟随当前阅读位置高亮;空间不够时,目录回到正文上方折叠。它由当前正文宽度档位和视口共同决定,而不是简单地写一个固定媒体查询:
| 档位 | 正文上限 | 目录并排门槛 |
|---|---|---|
| 窄 | 36rem | 944px |
| 标准 | 52rem | 1200px |
| 宽 | 68rem | 1456px |
目录展开使用 grid-template-rows: 0fr → 1fr,不需要用 JavaScript 猜测高度。目录项始终保留在 DOM 中,只改变可见高度,这样展开动画才有起点和终点。
点击目录不会瞬间跳到锚点,而是使用缓动滚动,并把目标放到视口约三分之一处,避开吸顶区域。滚动期间高亮锁定到目标项,避免页面经过几十个章节时目录一路闪过;只有用户再次滚动时才解除锁定。地址栏用 replaceState 更新,不污染后退历史。
正文宽度
文章页提供窄、标准、宽三档,偏好保存在 fbz-reading-width。档位使用 rem 而不是固定像素,因为中文阅读更接近按字号和字符宽度衡量。
「每行约 N 字」不是写死的估算值,而是用 canvas 测量全角字符,再结合实际正文列宽计算。窗口缩放、旋转屏幕和切换档位都会重新测量。
宽度状态只能由一个组件维护,切换按钮和正文容器共享同一个状态;正文容器使用w-full 配合 max-width,不能用会把列撑满的 flex-1。页面级使用scrollbar-gutter: stable,避免长短页面之间因为滚动条出现或消失而左右跳动。
代码块
Shiki 在构建期同时输出浅色和深色 token 颜色,CSS 根据当前主题选择它们。代码块自己的背景仍使用站点面板颜色,不直接使用 Shiki 主题的白色或深色背景,以免脱离整体配色。
代码块的复制按钮和语言标识常驻显示,长代码超过约 420px 时额外提供折叠。折叠动画使用实际 scrollHeight 设置具体的 max-height,而不是用不可靠的固定值。横向超长代码保留滚动能力,并在右侧显示渐变提示;滚到最右后提示消失,避免盖住最后几个字符。
这部分有一个容易忽略的 CSS 细节:rehype-pretty-code 会让 code 使用 grid 布局,只给 pre 增加右内边距不能保证滚动到末尾后留下空白。将 code 设为width: max-content、同时保留 min-width: 100%,右内边距才会真正成为末尾空间。
动效只用来解释状态
页面切换使用 React View Transitions,内容区域以 pathname 作为 key,顶栏、底栏和阅读进度条留在过渡区域外,导航时保持稳定。浏览器不支持 View Transitions 时仍然正常切换,只是不显示动画。
其他动效包括卡片悬停、目录展开、目录项错峰进入、代码块工具条和回到顶部按钮。它们都很短,也都在减少动态效果的系统设置下归零。透明度为 0 的控件同时会移出Tab 顺序并设置 aria-hidden,避免键盘用户聚焦到看不见的按钮。
静态页面和动态评论分开
博客页面使用 Next.js App Router 在构建期预渲染,Cloudflare Pages 只需要托管out/。静态导出通过 NEXT_OUTPUT=export 开关控制:
NEXT_OUTPUT=export pnpm build本地普通 pnpm build 仍可用 pnpm start 预览,因此不会把静态导出的限制带回日常开发流程。导出模式使用目录形式 URL,并关闭 next/image 服务端优化,让静态产物里的图片直接指向 public/。
评论是唯一的动态部分。它不改变文章页的静态性质,而是在浏览器中请求 CloudflarePages Functions:
functions/ API 和管理接口
db/ D1 表结构与迁移
wrangler.toml Pages 与 D1 绑定评论使用 Cloudflare D1 保存,访客不需要注册。后端只保存加盐后的 IP 哈希用于限流,不保存真实 IP;同一 IP 十分钟最多发表三条。回复最多两层,回复回复时归并到根留言,避免手机屏幕上出现越来越深的缩进。删除根留言时先删除回复,再删除父留言,避免留下孤儿数据。
管理登录使用 HMAC 签名的 HttpOnly Cookie,不在数据库里保存会话。Cookie 使用SameSite=Strict,生产环境使用 Secure,接口响应使用 no-store。管理页面本身可以被静态托管,但真正的权限检查始终在 /api/admin/* 接口完成。
最后
这个博客的设计原则可以压缩成三句话:
- 先让文章好读,再给文章添加工具。
- 用稳定的令牌和共享状态减少页面之间的意外差异。
- 能在构建期完成的工作就不要交给访问者的浏览器,必须动态的部分再单独隔离。
所以它看起来比较安静:没有为了展示技术而展示技术,但每个留下来的细节,都有一个具体的阅读或维护理由。
DISCUSSION
留言
正在加载留言…