三年前,我从零手写了一个博客系统:Nuxt 2 + Koa2 + MongoDB 全栈复盘

5 阅读8分钟

三年前写的博客:sglBlog,到今天依然在稳定运行。首页、文章、标签、留言、友链、关于——每一个模块、每一行代码,都是自己手写的,没用 Hexo、WordPress 这类现成的框架。那时候我已经写了几年前端,也淌过一两个全栈项目,于是决定花近两个月的业余时间,把前后台、API 和部署全撸一遍。现在回想起来,依然觉得这是对独立交付能力最彻底的一次检验。

为什么非要自己写

理由很直接。一是当时市面上缺少基于 Node.js 且前后端分离清晰的开源博客方案,很多都是后端模板一把梭,定制起来束手束脚。二来,就是想找个全链路项目练手,验证自己脱离公司的工程化基建后,到底能走多远。既然找不到完全称心的,那就自己造。

那时候的技术选型

三年前落定的组合,以现在的眼光看依然务实:

  • 前端展示:Nuxt 2 (Vue 2)
  • 后台管理:Vue 2 + Vue Router + Vuex + ElementUI
  • 后端 API:Node.js + Koa2
  • 数据库:MongoDB + Mongoose
  • 部署:Pm2 + Nginx + 云服务器

架构图.png

选 Nuxt 2 主要是为了 SEO 和服务端渲染。博客文章如果全靠客户端渲染,搜索引擎收录会很差,社交分享也抓不到有效信息,这我接受不了。那会儿 Nuxt 3 还没有稳定版,Vue 2 又是绝对主流,所以毫不犹豫就定了。

后台管理只给自己用,UI 框架选了 ElementUI,组件全、上手快,用 Vue CLI 搭了个带侧边栏的典型后台。登录、写文章、管理标签和留言,功能刚好够用。后端选 Koa2 是因为它的洋葱模型和 async/await 写起来比 Express 更清爽。数据库用 MongoDB,则完全是看中它对文章这种非结构化内容的灵活性,标签、分类、自定义字段随便折腾,不用像关系型数据库那样小心翼翼地改表结构。

网站都有哪些模块

前台模块很清晰,一共六个:首页、文章页、标签聚合页、留言板、友情链接、关于我。首页展示了最新文章列表和分类标签云;文章页支持 Markdown 自定义样式渲染;标签页聚合了所有标签下的文章;留言板集成了邮件通知;友链和关于页的内容虽相对静态,但也全都通过后台接口动态配置,方便随时调整。

几个当时比较用心的实现

1. 后台管理与 Markdown 自定义样式

后台编辑器选的是 mavon-editor,一个基于 Vue 的 Markdown 编辑器,支持实时预览。为了让后台预览效果和前台最终呈现一致,我花了不少时间自定义 Markdown 的渲染样式——一级标题改成了加粗带下划线的风格,代码块加上圆角和阴影,引用块的左边框也换成了博客的主题色。这在当时算是很典型的“为了好看跟自己较劲”。

图片上传没有存服务器本地,而是直接对接了七牛云对象存储。后台 API 先用七牛 SDK 生成上传凭证,前端拿到后直传图片到七牛云,最后把返回的链接嵌入 Markdown 编辑器。这样做既省了服务器带宽,也减轻了存储压力,那时对小水管服务器来说很实用。

2. 留言与邮件通知

留言模块有个挺实用的小功能:访客留言后,系统会用 Nodemailer 调用我的 QQ 邮箱服务,自动发一封邮件通知我。原理很简单,后台新增留言成功后,异步触发一个发邮件的函数,没上什么消息队列,一个异步函数跑到现在依然稳如老狗。

3. API 设计与数据模型

API 设计上,除了文章列表、详情和标签是公开的,其余接口全部要求 Token 鉴权,JWT 存 localStorage,后端 Koa 中间件校验。虽然是个人项目,但我一直觉得安全约束不能省。数据模型方面,文章、标签、分类、留言、友链都是独立的 MongoDB 集合,接口职责很清晰,后续按标签聚合文章之类的需求,做关联查询也非常方便。

三年前踩过最深的几个坑

1. Nuxt 的打包优化

这是最耗神的地方。项目第一次 npm run build 后,公共包体积大到离谱。分析下来,发现 ElementUI 和图标库被打进了前台展示的包里。当时在 nuxt.config.js 的 build 配置里手动拆分 vendors,把仅后台使用的组件改为动态导入,再把 moment.js 替换成轻量的 dayjs,才终于把首屏 JS 体积砍到能接受的程度。那阵子对着 webpack-bundle-analyzer 的图反复调,属实上头。

2. 部署:云服务器、PM2 与 Nginx 的午夜惊魂

