这篇文章记录一下这个博客现在的完整技术方案。它不是一步一步的教程,更像是一份阶段性的项目复盘:从为什么选这套栈,到目录结构、文章系统、私有模块、涂鸦白板、部署方式,以及这段时间做过的一些优化。
如果你也想搭一个属于自己的站点,希望它既能写文章,又能承载一点私人功能,这套方案可以作为一个很轻的参考。
技术栈一览
| 层 | 选型 |
|---|---|
| 运行时 | Bun |
| 后端框架 | Elysia |
| 前端框架 | Vue 3 + Vue Router + Pinia |
| 构建工具 | Vite |
| 数据库 | SQLite (bun:sqlite) |
| Markdown 渲染 | marked |
| 实时能力 | WebSocket |
| 语言 | TypeScript 全栈 |
| 部署 | Ubuntu VPS + Nginx + systemd |
| 备份 | cron + SQLite checkpoint + GitHub 私有仓库 |
没有 ORM,没有 Redis,也没有为了“现代化”而额外引入一堆基础设施。整个项目的核心思路很简单:尽量少的依赖,把博客和日常管理功能放进一个足够轻、足够稳定的全栈应用里。
为什么选 Bun + Elysia
我一开始就不想把这件事做得太重。
Bun 的优点很直接:
- 原生支持 TypeScript
- 自带高性能包管理器
-
内置
bun:sqlite - 本地开发和部署都很轻
对于一个个人项目来说,这种“少装一层就是少一层负担”的感觉很重要。数据库不用单独起服务,后端也不需要再套一层 Node + tsx + prisma + 额外驱动,整个项目从依赖到运行都比较利落。
Elysia 则刚好补上了 API 层这部分。它的好处是:语法简洁、类型友好,而且请求体校验直接内建。
比如登录接口:
app.post("/api/auth/login", async ({ body, cookie, set }) => {
const { password } = body as { password: string };
const ok = await verifyPassword(db, password);
if (!ok) {
set.status = 401;
return { error: "密码错误" };
}
const sid = createSession(db);
cookie[SESSION_COOKIE].set({
value: sid,
httpOnly: true,
sameSite: "lax",
secure: IS_PROD,
path: "/",
maxAge: 86400,
});
return { ok: true };
}, {
body: t.Object({ password: t.String() }),
})
t.Object(...) 这一层会直接帮我做参数校验,配合 TypeScript 用起来很顺手。
项目结构
现在这个项目是一个很小的 monorepo,前后端放在一起:
yophonsblog/
├── src/
│ ├── index.ts # Elysia 路由 + Bun.serve 静态服务
│ ├── db.ts # SQLite 表结构、迁移、CRUD
│ └── posts.ts # Markdown 文章读取、frontmatter 解析、HTML 渲染
├── frontend/
│ ├── src/
│ │ ├── views/ # 页面组件
│ │ ├── components/ # 通用组件
│ │ ├── stores/ # Pinia 状态
│ │ ├── composables/ # API 调用封装
│ │ └── router.ts # 前端路由
├── data/
│ ├── posts/ # Markdown 文章
│ └── yophon.db # SQLite 数据库
└── CLAUDE.md # 项目说明
这里有一个后来做的调整:文章目录最开始是单独放在项目根下的 posts/,后来我把它迁到了 data/posts/。这样文章和数据库都收敛到 data/ 下面,项目结构会更统一,也更符合“内容数据”和“代码”分开管理的思路。
文章系统:文件驱动,而不是 CMS
我现在依然很喜欢这种方式:一篇文章就是一个 Markdown 文件。
frontmatter 格式大概是这样:
---
title: 文章标题
date: 2026-03-28
category: 技术
tags: [Bun, TypeScript]
excerpt: 文章摘要
---
正文内容...
服务端会在请求时完成几件事:
-
读取
data/posts/*.md - 解析 frontmatter
-
用
marked把正文转成 HTML - 做一层基础 HTML 清洗
- 计算阅读时长
阅读时长的算法很朴素:中英文混合字数加总,每 400 字约等于 1 分钟。
function calculateReadingTime(content: string): number {
const chineseChars = (content.match(/[\u4e00-\u9fa5]/g) || []).length;
const englishWords = (content.match(/\b\w+\b/g) || []).length;
const total = chineseChars + englishWords;
return Math.max(1, Math.ceil(total / 400));
}
为了减少无意义的重复读取,我还加了一个很轻量的缓存:根据 data/posts/ 目录和文件的最新修改时间决定是否重建文章列表。对于个人博客这种规模,这已经足够了。
文章页现在也支持:
- 标签跳转
- 分类与归档聚合
- 上一篇 / 下一篇导航
- 评论区
- 404 页面兜底
这种模式最大的优点就是简单:不需要后台写作系统,不需要单独的 CMS,不需要同步流程。 打开编辑器写完,保存,部署,就结束了。
不只是博客:它也是我的生活管理面板
这个站现在已经不只是“文章列表 + 关于页”了。
除了公开部分,还有一组密码保护的私有模块:
- 日记本:支持心情、标签、搜索、日历视图、置顶
- 待办看板:多看板、自定义栏、拖拽排序
- 财务管理:收支记录、分类管理、预算、趋势图、分类统计
- 涂鸦板:公开共享白板,支持分页、缩放、全屏、小地图和实时同步
- 评论系统:文章和页面都可以挂评论
这些功能的数据都统一存在一个 SQLite 文件里。对个人项目来说,这种集中式存储特别舒服:备份简单,迁移简单,排查问题也简单。
涂鸦板:从一个小入口长成共享白板
最近变化最大的其实是涂鸦板。
它一开始只是一个公开画板:用户在页面上画几笔,前端把笔迹 POST 到后端,后端存进 SQLite,再刷新页面时重新加载。这个版本能用,但更像留言板,不像真正的白板。
后来我把它往“多人共享空间”的方向推进了一步。现在它的核心数据结构是:
CREATE TABLE IF NOT EXISTS graffiti_strokes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page INTEGER DEFAULT 0,
client_id TEXT DEFAULT '',
stroke_data TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
);
stroke_data 里存的是一条笔迹的点位、颜色、粗细、工具类型、透明度和混合模式。服务端不会直接相信前端传来的 JSON,而是会重新解析、限制大小、限制点位数量、校验颜色和画笔粗细,再把规整后的数据写进去。
白板现在有几个关键能力:
- WebSocket 实时广播:HTTP 负责写入,WebSocket 负责把新增、删除、清空事件推给其他在线客户端。
-
分页:每条笔迹带
page字段,URL 里也能带?page=3,这样第 0 页不会无限膨胀。 - 可靠保存状态:本地先画出来,再异步保存;保存失败的笔迹会标记并允许重试,保存成功后替换为服务端返回的 id。
-
只撤销自己的笔迹:前端保存一个
client_id,撤销时服务端会同时校验id + page + client_id,避免随便删别人的线。 - 移动端双指缩放:同一套 pointer 事件同时处理画笔、拖拽、滚轮缩放和手机双指缩放。
- 全屏体验:一个按钮短按进入网页全屏,长按进入浏览器全屏,避免工具栏放太多按钮。
- 小地图:右下角显示当前页面所有笔迹的概览,也可以点小地图快速移动视野。
WebSocket 这部分没有另起一个服务,而是继续放在同一个 Bun.serve 里:
if (path === "/api/graffiti/ws") {
if (server.upgrade(req)) {
return;
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
白板写入仍然走 /api/graffiti/strokes,写入成功后广播:
const row = createGraffitiStroke(db, normalized, page, client_id || "");
broadcastGraffiti({ type: "stroke-created", stroke: row, client_id, local_id, page });
return row;
这个拆法我比较喜欢:HTTP 负责可靠写入,WebSocket 负责实时扇出。 如果 WebSocket 临时断了,用户刷新或重连时仍然可以从数据库拿到完整笔迹,不会把数据一致性绑在一条长连接上。
认证与私有模块
认证用的是 cookie session。
大概流程是:
- 登录时校验密码
- 生成 session id
-
写入
sessions表 -
通过
yophon_sessioncookie 维持登录状态 -
私有 API 全部放在 Elysia 的
guard()里统一鉴权
.guard(
{
beforeHandle({ cookie, set }) {
if (!isAuthed(cookie)) {
set.status = 401;
return { error: "未授权,请先登录" };
}
},
},
(app) =>
app
.get("/api/diary", ...)
.get("/api/todo", ...)
.get("/api/finance", ...)
)
前端这边后来也补了一层路由守卫。这样即使用户手动输入 /diary、/todo、/finance,也不会先看到一个不完整的页面状态,而是先走认证检查。
另外,登录接口和公开写入接口还加了简单的频率限制,用来挡掉一些过于粗暴的请求。因为这些计数存储在内存里,后来我又顺手补了定时清理,避免服务长期运行后 Map 越积越大。
这里还有一个容易被忽略的小点:生产环境下应用本身监听 127.0.0.1,公网入口交给 Nginx。也就是说,不是“网站不对公网开放”,而是 Bun 进程不直接暴露在公网。真实访问路径是:
浏览器 -> Nginx:80 -> 127.0.0.1:3000 -> Bun/Elysia
为了让频率限制拿到真实客户端 IP,我没有盲目信任所有 X-Forwarded-For。只有当请求确实来自本机反向代理时,服务端才读取 Nginx 传进来的 X-Real-IP / X-Forwarded-For。这样既能在公网正常工作,也不会让外部用户自己伪造 IP 头绕过限制。
前端:Vue 3 SPA,手写 UI
前端就是一个 Vue 3 SPA,没有上组件库。
原因其实很简单:这个站的页面风格和交互都比较个人化,用现成 UI 库反而容易把项目做得很“通用后台”,失去一点自己的味道。所以我更愿意自己写一层轻量的卡片式设计系统。
现在前端包含这些核心部分:
-
router.ts:前端路由和鉴权守卫 -
stores/auth.ts:认证状态管理 -
composables/useApi.ts:统一 fetch 封装 -
views/:页面级组件 -
components/:导航、页脚、弹窗、评论区、密码门、空状态等复用组件
最近也做了一些小修正:
-
给
PasswordGate加了“加载中”状态,避免刷新时密码框闪一下 -
增加了
NotFoundPage.vue作为前端 404 路由 -
给
AppModal补了Escape关闭、聚焦和基础无障碍属性 -
把几个页面里原来用
alert()的地方改成了内联错误提示
这些都不算大改,但会明显影响一个站点用起来是否顺手。
静态文件服务:直接用 Bun.serve
这个项目生产环境里不是让 Vite 单独起一个服务,也不是用复杂的 SSR,而是后端直接用 Bun.serve 同时处理 API 和静态文件。
逻辑大概是:
-
/api/*交给 Elysia -
其他请求优先找
frontend/dist里的静态文件 -
找不到就回退到
index.html,让前端路由接管
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
if (path.startsWith("/api/")) {
return app.handle(req);
}
if (path !== "/") {
const file = Bun.file(join(DIST_DIR, path));
if (await file.exists()) {
return new Response(file);
}
}
return new Response(Bun.file(join(DIST_DIR, "index.html")));
},
});
后来还顺手把静态资源缓存策略改了一下:
-
/assets/这类带 hash 的构建产物走长期缓存immutable - 其他静态文件走较短缓存
这样对前端资源更友好,也不容易因为缓存策略太激进导致页面更新不生效。
财务模块的一次小优化
我印象比较深的一次优化发生在财务模块。
一开始年度视图写得比较偷懒:
- 年度汇总前端会连续发 12 次请求,逐月加总
- 年度交易记录会把所有数据拉回来,再在客户端按年份过滤
功能当然能跑,但思路并不优雅。后来就把这部分改成了后端直出:
- 增加按年查询交易的接口
- 增加按年汇总收入/支出的接口
- 月度趋势改成单条 SQL 聚合,不再循环查多次
这种优化的价值不在于“省了多少毫秒”,而在于项目结构更清晰:该在数据库层做的事,就别丢给前端。
部署方式
现在部署很简单:一台 Ubuntu VPS,Nginx 做公网入口,systemd 管理 Bun 进程。
服务配置核心大概是这样:
[Service]
WorkingDirectory=/root/yophon-blog
ExecStart=/root/.bun/bin/bun run src/index.ts
Restart=always
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3000
前端构建产物输出到 frontend/dist,服务重启后由 Bun.serve 直接托管。Nginx 只负责外层反代和 WebSocket upgrade:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
这里 HOST=127.0.0.1 是有意为之。Bun 只接受本机连接,公网访问统一从 Nginx 进来。这样服务仍然全网可见,但暴露面更小,代理头也更可控。
实际更新流程也很朴素:
git pull
bun install
bun run build
systemctl restart yophon-blog.service
数据库是单文件 SQLite,所以备份方式也很直接。但 SQLite 开了 WAL 之后,不能只把 data/yophon.db 当成一个普通文件随手复制,否则最新写入可能还在 -wal 文件里。
现在服务器上有一个定时任务,每 6 小时跑一次备份脚本。备份前先做一次 checkpoint:
PRAGMA wal_checkpoint(TRUNCATE);
然后再复制数据库、提交到备份仓库并 push 到 GitHub。备份仓库和主项目分开,这样主仓库主要放代码和文章,备份仓库专门存运行期数据快照。后来我还给备份日志加了时间戳,这样以后判断“到底有没有跑过”会清楚很多。
这套方案适合谁
如果你想做的是下面这种站:
- 有公开博客内容
- 有一点自己的私密功能
- 不追求大而全的后台系统
- 更在意掌控感,而不是平台化能力
那 Bun + Elysia + Vue 3 + SQLite 这套东西真的很顺手。
它不一定适合高并发、大团队、复杂协作,也不适合那种要做成标准 SaaS 产品的需求。但对于个人博客 / 个人工具 / 小型自留地,它有一种非常舒服的“轻”。
总结
这套博客到现在最让我满意的,不是用了什么新技术,而是它一直保持着一个比较稳定的边界:
- 公开内容用 Markdown 文件驱动
- 私有数据统一落 SQLite
- 后端保持很少的文件和很直的逻辑
- 前端保留足够的交互,但不过度复杂
- 公开互动功能用 WebSocket 增强体验,但写入仍然落数据库
- 部署和备份都尽量简单,并且让公网入口、应用进程、数据备份各自边界清楚
说到底,个人博客最重要的不是“技术栈有多炫”,而是它能不能长期被你自己维护下去。
对我来说,这个项目现在已经不仅是一个博客,更像是一个不断生长中的个人空间。后面它可能还会继续变,但我大概率还是会坚持这条路线:简单、够用、可控。