本站技术栈:Nuxt 4 + TypeScript + SQLite
一个只跑一个进程的个人博客是怎么搭起来的:Nuxt 4 负责页面与 SSR,Nitro 提供 API,SQLite 与 Drizzle 保存数据,Zod 守住输入边界,Vitest 与 Playwright 负责验证。
这个博客的目标很朴素:能舒服地写、能被搜索引擎完整收录、三年后的自己还看得懂代码。它不需要扛高并发,也不打算拆成前后端两个项目,于是整套系统被收在一个 Nuxt 应用里。这篇记录它实际使用的技术栈,以及每一项选择背后的取舍。
一句话架构
Browser
│
Nginx / Caddy HTTPS、反向代理、静态资源
│
Nuxt 4(Nitro / Node.js) 页面 SSR + API + 业务规则
│
SQLite(WAL)+ uploads/ 业务数据与图片
页面、后台和 API 在同一个应用内,共享同一套 Zod Schema 与 TypeScript 类型。部署时只有一个 Node 进程、一个 SQLite 文件,没有 Redis,没有消息队列,也没有第二个需要单独运维的数据库实例。
这套栈支撑了什么
| 区域 | 能力 |
|---|---|
| 前台 | 首页、文章列表与详情(SSR)、标签、归档、追番、足迹、Sitemap |
| 写作 | 后台登录、Markdown 编辑器(实时预览、插图、导入 .md)、草稿 / 发布 / 归档、图片库 |
| 附加 | 追番排名、旅行足迹、Giscus 评论 |
| SEO | 每页 title / description / canonical / Open Graph,文章页带 BlogPosting 结构化数据 |
技术选型一览
下表是 pnpm-lock.yaml 里实际锁定的版本:
| 层级 | 技术 | 版本 | 职责 |
|---|---|---|---|
| 应用框架 | Nuxt(Vue 3) | 4.5.2 / 3.5.41 | 页面、路由、SSR、SEO |
| 语言 | TypeScript | 5.9.3 | 前后端统一类型 |
| 服务端 | Nitro on Node.js | Node 24 | API、服务端逻辑、生产运行时 |
| 样式 | Tailwind CSS | 4.3.3 | 布局、响应式与设计令牌 |
| 数据库 | SQLite | — | 业务数据持久化 |
| ORM | Drizzle ORM | 0.44.7 | 类型安全的查询与 Migration |
| 数据库驱动 | better-sqlite3 | 12.11.1 | Node 侧同步 SQLite 连接 |
| 输入校验 | Zod | 4.4.3 | API、表单与环境变量校验 |
| Markdown | marked + isomorphic-dompurify | 16.4.2 / 2.36.0 | 渲染与 HTML 清洗 |
| 密码哈希 | bcryptjs | 3.0.3 | 管理员密码 |
| 日志 | Pino | 9.14.0 | 结构化运行日志 |
| 测试 | Vitest + Playwright | 3.2.7 / 1.63.0 | 单元、集成与端到端测试 |
| 静态检查 | ESLint + vue-tsc | 9.39.5 / 3.3.11 | 代码规范与类型检查 |
| 包管理 | pnpm | 11.22.0 | 依赖锁定与脚本管理 |
刻意没有引入的东西同样是选型的一部分:没有 Redis(进程内限流足够),没有 Elasticsearch(文章量级用 SQLite 查询足够),没有 GraphQL(内部接口用不上),也没有把实验性的 node:sqlite 当作生产驱动。
为什么是模块化单体
- 一次
git clone、一次pnpm install、一条pnpm dev就能跑起整个站点。 - 前后端共享类型:
shared/schemas里的 Zod Schema 同时给 API 校验和前端表单使用,改字段时类型检查会直接报错。 - 没有跨服务鉴权、CORS 和分布式事务的问题。
- 个人博客的写入者就是作者本人,SQLite 的单写入限制在这里不构成瓶颈。
只有当多实例部署、写并发明显升高或跨机器异步任务出现时,才需要迁移 PostgreSQL 或拆分服务;在那之前领域层保持独立,迁移成本是可控的。
分层与依赖方向
Page / Component → API → Domain Service → Repository → Database
│
└→ shared schemas / types
app/ 页面、组件、布局、composables、中间件
server/
api/ HTTP 适配:Zod 校验 → 领域服务 → 统一响应
domains/ 领域服务与 Repository(auth / posts / media / anime-rankings / feeds)
db/ Drizzle Schema、连接、Migration
routes/ sitemap.xml
utils/ 响应包装、错误转换、限流、路径与日志
shared/ 前后端共享的常量、Schema、类型与工具函数
tests/ unit / integration / e2e
data/ SQLite 与上传文件(不提交到 Git)
几条硬规则:Vue 组件不碰数据库;API 路由只负责校验、鉴权和响应转换,不堆业务逻辑;所有 Drizzle 查询收在 Repository 里;底层模块不反向依赖页面。
数据层:SQLite + Drizzle
- Schema 用 Drizzle 的 SQLite 方言声明,字段类型直接推导出 TypeScript 类型。
- 结构变更全部走 Migration:
pnpm db:generate生成 SQL,pnpm db:migrate执行,产物提交进仓库。 - 连接初始化固定四行 PRAGMA:
journal_mode = WAL、foreign_keys = ON、busy_timeout = 5000、synchronous = NORMAL。 - 开发、测试、生产使用不同的数据库路径;集成测试跑在临时库上,不会污染开发数据。
- 时间统一按毫秒时间戳存储,外键关系显式声明并按需级联。
目前的数据表:users、sessions、posts、tags、post_tags、media、settings、anime_rankings。
输入边界与统一响应
所有外部输入都要过 Zod:请求体、路径参数、查询参数、表单和环境变量。API 只返回两种形状:
{ "data": {}, "meta": { "page": 1, "pageSize": 10, "total": 42, "totalPages": 5 } }
{ "error": { "code": "POST_NOT_FOUND", "message": "文章不存在" } }
错误码集中在 shared/constants 里维护,客户端可以稳定判断;响应里不会出现堆栈、SQL 或服务器路径。列表接口一律分页,单页上限 50 条。
内容管线:Markdown 怎么变成页面
文章以 Markdown 原文存入数据库,渲染结果同时缓存下来:
Markdown 原文 → marked(GFM)→ DOMPurify 白名单清洗 → 缓存 HTML → 页面直接输出
- 渲染与清洗都只在服务端完成,客户端不承担安全职责。
- 白名单只放行正文需要的标签,
script、iframe、style与内联事件一律丢弃。 - 外链自动补
target="_blank"与rel="noopener noreferrer nofollow",图片自动补loading="lazy"。 - 正文里的标题整体降一级,保证一个页面只有一个
h1,也就是文章标题。 - 后台编辑器的实时预览复用同一套渲染实现,不会出现「预览好看、发布错乱」。
安全基线
- Session 存在数据库里,只保存 Token 的 SHA-256 摘要;Cookie 为
HttpOnly+SameSite=Lax,生产环境自动加Secure。 - 写操作使用双提交 Cookie 校验 CSRF(
x-csrf-token)。 - 密码用 bcrypt(cost 12)哈希;登录与上传接口有进程内限流。
- 上传校验 MIME、扩展名与大小,文件名随机化,并且只能通过
/api/uploads/:name读取,data/uploads目录不对外暴露。 - 评论交给 Giscus 托管,正文页不承担评论系统的维护成本。
测试与质量门禁
| 层次 | 工具 | 覆盖内容 |
|---|---|---|
| 单元测试 | Vitest | Schema、纯函数与领域规则(slug 生成、日期、地理投影等) |
| 集成测试 | Vitest + 临时 SQLite | Repository、Session、API 契约与 Sitemap |
| 端到端 | Playwright | 登录、写文章、发布、上传、公开阅读,含桌面与移动视口截图 |
| 静态检查 | ESLint + vue-tsc | 代码规范与严格类型(strict: true) |
日常提交前的顺序是 pnpm lint → pnpm typecheck → pnpm test → pnpm build,涉及关键流程时再跑 pnpm test:e2e。
部署与备份
pnpm build
node .output/server/index.mjs
- 只运行一个 Node 写入进程,SQLite 与上传目录挂载到持久化磁盘。
- 启动前执行
pnpm db:migrate;pnpm db:backup通过 SQLite 在线备份 API 生成一致性快照。 - 建议保留 7 份每日备份与 4 份每周备份,并且至少一份放到异机或对象存储。
- 由 Nginx 或 Caddy 提供 HTTPS 与安全响应头。
两个顺带实现的小功能
- 追番排名:条目元数据来自 Bangumi 公开 API,后台可搜索、增删改与调序,前台按分档展示。
- 旅行足迹:省级 GeoJSON 存放在本地,用自己写的投影与路径生成逻辑渲染成 SVG,点击省份记录足迹,全程不依赖第三方地图 SDK。
什么时候会换掉这套栈
当站点需要多实例部署、写入并发明显升高,或者出现跨机器的异步任务时,会先把数据层迁到 PostgreSQL——领域层与 Repository 的边界就是为此留的——再考虑拆分服务。在那之前,保持单一进程、单一数据库文件,是维护成本最低的形态。