1. 为什么我选择从零手写一个博客系统
很多人一提到"博客系统"就觉得这东西太老了、没什么技术含量,随便找个开源程序套个主题就完事了。我一开始也是这么想的,直到有一次帮朋友迁移他的个人站点,打开那个开源程序的源码目录,密密麻麻几百个文件、一堆看不懂的钩子和插件机制,改一个页面布局要翻半天文档,我才意识到一个问题:用别人的系统,你永远在别人的抽象层上打补丁。于是我就动了念头,干脆自己从零写一个,前后端都自己掌控,代码量控制在能一口气读完的范围内,功能只保留最核心的——文章发布、列表展示、详情阅读、后台管理。
这个项目的定位很明确:一个前后端结合的最小可用博客系统。前端负责页面渲染和交互,后端负责数据存储和接口提供,两边通过 HTTP 接口通信。它解决的问题不是"做一个能扛百万流量的内容平台",而是"让你彻底搞懂一个 Web 应用从浏览器到数据库的完整链路"。适合谁来参考?我觉得有三类人最合适:一是刚学完前端或后端基础、想找个完整项目练手的同学;二是想理解前后端分离到底"分离"在哪里的开发者;三是像我这样受够了重型框架、想要一个自己能完全看懂的代码库的从业者。
我打算把这个项目拆成几篇来写,这一篇先讲整体设计思路和前后端骨架的搭建,把地基打牢。后面再逐步填充文章管理、富文本编辑、部署上线这些模块。你跟着走一遍,最后手里会有一个能跑起来、能改得动、每一行代码都知道为什么这么写的博客系统。
2. 整体架构设计与技术选型思路
2.1 前后端分离到底分离了什么
先把这个概念说透。很多人以为前后端分离就是"前端写页面、后端写接口",这话对但不完整。真正的分离体现在三个层面:代码分离(前端和后端是两个独立项目,各自有自己的依赖和构建流程)、部署分离(前端打包成静态文件可以放任何静态服务器,后端单独跑在应用服务器上)、职责分离(前端只管展示和交互逻辑,后端只管数据和业务规则,两边通过约定好的接口契约通信)。
我见过不少新手写的"前后端分离"项目,后端接口里直接拼 HTML 字符串返回,或者前端直接连数据库,这就不叫分离了。判断标准很简单:把前端代码整个删掉,后端还能正常提供接口服务;把后端换个实现语言重写,前端一行不用改。做到这两点,才算真正分离。
这个博客系统的数据流是这样的:浏览器加载前端页面 → 前端发起接口请求 → 后端接收请求、查数据库、组装 JSON → 前端拿到数据渲染到页面上。整条链路上,前端不知道数据库长什么样,后端不知道页面长什么样,中间只认接口格式。
2.2 技术栈选择与取舍理由
技术选型这块我没有追求新潮,原则是够用、好学、生态成熟。具体选择如下:
| 层次 | 选型 | 选择理由 |
|---|---|---|
| 前端框架 | 原生 HTML/CSS/JS 起步,后续可换轻量框架 | 先理解原理,不被框架黑盒遮蔽 |
| 前端请求 | Fetch API | 浏览器原生支持,无需额外依赖 |
| 后端语言 | Node.js | 前后端同一门语言,降低切换成本 |
| 后端框架 | Express | 极简、中间件模型清晰、文档丰富 |
| 数据库 | SQLite | 零配置、单文件存储、适合小项目 |
| 接口风格 | RESTful | 语义清晰、易于理解和调试 |
为什么后端选 Node.js 而不是 Java 或 Python?一个很实际的原因:这个项目是前后端结合的,用同一门语言能让你在调试时不用来回切换思维模式。前端写 JS,后端也写 JS,接口数据结构在两边传递时心智负担最小。Express 这个框架虽然老,但它的中间件机制是理解 Web 框架设计的最佳教材,路由、请求处理、错误捕获这些概念在 Express 里都是裸露的,没有魔法。
数据库选 SQLite 是我踩过坑之后的决定。早期我用 MySQL 做练手项目,光是装数据库、配用户权限、处理连接池就耗掉大半天,还没开始写业务代码热情就消了一半。SQLite 一个文件就是整个数据库,npm install一个包就能用,特别适合这种单机小项目。当然它不适合高并发写入场景,但博客系统读多写少,完全够用。
提示:技术选型没有绝对的对错,只有场景匹配度。小项目上重型技术栈,就像用卡车送外卖,能送到但成本离谱。
2.3 项目目录结构规划
目录结构是项目的骨架,一开始规划清楚,后面加功能才不会乱。我的规划是这样的:
blog-system/ ├── client/ # 前端项目 │ ├── index.html # 首页(文章列表) │ ├── detail.html # 文章详情页 │ ├── admin.html # 后台管理页 │ ├── css/ │ │ └── style.css # 全局样式 │ └── js/ │ ├── api.js # 接口请求封装 │ ├── index.js # 首页逻辑 │ ├── detail.js # 详情页逻辑 │ └── admin.js # 后台逻辑 ├── server/ # 后端项目 │ ├── app.js # 应用入口 │ ├── db.js # 数据库初始化与连接 │ ├── routes/ │ │ └── posts.js # 文章相关路由 │ └── blog.db # SQLite 数据库文件(自动生成) └── package.json这个结构的关键在于按职责分层:client和server完全隔离,server内部再按"入口-数据-路由"拆分。有人喜欢把所有后端代码塞进一个app.js,小项目确实能跑,但一旦要加用户模块、评论模块,单文件就会膨胀到难以维护。提前分好目录,后面加功能就是往对应目录里加文件,思路清晰。
3. 后端核心实现:从数据库到接口
3.1 数据库表设计与初始化
博客系统最核心的数据就是文章。我设计的文章表结构如下:
CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, summary TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );字段设计有几个考量点值得说。id用自增主键,简单直接,虽然有人推崇 UUID 避免暴露数据量,但博客这种场景自增完全够用,还方便排序。summary字段单独存摘要而不是每次从正文截取,是因为摘要往往需要人工润色,自动截取容易在句子中间断开,体验很差。created_at和updated_at分开存,前端展示时可以区分"发布于"和"最后更新于",这是很多博客都忽略的细节。
初始化数据库的代码放在db.js里:
const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const dbPath = path.join(__dirname, 'blog.db'); const db = new sqlite3.Database(dbPath); db.serialize(() => { db.run(` CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, summary TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); }); module.exports = db;这里用CREATE TABLE IF NOT EXISTS而不是先判断表是否存在再创建,是因为前者是原子操作,后者在并发场景下会有竞态问题。虽然单机博客不太可能并发初始化,但养成这个习惯没坏处。db.serialize保证建表语句按顺序执行,避免异步执行顺序错乱。
3.2 接口设计与路由实现
接口设计遵循 RESTful 风格,文章资源的增删改查对应如下:
| 方法 | 路径 | 功能 | 请求体 |
|---|---|---|---|
| GET | /api/posts | 获取文章列表 | 无 |
| GET | /api/posts/:id | 获取单篇文章 | 无 |
| POST | /api/posts | 新建文章 | title, content, summary |
| PUT | /api/posts/:id | 更新文章 | title, content, summary |
| DELETE | /api/posts/:id | 删除文章 | 无 |
为什么用 RESTful 而不是全部 POST 加一个 action 参数?因为 RESTful 的语义是自解释的,看接口路径和方法就知道在干什么,调试时也方便。比如DELETE /api/posts/5一眼就知道是删 id 为 5 的文章,而POST /api/posts带个{action: 'delete', id: 5}就得读代码才知道。
路由实现放在routes/posts.js:
const express = require('express'); const router = express.Router(); const db = require('../db'); // 获取文章列表 router.get('/', (req, res) => { db.all( 'SELECT id, title, summary, created_at FROM posts ORDER BY created_at DESC', (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); } ); }); // 获取单篇文章 router.get('/:id', (req, res) => { db.get('SELECT * FROM posts WHERE id = ?', [req.params.id], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: '文章不存在' }); res.json(row); }); }); // 新建文章 router.post('/', (req, res) => { const { title, content, summary } = req.body; if (!title || !content) { return res.status(400).json({ error: '标题和内容不能为空' }); } db.run( 'INSERT INTO posts (title, content, summary) VALUES (?, ?, ?)', [title, content, summary || ''], function (err) { if (err) return res.status(500).json({ error: err.message }); res.status(201).json({ id: this.lastID }); } ); }); // 更新文章 router.put('/:id', (req, res) => { const { title, content, summary } = req.body; db.run( `UPDATE posts SET title = ?, content = ?, summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [title, content, summary || '', req.params.id], function (err) { if (err) return res.status(500).json({ error: err.message }); if (this.changes === 0) return res.status(404).json({ error: '文章不存在' }); res.json({ updated: this.changes }); } ); }); // 删除文章 router.delete('/:id', (req, res) => { db.run('DELETE FROM posts WHERE id = ?', [req.params.id], function (err) { if (err) return res.status(500).json({ error: err.message }); if (this.changes === 0) return res.status(404).json({ error: '文章不存在' }); res.json({ deleted: this.changes }); }); }); module.exports = router;这段代码里有几个细节值得展开。列表接口只查需要的字段,不把content查出来,因为列表页不需要正文,正文可能很长,全查出来白白浪费带宽和内存。这是接口设计的基本功——按需返回。参数化查询(?占位符)是防 SQL 注入的关键,千万别用字符串拼接 SQL,这是新手最容易犯的安全错误。this.lastID和this.changes是 SQLite 回调里this指向的上下文提供的,前者返回刚插入行的 id,后者返回受影响行数,用它们来判断操作是否真的生效。
3.3 应用入口与中间件配置
app.js负责把各个部件组装起来:
const express = require('express'); const path = require('path'); const postsRouter = require('./routes/posts'); const app = express(); const PORT = 3000; // 解析 JSON 请求体 app.use(express.json()); // 静态文件服务,指向前端目录 app.use(express.static(path.join(__dirname, '../client'))); // 挂载文章路由 app.use('/api/posts', postsRouter); // 全局错误处理 app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: '服务器内部错误' }); }); app.listen(PORT, () => { console.log(`服务已启动:http://localhost:${PORT}`); });中间件的顺序很重要,express.json()必须在路由之前,否则req.body是空的。静态文件服务放在路由之前,这样访问/index.html能直接命中文件,访问/api/posts则继续往下走匹配路由。全局错误处理放在最后,捕获前面所有环节抛出的异常。这个顺序不是随便排的,Express 的中间件是按注册顺序依次执行的,理解这一点就理解了 Express 的核心机制。
注意:
express.static指向client目录后,前端页面和后端接口同源,开发阶段省去了跨域配置的麻烦。生产环境如果前后端分开部署,才需要处理 CORS,这个后面部署篇再讲。
4. 前端实现:页面渲染与接口对接
4.1 接口请求的统一封装
前端要调后端接口,如果每个页面都手写fetch,重复代码多且不好维护。我在api.js里做了一层薄封装:
const BASE_URL = '/api'; async function request(path, options = {}) { const res = await fetch(BASE_URL + path, { headers: { 'Content-Type': 'application/json' }, ...options }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `请求失败:${res.status}`); } return res.json(); } const api = { getPosts: () => request('/posts'), getPost: (id) => request(`/posts/${id}`), createPost: (data) => request('/posts', { method: 'POST', body: JSON.stringify(data) }), updatePost: (id, data) => request(`/posts/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deletePost: (id) => request(`/posts/${id}`, { method: 'DELETE' }) };这层封装的价值在于统一处理了错误和请求头。所有接口请求都走request函数,错误处理逻辑只写一遍,将来要加 token 认证、加请求日志,也只改这一处。这就是抽象的意义——把变化点集中管理。async/await配合fetch让异步代码读起来像同步代码,比回调地狱清晰太多。
4.2 首页文章列表的渲染逻辑
首页要做的事情很单纯:请求列表接口,把数据渲染成卡片。index.js的实现:
async function loadPosts() { const listEl = document.getElementById('post-list'); try { const posts = await api.getPosts(); if (posts.length === 0) { listEl.innerHTML = '<p class="empty">还没有文章,去后台写一篇吧</p>'; return; } listEl.innerHTML = posts.map(post => ` <article class="post-card"> <h2><a href="/detail.html?id=${post.id}">${escapeHtml(post.title)}</a></h2> <p class="summary">${escapeHtml(post.summary || '暂无摘要')}</p> <time>${formatDate(post.created_at)}</time> </article> `).join(''); } catch (e) { listEl.innerHTML = `<p class="error">加载失败:${e.message}</p>`; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatDate(str) { return new Date(str).toLocaleDateString('zh-CN'); } loadPosts();这里有个必须强调的点:escapeHtml不是可选项,是必选项。文章标题和摘要来自用户输入,如果直接拼进innerHTML,用户写个<script>标签就能执行任意脚本,这就是 XSS 攻击。用textContent转义再取innerHTML是最简单的转义方式,虽然性能一般,但博客这种量级完全无感。我见过太多练手项目忽略这一点,上线后被注入脚本挂马,教训很深刻。
日期格式化用toLocaleDateString而不是自己拼字符串,是因为它自动处理了时区和本地化,一行顶十行。当然如果要精确到时分秒,可以用toLocaleString配合选项参数。
4.3 详情页与后台管理的交互实现
详情页从 URL 里取id参数,请求单篇文章渲染:
const params = new URLSearchParams(location.search); const id = params.get('id'); async function loadDetail() { const container = document.getElementById('post-detail'); if (!id) { container.innerHTML = '<p class="error">缺少文章 ID</p>'; return; } try { const post = await api.getPost(id); document.title = post.title; container.innerHTML = ` <h1>${escapeHtml(post.title)}</h1> <time>发布于 ${formatDate(post.created_at)}</time> <div class="content">${post.content}</div> `; } catch (e) { container.innerHTML = `<p class="error">${e.message}</p>`; } }注意正文content这里没有转义,因为正文我打算支持富文本(后续篇会讲),存的是 HTML。这就引出一个安全权衡:标题和摘要必须转义,正文如果允许 HTML 则需要在后端做白名单过滤。这个取舍在后续富文本篇会详细展开,这里先埋个伏笔。
后台管理页admin.js负责表单提交和列表操作,核心是新建和删除:
document.getElementById('post-form').addEventListener('submit', async (e) => { e.preventDefault(); const title = document.getElementById('title').value.trim(); const summary = document.getElementById('summary').value.trim(); const content = document.getElementById('content').value.trim(); if (!title || !content) { alert('标题和内容不能为空'); return; } try { await api.createPost({ title, summary, content }); alert('发布成功'); location.reload(); } catch (err) { alert('发布失败:' + err.message); } });表单提交用e.preventDefault()阻止默认的页面跳转,改成异步提交,这是单页交互的基本操作。前端做一次非空校验是为了即时反馈,但后端必须再校验一次,因为前端校验可以被绕过(改 JS、直接调接口),后端校验才是最后防线。这个"双重校验"原则在任何 Web 项目里都适用。
5. 联调过程中的常见问题与排查实录
5.1 接口调不通的排查顺序
前后端联调阶段,最常见的报错就是接口 404 或 500。我总结了一套排查顺序,按这个顺序走基本能定位问题:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 404 Not Found | 路由路径不匹配 | 检查前端请求路径与后端注册路径是否一致 |
| 500 Internal Error | 后端代码异常 | 看后端控制台错误堆栈 |
| 请求体为空 | 缺 express.json() | 检查中间件是否注册 |
| CORS 报错 | 前后端不同源 | 开发阶段用同源静态服务规避 |
| 数据不更新 | 缓存或未刷新 | 检查接口返回,强制刷新页面 |
我踩过最典型的一个坑是:前端请求/api/posts,后端注册的是/posts,结果一直 404。原因是app.use('/api/posts', postsRouter)里已经带了/api前缀,路由内部再写/posts就变成了/api/posts/posts。挂载路径和路由内部路径是拼接关系,这个细节不注意很容易绕进去。排查方法很简单,在路由里加一行console.log(req.originalUrl),打印出实际请求路径,一目了然。
5.2 数据库操作的异步陷阱
SQLite 的 Node 驱动是异步回调风格,新手最容易犯的错是把异步当同步用。比如:
// 错误写法 let post; db.get('SELECT * FROM posts WHERE id = ?', [id], (err, row) => { post = row; }); res.json(post); // 这里 post 还是 undefinedres.json(post)执行时,数据库查询回调还没触发,post自然是空的。正确写法是把响应放在回调里:
db.get('SELECT * FROM posts WHERE id = ?', [id], (err, row) => { if (err) return res.status(500).json({ error: err.message }); res.json(row); });这个坑的本质是不理解异步执行顺序。我的建议是:凡是涉及数据库、网络、文件的操作,一律假设它是异步的,响应逻辑必须写在回调或await之后。如果觉得回调嵌套太深,可以用util.promisify把db.get包装成 Promise,配合async/await写起来更顺。
5.3 几个容易忽略的实操心得
第一个心得:开发阶段把后端错误信息完整返回给前端。生产环境当然要隐藏堆栈防止信息泄露,但开发时把err.message直接返回,能省掉大量翻控制台的时间。我一般用环境变量区分,NODE_ENV=development时返回详细错误。
第二个心得:接口先用工具测通再接前端。我习惯用 curl 或接口调试工具先把增删改查跑一遍,确认后端没问题,再写前端调用。这样出问题时能快速判断是前端还是后端的问题,避免两头猜。
第三个心得:数据库文件记得加进忽略列表。blog.db是运行时生成的,不应该提交到代码仓库,否则每个人拉下来都是别人的数据。在.gitignore里加一行*.db就行。这个细节虽小,但团队协作时能避免很多莫名其妙的冲突。
提示:联调阶段遇到诡异问题,先看浏览器 Network 面板的请求和响应,再看后端控制台日志,九成问题能在这两处找到线索。
6. 这一版做完了什么,下一步往哪走
到这一步,一个能跑的最小博客系统就成型了:后端有完整的文章增删改查接口,前端有列表页、详情页、后台管理页,数据存在 SQLite 里,前后端通过 RESTful 接口通信。代码量不大,但整条链路是通的,你可以打开浏览器发一篇文章,刷新首页看到它,点进去读全文,再回后台删掉它。这个闭环跑通的意义在于,你已经掌握了 Web 应用最核心的数据流模型,后面加任何功能都是在这个骨架上长肉。
我自己在这个阶段最大的体会是:先把最小闭环跑通,比一开始就设计完美架构重要得多。我早期做项目总想一步到位,结果设计了两天架构,代码没写几行就发现设计有问题,推倒重来。后来学乖了,先让东西跑起来,跑起来之后再重构,重构的成本远低于空想的成本。
这个项目后续还有几块要补:富文本编辑器接入(让正文能排版)、文章分页(列表长了要分页)、简单的登录鉴权(后台不能谁都能进)、以及部署上线(让外网能访问)。每一块都有不少坑,我会在后续篇里逐个拆解。如果你跟着这一篇把骨架搭起来了,不妨先自己试着加个"文章分类"字段,练练手——改表结构、改接口、改前端表单,走一遍完整流程,比看十篇教程都管用。