博客架构演进:从纯静态到混合渲染
缘起
BlogFirefly 最初是一个基于Astro 5astro build && pm2 restart 整一遍。
随着文章增多,全量构建的等待时间变得难以忍受。
正文内容通过 .md 文件管理,用 getCollection("posts") 在构建时读取所有 Markdown 文件,解析 frontmatter 后生成静态页面。
这个阶段的最大问题:
- 每次内容变更必须全量构建(即使只改了一行)
- 构建消耗时间和服务器资源(1.6G 内存机器)
- 没有编辑界面,只能本地改文件提交,无法实时查看修改之后的效果
除非使用 pnpm dev启动开发服务器,但我认为这样不够优雅
解决思路很自然:把内容的管理和渲染分离开,引入数据库和运行时渲染。
第一阶段:数据上库 + 管理后台
第一步是让文章脱离构建过程。引入 PostgreSQL + drizzle-orm,将文章元数据和正文存入数据库。
数据库设计
posts├── id SERIAL PRIMARY KEY├── code TEXT UNIQUE NOT NULL -- "cv1", "cv2"...├── slug TEXT UNIQUE NOT NULL -- 兼容旧 .md 路径├── title TEXT├── description TEXT├── category TEXT├── tags TEXT[]├── cover_image TEXT├── status ENUM(published|hidden|draft)├── pinned BOOLEAN DEFAULT false├── word_count INTEGER├── published_at TIMESTAMP├── created_at TIMESTAMP└── updated_at TIMESTAMP
post_contents├── id SERIAL PRIMARY KEY├── post_id INTEGER UNIQUE → posts.id (ON DELETE CASCADE)├── content TEXT -- Markdown 正文(无 frontmatter)└── updated_at TIMESTAMP
users├── id SERIAL PRIMARY KEY├── username TEXT UNIQUE├── password_hash TEXT -- bcrypt 哈希└── created_at TIMESTAMPposts表存储文章元数据和状态控制post_contents表存储纯 Markdown 正文,与posts一一对应posts行被删除时,post_contents通过ON DELETE CASCADE自动清理
认证系统
- JWT 令牌存在 httpOnly cookie 中,有效期 7 天
- bcryptjs 验证密码,jose 签发/验证 JWT
- Astro middleware 拦截
/admin/*路径,校验 token,无效则 302 跳回首页
管理后台功能
文章列表页(/admin/posts/)├── 统计卡片(StatCard.svelte——已发布/隐藏/草稿/总计)│ └── 数据来自 POST /api/admin/posts/stats│ └── 操作后触发 admin:statsChanged 事件,Svelte 组件自动刷新├── 搜索 / 批量操作 / 排序分页└── 编辑 / 显示隐藏 / 删除
文章编辑页(/admin/posts/cv6/)├── ArticleMetaForm.svelte(元数据:标题、分类、标签、置顶…)└── MarkdownEditor.svelte(CodeMirror 6 + 实时预览) └── 预览使用完整 unified 管道(同文章页)删除是硬删除——从 posts 表直接删行,post_contents 级联清除,同时删除对应的 .md 文件。
第二阶段:全站 SSR
第一阶段在 SSG 模式下加数据库,但文章详情页在构建时仍然需要读取所有 .md 文件。每新增一篇文章就要构建一次。
真正的解决方案:放弃全量构建,所有页面改为 SSR。
关键迁移
| 项目 | 改前 | 改后 |
|---|---|---|
| 渲染模式 | SSG(getStaticPaths) |
SSR(prerender = false) |
| 数据源 | getCollection("posts") 读 .md 文件 |
drizzle-orm 直查 posts 表 |
| 缓存 | 无 | 10 秒内存 TTL |
| 路由 | [...slug].astro(slug 路径) |
[code].astro(cv 码路径) |
| PostCard | 调用 render(entry) 解析 frontmatter |
移除 render(),数据全部来自 DB |
内存缓存
为了避免每次 SSR 请求都查数据库,首页/归档/标签/分类等列表页实现了 10 秒 TTL 缓存:
let postsCache: PostEntry[] | null = null;let postsCacheTime = 0;const POSTS_CACHE_TTL = 10_000;
async function getRawSortedPosts(): Promise<PostEntry[]> { const now = Date.now(); if (postsCache && now - postsCacheTime < POSTS_CACHE_TTL) { return postsCache; // ← 10 秒内直接返回 } const rows = await db.select()...where(eq(status, "published")); postsCache = entries; postsCacheTime = now; return entries;}编辑文章后,管理后台不再重启 PM2——缓存 10 秒后自动过期,下次请求读取最新数据。
cv 码路由
URL 从 /posts/site-architecture/site-architecture/ 改为 /posts/cv4/:
cv码是自增短标识,与数据库自增id对齐- 主页 PostPage 通过
getPostUrlBySlug()从 DB 查询 slug→code 映射 - 映射表有 10 秒 TTL,新建文章后至多等 10 秒 URL 自动切换
- 文章详情页同时支持 cv 码和 slug 兜底查询(缓存延迟时仍能正确渲染)
Markdown 渲染管道
文章正文通过完整的 unified 管道处理,从文章页到后台预览完全一致:
共 16 个自定义插件,覆盖代码高亮(expressive-code)、数学公式(KaTeX)、提醒框(rehype-callouts,支持 GitHub/Obsidian/VitePress/Docusaurus 四种主题)、图表(mermaid + plantuml)、图像网格、GitHub 卡片、外部链接识别、邮件保护等功能。
PostCard 性能优化
之前的 PostCard 在 SSR 时会调用 render(entry) 来解析 frontmatter 和正文摘要。这个操作会触发完整 unified 管道解析,对于列表页(首页/归档/标签)上有 5–20 篇文章的情况,开销很大。
优化方案:
- 所有文章数据(标题、描述、标签、封面、字数等)直接来自
posts表查询 - 去掉 PostCard 中的
render()调用 - 首页/归档等列表页无需解析 Markdown,纯 SQL 查询即可
第三阶段:CI/CD 自动部署
三个 job 的顺序与并行关系:
| Job | 运行位置 | 耗时 | 说明 |
|---|---|---|---|
| quality | GitHub runner | ~10s | biome 格式/风格检查,失败则全部中止 |
| server-pull | 生产服务器 | ~5s | git pull + pnpm install + 数据库推送 |
| build-and-deploy | GitHub runner | ~3min | pnpm build + rsync --delete + pm2 restart |
build-and-deploy 依赖 quality 和 server-pull 都成功后才会执行。server-pull 先同步代码并迁移数据库,build-and-deploy 再用最新的代码构建并部署。
架构总览
关键技术栈
| 层级 | 技术 |
|---|---|
| 框架 | Astro 6 + Svelte 5 + Tailwind CSS 4 |
| 代码高亮 | expressive-code(行号/折叠/语言徽章/双主题) |
| 图表 | PlantUML(服务端渲染)+ Mermaid(客户端渲染) |
| 数学 | KaTeX |
| 提醒框 | rehype-callouts(GitHub / Obsidian / VitePress / Docusaurus) |
| 数据库 | PostgreSQL + drizzle-orm |
| 认证 | bcryptjs + jose (JWT, httpOnly cookie) |
| 编辑器 | CodeMirror 6 (Markdown) |
| CI/CD | GitHub Actions(quality → server-pull → build-and-deploy) |
| 服务器 | Nginx + PM2 + Node.js 22 |
| 部署方式 | git push → CI/CD → rsync + pm2 restart |
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
NaBCWorkshop