做个人博客容易,真正把它做成带社交生态的Nodejs+vue个人博客社交系统,才是大部分人栽坑的地方。这篇文章不聊虚的,直接把我做过的“个人博客社交系统”拆给你看,重点落在相册关注这两个模块上:一个是内容沉淀,一个是关系链,两个合起来才是社区感的核心。适合正在做毕设、想做独立博客、或者想练手全栈项目的人参考。
我这套方案踩过一轮又一轮坑,最后沉淀下来是比较稳妥的组合:Node.js 提供后端接口,Vue 负责前端交互,数据库用 MySQL 存结构化业务数据。文章、用户、相册、关注、点赞、评论这些功能拆成一块一块做,比一上来就堆功能好维护得多。
先说结论:个人博客社交系统,技术上没有多难,难点在于你怎么把业务模块之间的关联理清楚。比如关注了别人之后,首页动态流要能刷出对方的文章与相册更新;比如上传相片之后,封面怎么取、图片怎么压缩、权限怎么控制。这些细节才是项目真正花时间的地方。
1. 系统定位与业务拆解
1.1 做这套系统前先想清楚的三件事
第一件事,博客系统和社交系统到底怎么融合。博客本身是内容消费,社交系统是关系维护。融合之后,用户既要能看别人的博客,也要能关注作者、评论互动、订阅更新。所以你不能只做一个文章发布网站,得有用户体系、关注关系、动态流、互动反馈。
第二件事,相册模块到底是独立的图片管理工具,还是个人主页的附属模块。我最后做成了“相册 + 图片”双层结构:一个用户有多个相册,一个相册下面有多张图片。这样用户在浏览作者主页的时候,既能看文章列表,也能点进相册看生活记录,内容形式就丰富了。
第三件事,到底要不要做成前后端分离。如果你只是本地跑一个小 demo,可以用模板渲染一把梭。但既然标题里写了 vue,方向就已经明确了:前端 Vue 单页应用,后端 Node.js 提供 JSON API,前后端通过 HTTP 通信。这种架构的好处是前端可以独立部署、独立调试,后面想拆微服务、换前端框架也容易。缺点也非常现实:跨域、登录态、接口鉴权这些都得自己处理。
1.2 功能模块拆解:文章、相册、关注三条主线
- 用户模块:注册、登录、个人信息修改、头像上传。这是整个系统的地基,没有用户体系,后面关注和评论无从谈起。
- 文章模块:发表文章、编辑删除、列表分页、文章详情、点赞、评论。
- 相册模块:新建相册、上传图片、浏览相册、删除图片、设置封面。
- 关注模块:关注用户、取消关注、粉丝列表、关注列表、访问对方主页时显示是否已关注。
- 动态流模块:基于关注关系,在首页聚合展示“我关注的人”发的最新文章和相册动态。
这里我不建议一开始就把五个模块全做完再联调,正确的做法是先把用户注册登录跑通,再把文章模块跑通,然后做关注,最后接相册和动态流。我第一版就是贪快,先做相册,结果用户体系没准备好,上传图片不知道归属给谁,返工了一大轮。
2. 技术选型与整体架构
2.1 为什么选 Node.js 加 Vue 的组合
现在做全栈项目,主流可选项很多:Spring Boot 加 Vue、Python Django 加 Vue、Node.js 加 Vue。如果你本身熟悉 JavaScript 生态,Node.js 全栈是个性价比极高的选择。
Node.js 后端这边,不一定要用什么重型框架,Express 足够稳定,Koa 也可以,我更倾向 Express,因为中间件生态成熟,遇到问题搜索到的资料最多。前端则用的是 Vue 3 加 Vite,配合 Pinia 做状态管理,Vue Router 做路由。这套组合启动速度快,开发体验好,模板语法也足够简单,适合快速搭建个人博客系统。
有人会问,Vue 2 行不行?如果你是新项目,建议直接上 Vue 3。Vue 2 虽然还有存量项目在用,但已经进入维护末期,新项目没必要给自己埋坑。Vue 3 的组合式 API 写业务逻辑会比 Vue 2 的选项式 API 更清晰,尤其在做关注按钮、点赞按钮这种高频交互组件时,组合式 API 的状态管理明显更顺手。
2.2 前后端分离架构下的目录组织
我所有项目都习惯用清晰的前后端目录分离,而不是把前端直接混在后端项目里。根目录下建两个子目录:server 和 web,分别表示后端服务和前端应用。这样部署时后端跑在 Node 服务上,前端打包成静态文件后交给 Nginx 托管,双方通过 API 通信。
目录参考:
blog-social-system/ ├── server/ # 后端源码 │ ├── app.js # 应用入口 │ ├── routes/ # 路由 │ ├── controllers/ # 控制器层 │ ├── services/ # 业务逻辑层 │ ├── models/ # 数据库模型 │ ├── middlewares/ # 中间件(JWT校验、错误处理) │ ├── uploads/ # 本地上传文件目录 │ └── config/ # 配置文件 ├── web/ # 前端源码 │ ├── src/ │ │ ├── api/ # axios 接口封装 │ │ ├── views/ # 页面组件 │ │ ├── components/ # 通用组件 │ │ ├── stores/ # Pinia 状态 │ │ ├── router/ # 前端路由 │ │ └── utils/ # 工具函数 └── blog-social.sql # 数据库初始化脚本这种结构的优势在联调阶段特别明显:你可以把后端接口在 Postman 里全部测好,再对接前端页面,出问题能一眼定位是哪边的锅,不用两边代码混在一起找半天。另外前后端分开还有个好处,就是前端页面走到哪里都能预览,不必每次刷新都被后端服务重新渲染。
2.3 数据库设计:五张核心表
数据库我用的是 MySQL,业务数据用关系型数据库来存是最省心的。文章和用户是实体表,关注关系是中间表,相册用两张表连起来。
核心表结构如下:
用户表 users:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键,自增 |
| username | varchar(50) | 用户名,唯一 |
| password | varchar(100) | 加密后的密码 |
| nickname | varchar(50) | 昵称 |
| avatar | varchar(255) | 头像地址 |
| bio | varchar(255) | 个人简介 |
| created_at | datetime | 注册时间 |
文章表 articles:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 作者ID,外键关联用户表 |
| title | varchar(100) | 标题 |
| content | text | 正文内容 |
| cover | varchar(255) | 封面图地址 |
| tags | varchar(255) | 标签,逗号分隔 |
| views | int | 浏览量 |
| created_at | datetime | 发布时间 |
相册表 albums:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 所属用户ID |
| title | varchar(100) | 相册名称 |
| cover | varchar(255) | 封面图 |
| description | varchar(255) | 相册描述 |
| created_at | datetime | 创建时间 |
相片表 photos:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| album_id | int | 所属相册ID |
| user_id | int | 上传者ID |
| url | varchar(255) | 图片路径 |
| created_at | datetime | 上传时间 |
关注表 follows:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| follower_id | int | 关注者ID |
| following_id | int | 被关注者ID |
| created_at | datetime | 关注时间 |
需要注意一点,关注表需要加唯一约束,组合键设置为 follower_id 和 following_id。否则用户在页面上手快点了两次“关注”,请求发出后数据库就会生成两条重复记录。这个问题我是在测试时发现的,前端虽然做了 loading 禁用按钮,但是防不住直接调接口的并发请求,唯一索引才是底线保障。
3. 后端核心实现:从登录鉴权到动态流
3.1 用户注册登录与 JWT 鉴权
一开始我考虑用 session 方案,服务端直接维护一份 session 表,登录后把 sessionId 写入客户端 cookie。但当前后端分离接口越来越多,移动端和前端都要用同一套登录态,JWT(JSON Web Token)明显更简洁:登录成功后服务端发一个 token 给前端,前端后续请求在 Authorization 头里带上这个 token,后端中间件校验成功后把用户信息挂到请求对象上即可。
注册接口的密码处理是每个新手最容易出问题的地方。绝对不能明文存。我这里用 bcryptjs 做哈希,它自带盐值,同样的密码每次加密结果都不同,安全性比单纯的 md5 强很多。实际注册逻辑如下:
const bcrypt = require('bcryptjs') const jwt = require('jsonwebtoken') const { createUser, findUserByUsername } = require('../models/userModel') async function register(req, res) { const { username, password, nickname } = req.body if (!username || !password) { return res.status(400).json({ msg: '用户名和密码不能为空' }) } const exists = await findUserByUsername(username) if (exists) { return res.status(400).json({ msg: '用户名已被占用' }) } const hash = await bcrypt.hash(password, 10) const userId = await createUser({ username, password: hash, nickname }) res.json({ id: userId, username, nickname }) }登录成功后的 token 生成需要注意有效期设置。我做的是 7 天有效期,同时在前端 axios 拦截器里对所有请求附上 token 头。退出登录时前端只需删除本地 token 并跳转登录页,不一定要调后端接口,因为 JWT 本身是无状态的,服务端没法主动让一个 token 失效。如果你真的需要立即失效,就得引入黑名单机制,那就失去 JWT 的轻量优势了。
JWT 鉴权中间件是整个后端接口安全的第一道大门:
function auth(req, res, next) { const authHeader = req.headers.authorization || '' const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null if (!token) { return res.status(401).json({ msg: '未登录' }) } try { const decoded = jwt.verify(token, process.env.JWT_SECRET) req.userId = decoded.id next() } catch (err) { return res.status(401).json({ msg: '登录已过期,请重新登录' }) } }多个接口鉴权时,不管有没有认证的接口都要处理好“没有 token 时”的行为,不能让服务端直接崩。我见过不少项目在没带 token 的请求上抛 500,而不是 401,这是接口语义不规范。
3.2 文章模块:常用 CRUD 背后的细节
文章模块表面上就是增删改查,难点在于几个容易被忽略的细节。
一是分页查询。前端列表页肯定会用到“加载更多”或者页码切换,所以后端接口必须支持 page 和 pageSize 参数,并返回 total 总数。使用 MySQL 的话就是 LIMIT 和 OFFSET 的配合。
二是浏览量的处理。最简单的方案是每次文章详情接口被调用时就把 views 字段加一。但这样同时也会把作者自己的浏览加上去,更严谨的做法是判断请求者 ID 是否等于文章作者 ID,不一致才加浏览量。
三是文章内容的存储。个人博客的内容可能包含 Markdown 或者富文本 HTML。我建议统一存 Markdown 文本,前端渲染时再用一个 Markdown 解析库转成 HTML。这样做的好处很多:文本体积小、方便编辑、不会出现直接拼 HTML 导致脚本注入的风险。如果前端直接渲染后端返回的 HTML,后端必须做 XSS 过滤,逃不掉的。
文章发布接口的简化实现:
async function createArticle(req, res) { const { title, content, tags, cover } = req.body if (!title || !content) { return res.status(400).json({ msg: '标题和内容必填' }) } const articleId = await insertArticle({ userId: req.userId, title, content, tags, cover }) res.json({ id: articleId }) }返回 id 而不是仅返回成功消息,是很细节但很有用的习惯。因为前端在创建完成后,往往需要立即跳转到详情页,这时候如果没有后端返回的 id,前端就得再查一次列表才能拿到,多一次请求还容易拿错。
3.3 相册模块:文件上传不算难,难的是文件管理
相册模块开始之前,我建议先把图片存储方案想清楚。本地存储方案适合学习和小流量场景:项目目录下建一个 uploads 文件夹,Multer 中间件接收文件,保存后用静态资源中间件直接暴露出去访问。对于一个个人博客系统,这个方案完全够用。
Multer 配置要考虑的核心点是文件的保存路径和随机文件名。直接用用户上传的原文件名会带来两个问题:一是中文文件名可能存在编码问题,二是重名文件会互相覆盖。最稳的方式是使用时间戳加随机数拼接作为文件名。
const multer = require('multer') const path = require('path') const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, 'uploads/'), filename: (req, file, cb) => { const ext = path.extname(file.originalname) const uniqueName = Date.now() + '_' + Math.round(Math.random() * 1e9) + ext cb(null, uniqueName) } }) const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] const ext = path.extname(file.originalname).toLowerCase() if (!allowed.includes(ext)) { return cb(new Error('仅支持图片格式')) } cb(null, true) } })上传接口这里我同时做了两层控制:文件格式和文件大小。图片后缀白名单可以直接过滤掉绝大多数非图片文件,5MB 的限制则能防止用户上传超大图片把磁盘塞满。生产环境里更严谨的方案是读取文件的 MIME 类型并做压缩处理,但对个人项目来说,后缀白名单加大小限制已经足够。
照片和相册的关系要小心设计。我选择让 photos 表里单独存 user_id 字段,而不是每次通过 album_id 回查到用户。这里看着冗余,实际是有用的:当用户删除一个相册时,要么把相册下的图片全部删除,要么把图片保留成“未分类”状态。有 user_id 在图片表里,统计用户的相片总数、做用户维度的相片查询都简单很多。
另外相册封面的处理是相册页面的核心细节。一个相册创建时可能还没有照片,封面可以先用默认图占位;上传第一张照片后,把这张照片的地址回写为相册封面。这个逻辑看起来简单,如果不特意思考,会出现相册封面空白的尴尬情况。
3.4 关注关系与动态流的实现
关注模块的数据结构很简单,就是关注表里的 follower_id 和 following_id 两个外键。真正做起来,需要处理三个层面的问题:关注操作、关系查询、动态流聚合。
关注操作就是插入一条记录,取关操作就是删除一条记录。因为我在数据库加了唯一索引,重复关注会直接报错,所以接口里最好先查一下关注关系是否存在,存在就返回“已经关注”,不存在才插入。
判断“我是否已关注某用户”在前端很常见:用户访问对方主页,页面上要显示“关注”还是“已关注”按钮,个人主页的粉丝列表也要显示“互相关注”之类的标记。最实用的方案是写一个批量查询接口,传入需要判断的目标用户 ID 列表,返回一个 Set 集合,前端拿到结果后直接做判断。避免每渲染一个用户卡片就去请求一次后端,请求数量会爆炸。
动态流是整个系统里最“社交”的功能。我实现的方案是:首页动态流只显示当前用户关注的用户所发布的最新文章和最新相册。SQL 写法上用 JOIN 把用户表、文章表和关注表关联起来,按发布时间倒序,然后分页。
SELECT articles.id, articles.title, articles.cover, articles.created_at, users.username, users.nickname, users.avatar FROM articles JOIN follows ON follows.following_id = articles.user_id JOIN users ON users.id = articles.user_id WHERE follows.follower_id = ? ORDER BY articles.created_at DESC LIMIT ? OFFSET ?关注和相册的动态流可以用 UNION 把两个查询结果合并,或者采用更工程化的做法:在服务端维护一个 feed 表,当用户发文章或传照片时,往所有粉丝的 feed 里插入一条提醒记录。这个方案叫“写扩散”,适合关注关系不怎么庞大的系统,粉丝量大之后才有性能压力。我建议个人项目还是用实时 JOIN 查询,简单直接,不要为一个学习项目过早引入消息队列或写扩散架构。
4. 前端核心页面与交互实现
4.1 路由守卫与登录状态管理
Vue 前端的第一个基础设施就是登录态。用户登录后把 token 存到 localStorage,同时把用户信息放到 Pinia 的 store 里。每次刷新页面时,通过一个接口获取当前登录用户信息并重新填充 store。
Vue Router 的全局前置守卫,是控制页面访问权限的关键:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })这里面容易踩的一个坑是:只保存 token,但用户信息在刷新后丢失了。解决办法是在路由守卫里加一个异步判断,如果 store 里没有用户信息并且有 token,就去调用一次“获取当前用户信息”接口,等拿到结果后再放行。这里记得要考虑接口失败的情况,比如 token 过期,那么需要清除本地 token 并跳转登录页。
4.2 相册上传页与图片列表组件
相册页的前端拆成两层:相册列表页负责展示当前用户或他人的所有相册,每个相册显示封面图、标题、照片数量;相册详情页展示某个相册下的所有图片。
上传交互我做了两种入口:新建相册时直接上传第一张图;已有相册中追加图片。组件里用 el-upload(Element Plus 的上传组件)或者自己写一个 input file,核心是要做好上传前校验。
一个合适的做法是:选择文件后先在前端用 FileReader 读取图片并生成预览图,用户确认后再真正调上传接口。这样避免了大文件上传失败浪费时间。上传过程中还要显示进度条,前后端分离项目里,进度条帮用户省下的等待焦虑是很明显的。
照片删除操作需要前端确认弹窗,毕竟删图不可逆。组件交互上,我用的是图片右上角悬停出现删除按钮的模式。这里有个体验细节:用户在上传完一批图片后,要及时刷新相册的照片数量和封面,很多博客项目在这里就漏了,导致用户以为上传失败,实际是前端视图没刷新。
4.3 关注与取关的交互设计
关注按钮本身很简单,一个按钮两个状态,点击后切换。但要从用户角度把逻辑想完整。
如果访问的是自己的主页,不应该显示“关注自己”按钮,直接从组件层面判断当前访问用户 ID 和登录用户 ID 是否一致,一致则隐藏。
如果访问的是别人的主页,按钮初始状态要通过接口查询获知。这里我建议封装成一个自定义 Hook 或者组合式函数,把关注状态和切换逻辑都放进去,任何页面只需要传入目标用户 ID 就能复用。
具体关注切换逻辑在 Vue 里可以这样写:
const isFollowed = ref(false) const loading = ref(false) async function toggleFollow(targetUserId) { loading.value = true try { if (isFollowed.value) { await unfollowUser(targetUserId) } else { await followUser(targetUserId) } isFollowed.value = !isFollowed.value } finally { loading.value = false } }这里有几个必要点:按钮在请求过程中要进入 loading 状态,防止用户疯狂点击触发多次重复请求;关注和取关接口失败时要有错误提示,不要把失败吞掉;切换状态最好以后端返回结果为准,而不是前端乐观更新。乐观更新虽然响应快,但一旦接口失败,状态还原的逻辑写起来很麻烦。个人项目我建议别加这个复杂度。
4.4 axios 封装与接口统一管理
前端每页都会调用大量接口,如果不做封装,代码里到处都是 axios 请求配置,后期维护想哭。
我用的封装方式是:单独建一个 request.js 文件,创建 axios 实例,统一配置 baseURL 和超时时间,用请求拦截器注入 token,用响应拦截器统一处理后端返回的错误码。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( (response) => response.data, (error) => { const status = error.response?.status if (status === 401) { localStorage.removeItem('token') router.push('/login') } else { ElMessage.error(error.response?.data?.msg || '请求失败') } return Promise.reject(error) } ) export default request这里有一个需要单独说明的点:baseURL 设置为 /api 而不是直接写死一个域名。开发环境下,我通过 Vite 的 proxy 配置把 /api 接口转发到后端的 3000 端口;生产环境下,Nginx 也会配置 /api 的反向代理。这样前端代码里完全没有后端地址,环境切换只需要改配置,前端不需要重新打包。
5. 环境配置与联调避坑实录
5.1 Node.js 环境配置中高频出现的安装问题
一个 Nodejs+vue 项目,第一个坑往往是开发环境本身搭不起来。很多新手在 Windows 上安装 Node.js 后执行 npm 命令,会遇到下面的报错:
npm : 无法加载文件 D:\program files\nodejs\npm.ps1,因为在此系统上禁止运行脚本这个报错不是 Node.js 本身坏掉了,而是 Windows PowerShell 的执行策略默认禁用了脚本运行。解决办法是打开管理员权限的 PowerShell,执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned执行策略改成 RemoteSigned 只允许本地脚本和已签名脚本运行,比直接用 Unrestricted 更安全。另外还要注意,Node.js 安装目录不要像报错里那样带着 Program Files 这类带空格的路径,部分工具链解析路径时容易出问题。建议把 Node.js 直接装到 D:\nodejs 这类无空格目录下。
安装完成后要立刻检查环境变量是否配置成功。在命令行执行:
node -v npm -v如果 node 命令能找到但 npm 找不到,通常是环境变量里的 PATH 没有包含 npm 目录,或者安装时没有勾选自动加入 PATH。手动把 Node.js 安装目录加到系统变量 PATH 里就能解决。
5.2 前后端联调时常见的跨域与代理配置
前后端分离开发时,前端地址是 http://localhost:5173(Vite 默认端口),后端接口地址是 http://localhost:3000。浏览器跨域策略会直接拦截请求,所以开发环境最常见的两种处理方案是:后端配 CORS 中间件,或者前端配代理。
我推荐前端配代理。原因很简单,后续生产环境部署时,前端静态文件和接口通常都会走同一个域名,本身就是“同源”,不需要后端额外开放跨域。开发环境用代理模拟生产环境是最干净的方式。
Vite 的代理配置:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })后端接口统一以 /api 开头,代理转发时就非常清爽。如果某个接口忘了写 /api 前缀,就会变成直接请求 5173 端口,结果返回的是一个 HTML 页面而不是 JSON,这个错误非常好排查,报错里绝不会是跨域,而是拿到 200 状态但解析 JSON 失败。
5.3 图片上传后无法访问的解决方案
本地上传图片保存成功后,浏览器访问图片地址 404,是很多人会遇到的坑。原因通常是:后端虽然把图片存进了 uploads 目录,但没有用静态资源中间件把这个目录暴露出来。
Express 里加一行代码就好:
app.use('/uploads', express.static(path.join(__dirname, 'uploads')))这样访问 http://localhost:3000/uploads/xxx.jpg 就能正常拿到图片。前端展示图片时,直接使用后端返回的 /uploads/xxx.jpg 路径,再配合代理走通整个链路。
注意生产环境下,构建好的前端文件本身打包了路由,所以 Nginx 需要同时配置前端 history 模式路由回退和 /uploads 静态资源映射。路由回退的配置是 location / 下面加 try_files 指令,否则刷新页面会出现 404。图片路径则把 /uploads 指到后端的 uploads 目录。
5.4 部署上线时前端路由回退与接口代理
个人博客系统部署不算复杂,我用的是经典套餐:后端用 PM2 守护进程跑在服务器上,前端 build 成静态文件后用 Nginx 托管。
后端启动:
npm run start生产环境我推荐用 PM2 管理 Node 进程,崩溃自动重启,日志管理也方便。
pm2 start app.js --name blog-server pm2 save pm2 listNginx 配置里最核心的两个片段:
server { listen 80; server_name your-domain.com; root /var/www/blog-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads/ { proxy_pass http://127.0.0.1:3000/uploads/; } }try_files 的作用就是让前端路由在刷新时都指向 index.html,由 Vue Router 再根据 URL 解析到对应页面。这个配置是第一优先级,漏了它就出现“刷新页面就白屏”的灵异事件。
6. 安全加固与性能优化细节
6.1 密码安全、JWT 密钥与图片校验
安全这块不用做得很复杂,但最基础的三样必须做到。
第一,密码永远不能明文存储。bcryptjs 加盐哈希,这个前面已经讲了,属于最底线。如果用户密码用了 md5 直接入库,跟明文几乎没有区别,现在彩虹表一查就能还原。
第二,JWT_SECRET 不能写死在代码里。我见过很多人把 secret 放在 app.js 里,然后在 GitHub 上把代码传了上去,等于用钥匙挂在门外面。正确做法是用环境变量管理。开发环境下放在 .env 文件里,生产环境从部署平台或者启动脚本中注入。
第三,文件上传除了校验后缀,还要校验文件的大小。Multer 的 limits 里设置一个合理上限,比如 5MB,就能防住恶意上传大文件拖垮服务器磁盘。当然,如果项目面向的用户量大,更专业的做法是接入对象存储服务,图片上传走云存储,但个人项目没这个必要。
6.2 数据库索引优化
数据量小的时候,有没有索引看起来区别不大。但一旦用户量到几千、文章到几万条,无索引的查询会明显变慢,这是必然发生的,不需要等到性能真的出问题才处理。
我的建议是趁早把索引加上。关注表给 follower_id 和 following_id 建联合唯一索引,文章表给 user_id 建普通索引,相片表给 album_id 建索引。这些索引在数据库表创建脚本里直接写好,不要上线以后再来回补,表里有数据后再加索引容易锁表。
查看 MySQL 是否走了索引,用 EXPLAIN 命令看查询计划即可,如果看到 type 是 ALL 就是全表扫描,说明索引没有建对。
6.3 前端性能优化三件套
前端性能优化里,见效最快的是图片懒加载。相册列表和文章列表的图片数量必然不少,一屏只显示几张,其他图片完全可以到用户滚动到对应位置再加载。Element Plus 里图片组件自带 lazy 属性,或者使用 v-lazy 指令,这个优化成本极低收益极明显。
其次是组件拆解。把文章卡片、相册卡片、关注按钮都拆成独立组件,不仅是为了复用,更是为了让 Vue 的响应式更新范围更小。同一个数据变了,只有对应组件重渲染,不会带着整页一起抖动。
最后是接口数据按需加载。列表页先只加载文字信息和封面,相册详情里的原图在点击后才加载。尽量后端接口就拆分细,前端不要一把拉全量数据在本地筛选,数据库层面做过滤永远比前端处理要省资源。
7. 常见问题速查与个人心得
做这套 Nodejs+vue 个人博客社交系统,我踩过不少坑,这里挑几个高频问题整理成表格,直接照着排查就行。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| npm 命令报错无法加载脚本 | Windows PowerShell 禁止脚本运行 | 管理员执行 Set-ExecutionPolicy RemoteSigned |
| 前端请求后端接口拿到 HTML | 没有走代理或接口缺少 /api 前缀 | 检查 vite proxy 配置与接口路径 |
| 图片上传成功但访问 404 | 后端没配置静态资源目录 | app.use('/uploads', express.static('uploads')) |
| 刷新页面白屏 | 前端 history 模式路由未回退 | Nginx 配置 try_files $uri /index.html |
| 重复关注了同一个用户 | 关注表缺少唯一索引 | 加联合唯一索引并查询后插入 |
| 用户信息刷新后丢失 | 没有在路由守卫中重新拉取 | Pinia 存储加异步获取用户信息逻辑 |
| 上传大图片后页面卡顿 | 前端没有压缩且后端无大小限制 | 限制文件大小并加入前端预览压缩 |
| 接口返回 401 后一直在登录页打转 | 响应拦截器没有清除过期 token | 401 时清除本地 token 并跳转登录页 |
最后补充一点我自己反复踩坑才意识到的经验:任何跟“登录后操作”相关的接口,比如发表文章、创建相册、关注别人,都要在写代码时就把 auth 中间件加好,而不是等接口联调完再补。补的时候很容易漏掉某个接口,导致测试时接口可以访问,上线后被人直接调用,没有任何权限控制,到时候再排查就很被动。
我在实际开发过程中还有个体会:相册和关注这两个模块,看着互相独立,其实是系统里最能体现“社交感”的部分。一个用户被关注了,他的更新能推送到关注者的动态流里;一个用户发了一组新照片,粉丝第一时间能在首页看到。这种连接的建立会激励用户持续创作,也让整个系统从“存文章”进化成“互动社区”。初版哪怕功能做浅一点,也要保证这条关系链路是完整打通的。
如果你现在刚开始动手,我强烈建议第一步先装好 Node.js 环境并跑通一个最简单的 Express 接口,别一开始就沉浸到页面布局里。环境跑通后,按用户、文章、关注、相册的顺序推进,每个模块完成就立即联调。项目不怕功能少,就怕每个模块都半生不熟,最后整个系统跑起来全是边界问题。