部署是把我从纯前端拉到全栈最实打实的一环。当时买的是一台阿里云低配 ECS,1核2G内存,跑着 CentOS 系统。整个服务架构很清晰:Nginx 做反向代理,Nuxt 和 Koa 分别跑在服务器的不同本地端口,由 PM2 守护进程

PM2 的配置用一个简单的 ecosystem.config.js 管着两个进程:

js

module.exports = {
  apps: [
    {
      name: 'blog-front',
      script: 'npm',
      args: 'start',
      cwd: '/www/xxx/front',
    },
    {
      name: 'blog-api',
      script: 'app.js',
      cwd: '/www/xxx/api',
      env: { NODE_ENV: 'production' },
    },
  ],
};

前端 Nuxt 服务默认跑在 3000 端口,Koa 后端跑在 7001,PM2 负责进程守护和日志管理,死了自动拉起来,初次配置好后心里踏实不少。

Nginx 才是真正让我连熬几夜的地方。域名、HTTPS、跨域、WebSocket 升级(虽然这个项目没用)一股脑涌过来。最终的 Nginx 配置骨架大致是这样:

nginx

server {
    listen 80;
    server_name xxx.com www.xxx.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name xxx.com www.xxx.com;
    
    ssl_certificate     /etc/nginx/ssl/xxx.com.pem;
    ssl_certificate_key /etc/nginx/ssl/xxx.com.key;

    # 代理 API 请求到后端
    location /api {
        proxy_pass http://127.0.0.1:7001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 其余所有请求交给 Nuxt 前端渲染
    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 $proxy_add_x_forwarded_for;
    }
}

这套配置看着清爽,但当年调通它可没少遭罪。记忆最深的有两个凌晨:

一次是 API 接口疯狂报 502 Bad Gateway。日志翻了无数遍,最后发现是 proxy_pass 地址末尾少写了那个斜杠——http://127.0.0.1:7001 和 http://127.0.0.1:7001/ 在 Nginx 里的 URI 转发逻辑完全不同,后者会剥离匹配的 /api 前缀,而前者会原样带过去,导致后端路由根本匹配不上。

另一次是 HTTPS 配好后,前端请求全部 CORS 报错。原因是本地开发时从来没挂 HTTPS,后端 Koa 里的 CORS 中间件只允许了 http://localhost:3000,上线后协议变了,浏览器预检请求直接被拒。后来把 Access-Control-Allow-Origin 动态匹配域名,同时把 credentials 相关头配齐,才彻底消停。

HTTPS 证书用的是 Certbot 自动续签,第一次跑完看着浏览器地址栏的小锁图标,的确有种“终于搞定”的成就感。但也正因为自动续签,后来发现 80 端口的重定向一旦写错,证书更新就会失败,这些细碎点都是靠一次次踩坑攒出来的经验。

现在想想,那段调试云服务器、PM2 和 Nginx 的深夜时光,虽然折磨,但确实让我从一个只写业务的“前端切图仔”,变成了能独立把应用从代码送到生产环境的工程师。

3. SEO 的细枝末节

Nuxt 的 SSR 解决了基础问题,但真正做好 SEO,远不止设个 title 和 meta 那么简单。我为文章页动态生成了独特的 <title> 和 <description>,并加上 Open Graph 标签,方便社交平台抓取预览。同时用 @nuxtjs/sitemap 自动生成站点地图,提交给搜索引擎,并在 asyncData 里做服务端数据获取,确保正文直接出现在 HTML 里。404 和重定向这类逻辑,则统一放进 Nuxt 路由中间件处理,才保证了服务端和客户端行为一致。

三年后再看这个项目

三年过去,博客依然跑得很稳,偶尔改改 UI、修修小 bug。但现在回头看,最大的收获并不是网站本身,而是把产品想法前后台开发,再到服务器上线运维这整条链路亲手串了起来。那会儿在公司,打包有架构组、运维有专人,很多底层细节根本不用自己操心。当一个人直面云服务器、PM2 和 Nginx 这些生产环境细节时,才知道哪里有坑、哪里会翻车。

如果以现在的经验和工具重新写一次,大概率会上 Nuxt 3 + TypeScript 了,类型约束对中大型项目的安全感提升是实打实的,后台管理也可能融合进同一个 Nuxt 工程,通过路由隔离来共享类型和组件。不过,那时候连 Nuxt 3 的稳定版都没出,倒也没什么可遗憾的。

说到底,看着一个三年前自己一行行敲出来的网站还在稳定运行,那种踏实感,真不是用任何现成系统能替代的。


我的博客 至今还在慢慢更新,如果你也打算自己从零手写一套博客,或者正在踩类似的坑,欢迎来交流,毕竟这条路我替你走过一遍,知道哪些坑可以绕着走。