这个站是怎么搭起来的:技术栈与每一项的取舍
这个站最早只是一个 HTML 加 JS 的个人主页。2025 年 6 月,我用 Nuxt 重写了一版,加上了博客,就是现在这个项目的起点。一年多下来,它换过部署平台、换过 UI 库,也删掉了不少东西。
这篇把整个技术栈摊开讲:每一层选了什么、为什么、代价是什么。好几层单独写过详细的文章,这里只讲结论,细节放在链接里。
全貌
| 层 | 用的是 | 详细的文章 |
|---|---|---|
| 框架 | Nuxt 4(Vue 3) | |
| 界面 | shadcn-vue + Tailwind CSS v4 | Nuxt 的 UI 库怎么选 |
| 深浅色 | @nuxtjs/color-mode + View Transitions | Nuxt 深浅色切换 |
| 多语言 | @nuxtjs/i18n | @nuxtjs/i18n 前缀策略踩坑 |
| 部署 | Cloudflare Pages | Cloudflare Pages 缓存踩坑 |
| 数据库和登录 | Supabase | |
| 编辑器 | CodeMirror 6 | 个人博客的编辑器怎么选 |
| 图床 | Cloudflare R2 | R2 作为图床、上传与图片生命周期 |
| 邮件 | Resend | |
| 性能 | 边缘缓存、按需加载、系统字体 | 个人站性能优化实录 |
| 依赖管理 | pnpm,升级前后逐字对比输出 | 怎么放心地升级依赖 |
为什么是 Vue 和 Nuxt
我最早学的框架其实是 React,那时候还是类组件、生命周期、setState 的写法。第一个真正拿来做项目的框架也是 Next.js。后来选了 Nuxt,主要是因为更喜欢 Vue。
Vue 的单文件组件读起来更清楚。 按惯例,<template> 写在文件最上面,打开一个组件,第一眼看到的就是它的 HTML 骨架。React 的组件文件开头是一堆 import,要往下翻到 return 才知道它渲染出什么结构。
模板比 JSX 顺眼。 JSX 里,div 结构中间夹着 {list.map(...)}、{cond && ...} 和各种花括号,嵌套几层之后比 Python 的缩进还让人恼火。Vue 用 v-for、v-if 写在标签上,结构还是结构。
AI 生成的代码能直接粘进来。 让 AI 在网页上生成一段 HTML 加 Tailwind,贴进 Vue 模板就能用。贴进 React 还要改一遍:class 改成 className,for 改成 htmlFor,行内 style 改成对象。
副作用更不容易写错。 React 的 useEffect 依赖数组写错一项,就是莫名其妙的重复请求或者过期的闭包。Vue 的响应式是自动追踪依赖的,这类错误少很多。
还有一点是体验:当时用 Next.js 时,热更新和打包都很慢,整体给我一种很臃肿的感觉。Next.js 后来把 Turbopack 设成了默认打包器,据说快了很多,但那时的印象已经定型了。
Nuxt 对我来说就是「Vue 版的 Next.js」:文件即路由、服务端渲染、服务端接口写在同一个项目里(server/api/),部署时换一个预设就能跑在 Cloudflare 上。
部署:从 Vercel 到 Cloudflare Pages
今年 1 月,我把站从 Vercel 迁到了 Cloudflare Pages,主要是三个原因:
- 离访客更近。 Cloudflare 的边缘节点多,页面可以直接缓存在离访客最近的节点上。
- 流量不设上限。 Vercel 的免费套餐每月有 100GB 的流量额度,超了不会扣钱,但项目会被暂停到下个月。Cloudflare Pages 的静态资源流量不计量。
- 一家搞定。 图床用的 R2、DNS、域名都在 Cloudflare,一个后台就能管完。
有一个限制要知道:Pages 上的服务端渲染和接口,都算在 Workers 免费套餐的额度里,每天 10 万次请求。所以缓存很重要:页面的 HTML 加了 Cache Rule,命中边缘缓存的请求根本不会走到服务端,也就不消耗这 10 万次。这件事的来龙去脉写在缓存那篇里。
在 Cloudflare 上跑 Nuxt 也有一些坑:Worker 运行时没有文件系统,也跑不了原生模块。所以 AWS SDK 换成了 aws4fetch,Resend 的 SDK 换成了直接调接口,图片压缩也放到了浏览器里做(常用的 sharp 依赖原生模块,在 Worker 上装不起来)。
数据:Supabase
文章、想法、反馈都存在 Supabase 的 Postgres 里,后台登录也用它的 Auth。
选它的理由很简单:一个免费账号就同时有了数据库、登录和行级权限控制(RLS)。匿名访客只能读已发布的内容,写操作全部经过服务端校验管理员身份,再用服务端密钥执行。
免费套餐的数据库是 500MB,对一个博客来说基本用不完。要注意的是,连续 7 天没有任何数据库查询,项目会被暂停,恢复要几十秒。这个站不太会遇到:页面缓存 10 分钟过期,之后的第一个访客就会触发查询;而且反馈通知的定时任务每次运行也会查一次库。
内容:Markdown,在服务端渲染
文章和想法都以 Markdown 存进数据库。我喜欢写 Markdown:语法简单,而且哪里都认,想把文章搬去别的平台,复制过去就行。
编辑器是 CodeMirror 6,后台的编辑区和发布后的文章用同一套字体、字号和行宽。选型过程写在编辑器那篇里。
渲染在服务端做:marked 把 Markdown 转成 HTML,highlight.js 给代码上色,结果直接写进页面。这样读者的浏览器里不需要任何渲染器,这两个库加起来大约 32KB(gzip 后),读者一个字节都不用下载。
图片存在 R2。粘贴进编辑器时,先在浏览器里缩到长边 2000px 以内、转成 WebP,再上传。R2 的出口流量不收费,这是它做图床最大的优点。
界面:shadcn-vue 和 Tailwind
最早用的是 Nuxt UI,今年 7 月换成了 shadcn-vue。原因是 Nuxt UI 的样式要通过它的主题系统去覆盖,而我要的样子和它的默认样子差得比较远;shadcn 的组件源码就在项目里,想改哪里直接改。过程写在 UI 库那篇里。
中文用系统自带的字体:Mac 和 iPhone 是苹方,Windows 是微软雅黑,安卓是系统的思源黑体。一个中文字体都不下载。
这是后来改的。之前我自托管了思源黑体,为的是 Windows 上的阅读体验:微软雅黑只有常规和粗体两档,标题想用中等字重做不到。后来实测发现,Windows 访客第一次打开一篇长文,要下载 21 个字体分片、约 1.1MB,比全站所有 JS 和 CSS 加起来还多好几倍;字体声明本身也占了入口 CSS 约 40KB,连从来用不上它的 Mac 访客也要下载。为了标题粗一点点,让每个 Windows 访客多等 1MB,不划算,就去掉了。现在入口 CSS 只剩 16KB。
删掉的东西
一年下来,删掉的东西比加上的更能说明取舍:
- Nuxt UI:换成 shadcn-vue,入口 CSS 小了四分之一。
- AWS SDK:新版本在 Worker 上会读取
~/.aws配置文件然后崩溃,换成了aws4fetch。 - Resend 的 SDK:它会按需加载 React 邮件渲染器,Cloudflare 的构建不允许留下解析不了的依赖,直接 build 失败。全站只有一处发邮件,改成十几行的
fetch。 - 浏览器里的 Supabase 客户端:前台数据全部走服务端接口,浏览器端只有后台登录用到它,却让每个访客都多下载约 50KB。登录和退出改成了服务端接口。
- 自托管的思源黑体:原因见上面「界面」一节。
- Pinia:全仓库没有一个 store。
- @tailwindcss/typography:排版是自己写的,它没被用到,却因为一段注释里出现了「prose」这个词,每次构建都往 CSS 里塞 14KB 没用的规则。
- 自动同步到 X:带站外链接的推文在 X 上传播受限,改成先在 X 上原生发,值得留存的再贴回站上。
- 底部 dock 导航:手机 App 的范式,在桌面阅读页上会压住正文,换成了普通的页眉。
花了多少钱
| 服务 | 套餐 | 免费额度 | 费用 |
|---|---|---|---|
| Cloudflare Pages | Free | 静态流量不计量;服务端每天 10 万次请求;每月 500 次构建 | 0 |
| Cloudflare R2 | Free | 10GB 存储,出口流量免费 | 0 |
| Supabase | Free | 500MB 数据库,连续 7 天无查询会暂停 | 0 |
| Resend | Free | 每月 3000 封,每天 100 封 | 0 |
| greatpi.dev 域名 | 12.2 美元 / 年 |
全部加起来,每年 12.2 美元,也就是一个月一美元,全是域名费。
免费套餐的额度对个人博客来说绰绰有余。真正要留意的只有两条:服务端每天 10 万次请求(靠边缘缓存挡住),以及 Supabase 的 7 天休眠(有访问就不会触发)。
最后
回头看,这个技术栈的每一次变化都是同一个方向:去掉用不上的,把用得上的握在自己手里。UI 组件的源码在项目里,Markdown 原文在数据库里,图片在自己的存储桶里,除了域名,一分钱不花。
下一次大改,大概会是把整个站从 Pages 迁到 Cloudflare Workers:Cloudflare 这两年在把 Pages 的能力并入 Workers,迁过去之后,定时任务之类的功能就能直接写在项目里了。