本站技术栈: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 的边界就是为此留的——再考虑拆分服务。在那之前,保持单一进程、单一数据库文件,是维护成本最低的形态。