文章

从零搭建个人博客

这个博客是怎么搭出来的:Bun + Elysia 后端、Vue 3 + Vite 前端、SQLite 数据库、WebSocket 涂鸦白板,以及 VPS 上的部署和备份。

目录
阅读字号18

这篇文章记录一下这个博客现在的完整技术方案。它不是一步一步的教程,更像是一份阶段性的项目复盘:从为什么选这套栈,到目录结构、文章系统、私有模块、涂鸦白板、部署方式,以及这段时间做过的一些优化。

如果你也想搭一个属于自己的站点,希望它既能写文章,又能承载一点私人功能,这套方案可以作为一个很轻的参考。

技术栈一览

层 选型
运行时 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: 文章摘要
---

正文内容...

服务端会在请求时完成几件事:

  1. 读取 data/posts/*.md
  2. 解析 frontmatter
  3. 用 marked 把正文转成 HTML
  4. 做一层基础 HTML 清洗
  5. 计算阅读时长

阅读时长的算法很朴素:中英文混合字数加总,每 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_session cookie 维持登录状态
  • 私有 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 增强体验,但写入仍然落数据库
  • 部署和备份都尽量简单,并且让公网入口、应用进程、数据备份各自边界清楚

说到底,个人博客最重要的不是“技术栈有多炫”,而是它能不能长期被你自己维护下去。

对我来说,这个项目现在已经不仅是一个博客,更像是一个不断生长中的个人空间。后面它可能还会继续变,但我大概率还是会坚持这条路线:简单、够用、可控。