从零到上线 Build-my-blog
Chris
作者
从零到上线
我如何搭建了自己的个人博客网站 chrislll.cn —— 从本地开发到阿里云部署的完整踩坑记录
为什么我要自己搭建博客
不是选择一个平台,而是从头开始掌控一切。
作为一个技术爱好者,我一直想拥有一个完全属于自己的内容空间。不是 Medium,不是微信公众号,不是知乎专栏 —— 而是一个我拥有完全控制权的个人网站。
我的需求很明确:
- Apple 风格的简洁设计,内容用简体中文展示
- 自定义域名
chrislll.cn(已完成 ICP 备案) - 后台可以增删改查文章,支持配图上传
- 部署在阿里云,国内访问速度快
技术选型:为什么要这样组合
全栈不是堆砌技术,而是让每个工具做它最擅长的事。
| 层级 | 技术 | 选择理由 |
|---|---|---|
| 前端框架 | Vue 3 + Nuxt 3 | SSR 首屏渲染快,SEO 友好,文件路由省心 |
| 样式 | Tailwind CSS | 原子化 CSS,不用写一堆自定义类名 |
| 后端 API | Node.js + Express | 轻量、生态丰富、前后端同语言 |
| ORM | Prisma | TypeScript 类型安全,迁移管理方便 |
| 数据库 | MySQL 8.0 | 阿里云 RDS,100GB 弹性存储 |
| 部署 | Nginx + PM2 | Nginx 做反向代理和静态文件,PM2 进程保活 |
阿里云 RDS 和 ECS 本地 MySQL 的区别:RDS 是独立服务,按量付费,提供弹性存储和自动备份;本地 MySQL 受限于 ECS 硬盘空间。对于博客文章这类文本数据,RDS 的 100GB 基本等同于"无限"。
本地开发:MacBook 上的第一步
在部署到云服务器之前,先在本地把整个流程跑通。
我的开发环境是 MacBook Pro。首先需要安装 MySQL:
# 通过 Homebrew 安装 MySQL
brew install mysql
# 启动服务
brew services start mysql
# 创建数据库
mysql -u root -p
CREATE DATABASE chrislll_blog;
然后初始化 Prisma:
# 生成 Prisma Client
npx prisma generate
# 执行数据库迁移
npx prisma migrate dev
# 填充种子数据(示例文章、分类、标签)
npx prisma db seed
启动后端 API(端口 3000):
cd blog-api
npm install
npm run dev
启动前端(端口 3001):
cd blog-web
npm install
npm run dev
打开 http://localhost:3001,看到首页正常显示文章列表,后台管理页面可以登录 —— 本地环境准备完毕。
部署到阿里云:踩坑开始
ECS 只有 2GB 内存,每一步都是挑战。
我的阿里云配置:
- ECS 实例:Alibaba Cloud Linux 3,2GB 内存
- RDS:MySQL 8.0,100GB 存储
- 域名:
chrislll.cn(已 ICP 备案) - 管理面板:宝塔面板
坑 1:Git 未安装
第一次在 ECS 上执行 git clone,报错 git: command not found。
解决:Alibaba Cloud Linux 用 dnf 而不是 apt:
dnf install -y git
坑 2:Node.js 环境
ECS 上没有 Node.js。我通过宝塔面板安装了 v20.20.2,但需要手动添加到 PATH:
export PATH=/www/server/nodejs/v20.20.2/bin:$PATH
坑 3:Nginx 配置
宝塔面板生成的 Nginx 配置是自动化的,但我需要添加自定义的 location 规则。一开始我直接把 location 块贴到了 server 块外面,报错 "location" directive is not allowed here。
location 块必须放在 server { } 内部。修改前先用 nginx -t 测试配置是否正确,再重载。
坑 4:500 错误与 SSR
网站能打开,但首页显示 500 Server Error。查看日志发现 Cannot read properties of null (reading 'length')。
根因:Nuxt 3 的 SSR(服务端渲染)会在服务器上直接请求后端 API。如果 NUXT_PUBLIC_API_BASE 没设置,Nuxt 默认使用 /api,而服务器上没有对应的代理,请求失败返回 null。
// ecosystem.config.js — PM2 配置
module.exports = {
apps: [{
name: 'blog-web',
cwd: './blog-web',
script: './.output/server/index.mjs',
env: {
NODE_ENV: 'production',
PORT: 3001,
// 关键:SSR 时用这个地址访问后端 API
NUXT_PUBLIC_API_BASE: 'http://127.0.0.1:3000/api'
}
}]
}
坑 5:内存不足,构建失败
执行 npm run build 时,ECS 的 2GB 内存直接被打满,进程被系统杀掉。
解决:在 MacBook 本地构建,上传构建产物到服务器:
# MacBook 本地构建
cd blog-web
NUXT_PUBLIC_API_BASE=/api npm run build
tar czf output.tar.gz .output
# 上传到 ECS
scp output.tar.gz root@8.163.80.16:/www/wwwroot/chrislll-blog/blog-web/
# ECS 上解压
cd /www/wwwroot/chrislll-blog/blog-web
rm -rf .output
tar xzf output.tar.gz
本地构建时 NUXT_PUBLIC_API_BASE=/api(相对路径),因为浏览器端会通过 Nginx 代理到后端。服务器构建时如果用了 http://127.0.0.1:3000/api,浏览器端会尝试直接访问这个地址,导致 API 请求失败。
功能迭代:从能用到好用
网站跑起来只是开始,真正的工作量在于打磨细节。
定制 1:备案号和联系方式
底部加上 ICP 备案号,替换社交链接为微信二维码:
- 备案号:吉ICP备2025025523号-2
- 微信:nonojay
- 视频号:chris来唠唠
定制 2:自定义标签
把默认的技术标签(Vue、React、Node.js)替换为内容导向的标签:
- 英文阅读理解
- 人工智能前沿
- 用英文讲好中国
Bug 修复 1:文章排序
更新文章后,首页"最新文章"没有按最新发布排序。
根因:更新文章时 publishedAt 字段保持不变。修复:更新时自动将 publishedAt 设为当前时间。
// posts.ts — 更新文章时
publishedAt: data.status === 'PUBLISHED'
? new Date() // 自动更新为当前时间
: null
Bug 修复 2:数据验证失败
保存文章时提示"数据验证失效"。
根因:HTML select 元素返回的 categoryId 是字符串,而 Zod schema 要求数字。修复:提交前强制转换类型。
// PostEditor.vue — handleSubmit
if (data.categoryId === '' || data.categoryId === null) {
data.categoryId = null;
} else {
data.categoryId = Number(data.categoryId); // 字符串转数字
}
Bug 修复 3:图片上传
上传图片后不能正常显示,URL 是相对路径 /uploads/xxx.jpg。
修复:上传 API 返回完整 URL(包含协议和域名):
const protocol = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = req.headers.host || 'localhost:3000';
const url = `${protocol}://${host}/uploads/${req.file.filename}`;
Bug 修复 4:客户端 Hydration 后文章消失
最诡异的一个 bug:页面刚加载时能看到文章,一闪之后变成"暂无文章"。
根因:Nuxt SSR 时成功渲染了文章(服务器能访问 127.0.0.1:3000),但浏览器客户端 hydration 后重新请求 API,如果 apiBase 被硬编码为 http://127.0.0.1:3000/api,浏览器无法访问这个地址,导致数据变为空。
客户端的 API baseURL 必须用相对路径 /api(由 Nginx 代理),只有 SSR 服务端才用完整 URL http://127.0.0.1:3000/api。
缓存与多端同步
电脑端更新了,手机端还是旧内容 —— 缓存问题比代码 bug 更隐蔽。
第一次发现这个问题时,我以为是部署没成功。但用 curl 检查服务器返回的是最新内容,手机浏览器加 ?t=1 参数也能看到新内容。
问题出在多个层面:
1. 手机浏览器缓存:浏览器保存了旧页面的 HTML 和静态资源。
2. Nuxt SSR 数据获取:useAsyncData 可能复用了之前的数据。
3. Nginx 代理缓存:如果配置了缓存,会缓存 API 响应。
我的解决方案:在所有 API 请求上加时间戳参数,同时禁用 Nitro 服务端缓存:
// nuxt.config.ts
nitro: {
routeRules: {
'/**': {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
}
}
}
// 每个 API 请求加时间戳
$api('/posts?limit=6&_t=' + Date.now())
同时,在页面挂载时强制刷新数据,避免客户端导航时复用旧数据:
// index.vue
const { data: posts, refresh } = await useAsyncData('home-posts', ...);
onMounted(() => {
refresh(); // 从后台返回前台时强制刷新
});
经验总结
如果让我重新搭一次,我会怎么做?
1. 本地验证优先:在 MacBook 上把整个流程跑通再部署,能节省大量调试时间。
2. 内存规划:2GB 内存的 ECS 跑不了 Nuxt 构建,本地构建 + 上传是更务实的方案。
3. 环境变量管理:NUXT_PUBLIC_API_BASE 在开发和生产环境的值完全不同,一定要理解 SSR 和 CSR 的区别。
4. 数据类型严格:HTML 表单返回的永远是字符串,提交到 Zod 验证前一定要做类型转换。
5. 缓存策略:公开内容站点要考虑多端缓存同步,时间戳 + Cache-Control 是简单有效的方案。
6. 微信验证:国内网站一定要做 ICP 备案,微信分享链接需要域名验证才能去掉安全警告。
这篇文章的 HTML 也是用同样的技术栈生成的 —— 递归的快乐。