你真以为“全栈”就是前端+后端这么简单吗?
本文分享了一个全栈开发Todo项目的实战经验,重点探讨了全栈开发的核心难点和技术实现。作者从初学者视角出发,剖析了全栈开发中常见的"缝合"问题,包括数据结构对齐、错误处理一致性和性能优化等关键点。 项目采用Node.js+Express+PostgreSQL+React技术栈,实现了包含用户认证、权限控制、分页查询等完整功能的Todo应用。文章详细介绍了数据库表设计(特别强调索
哈喽,各位小伙伴,欢迎来到dly_blog的博客!我是我是dly呀,虽然还在编程的“菜鸟”阶段,但我已经迫不及待地想和大家分享我一路上踩过的坑和学到的小技巧。如果你也曾为bug头疼,那么你来对地方了!今天的内容希望能够给大家带来一些灵感和帮助。
前言:全栈的快乐,来自“我都能修”的嘴硬
我见过太多人对“全栈”有误解:
- 有人以为全栈是“啥都会一点”,像自助餐,夹两口就走。
- 也有人以为全栈是“一个人当十个人用”,像无薪超人。
我以前也嘴硬:“全栈嘛,不就是前端写页面、后端写接口、数据库存数据、上线按按钮?”
后来项目真跑起来——接口一慢,前端卡住;数据库索引一少,后端开始冒汗;上线配置一错,半夜开始怀疑人生。
所以这篇我不打算讲“宏大叙事”,我就讲一件很具体的事:
从 0 到 1 做一个“小而完整”的全栈功能:待办 Todo(带登录、鉴权、分页、错误处理),最后给你一个能跑起来的项目骨架。
你看,目标很朴素,但坑绝对不少(嘿嘿,坑多才真实嘛)。
1. 全栈到底难在哪?难在“缝合”😵💫
做单点技术,很多时候像“解题”:
- 前端:把交互写顺,把状态管好。
- 后端:把接口写对,把权限做严。
- DB:把表设计稳,把查询变快。
但全栈的核心,不是你会多少语法,而是你能不能把这些“缝”缝得不漏风:
1.1 典型缝合点(也是典型翻车点)
- 数据结构对齐:前端以为是
completed,后端叫isDone,然后大家互相甩锅:“你怎么不按接口文档来?” - 错误处理一致:前端想要
code/message,后端直接丢一坨 HTML 500。前端:我怎么展示?后端:你自己想办法。 - 鉴权策略统一:用 Cookie?JWT?Session?CSRF 怎么搞?刷新 Token 怎么搞?搞不好就是“安全漏洞大礼包”。
- 性能认知闭环:慢到底是慢在 SQL?慢在网络?慢在序列化?慢在前端渲染?
你要是只会一句“我这边没问题”,那就等着被问题教育。
全栈最值钱的能力:你能把“问题定位链路”跑通。
说白了就是:你能像侦探一样,从页面卡顿一路追到数据库索引缺失,然后修完还顺手加个监控。😎
2. 我们来做个“能上线”的 Todo:需求先别飘,先别装😆
我给这个小项目定几个现实一点的需求(别搞“宇宙级 Todo”):
- 用户注册/登录
- 创建/完成/删除 Todo
- 只允许操作自己的 Todo(权限)
- 分页查询(别一次性全拉)
- 统一错误格式
- 前端有基本交互(加载态、错误提示)
技术栈我选一个相对通用、上手快的组合:
- 后端:Node.js + Express
- 数据库:PostgreSQL(你也可以换 MySQL,思路一样)
- 鉴权:JWT
- 前端:React(Vite)(你换 Vue 也不影响理解)
3. 数据库:别急着建表,先想“未来会怎么查”🧠
3.1 表结构(最小可用,但不简陋)
- users:用户表
- todos:待办表,关联 user_id
-- users
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- todos
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 为了分页和按用户查,加索引(别等慢了再补)
CREATE INDEX idx_todos_user_created ON todos(user_id, created_at DESC);
我为啥强调索引?
因为你迟早会写这种查询:
- “查某个用户最近的 todo”
- “分页”
- “按时间倒序”
没索引就会变成数据库在那儿“翻旧账”,越翻越慢,慢到你开始怀疑服务器被人偷了。
4. 后端:接口写得再快,不如错误格式统一来得爽👌
4.1 先定一个统一返回结构(别让前端猜)
成功:
{ "ok": true, "data": {...} }
失败:
{ "ok": false, "error": { "code": "AUTH_REQUIRED", "message": "请先登录" } }
这玩意儿看起来很“规范”,实际上是为了减少扯皮。
不然你会听到这些人类经典语录:
- “你这个接口到底是 200 还是 500?”
- “你这个 message 能不能给我个稳定的?”
- “你这错误怎么是字符串,不是对象啊?”
4.2 Express 后端代码(可跑的核心示例)
下面是一个“最小但完整”的后端示例:
- 注册/登录
- JWT 中间件
- Todo CRUD(带分页)
- 统一错误处理
// server.js
import express from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import pg from "pg";
const app = express();
app.use(express.json());
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
// 小工具:统一响应
function ok(res, data) {
return res.json({ ok: true, data });
}
function fail(res, code, message, status = 400) {
return res.status(status).json({ ok: false, error: { code, message } });
}
// 鉴权中间件:从 Authorization: Bearer xxx 取 token
function auth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return fail(res, "AUTH_REQUIRED", "请先登录", 401);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.uid, email: payload.email };
next();
} catch (e) {
return fail(res, "AUTH_INVALID", "登录已过期或无效", 401);
}
}
// 注册
app.post("/api/register", async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return fail(res, "BAD_INPUT", "邮箱和密码不能为空");
const password_hash = await bcrypt.hash(password, 10);
try {
const result = await pool.query(
"INSERT INTO users(email, password_hash) VALUES($1, $2) RETURNING id, email",
[email, password_hash]
);
return ok(res, { user: result.rows[0] });
} catch (e) {
// 唯一约束冲突
if (String(e).includes("duplicate key")) {
return fail(res, "EMAIL_EXISTS", "邮箱已被注册");
}
return fail(res, "SERVER_ERROR", "服务器开小差了", 500);
}
});
// 登录
app.post("/api/login", async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return fail(res, "BAD_INPUT", "邮箱和密码不能为空");
const result = await pool.query("SELECT id, email, password_hash FROM users WHERE email=$1", [email]);
const user = result.rows[0];
if (!user) return fail(res, "LOGIN_FAILED", "邮箱或密码不对", 401);
const passOk = await bcrypt.compare(password, user.password_hash);
if (!passOk) return fail(res, "LOGIN_FAILED", "邮箱或密码不对", 401);
const token = jwt.sign({ uid: user.id, email: user.email }, JWT_SECRET, { expiresIn: "7d" });
return ok(res, { token });
});
// 创建 todo
app.post("/api/todos", auth, async (req, res) => {
const { title } = req.body || {};
if (!title?.trim()) return fail(res, "BAD_INPUT", "标题不能为空");
const result = await pool.query(
"INSERT INTO todos(user_id, title) VALUES($1, $2) RETURNING id, title, completed, created_at",
[req.user.id, title.trim()]
);
return ok(res, { todo: result.rows[0] });
});
// 分页查询 todos:?page=1&pageSize=10
app.get("/api/todos", auth, async (req, res) => {
const page = Math.max(1, Number(req.query.page || 1));
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 10)));
const offset = (page - 1) * pageSize;
const totalRes = await pool.query("SELECT COUNT(*)::int AS c FROM todos WHERE user_id=$1", [req.user.id]);
const total = totalRes.rows[0].c;
const listRes = await pool.query(
"SELECT id, title, completed, created_at FROM todos WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
[req.user.id, pageSize, offset]
);
return ok(res, {
page,
pageSize,
total,
items: listRes.rows,
});
});
// 切换完成状态
app.patch("/api/todos/:id/toggle", auth, async (req, res) => {
const id = Number(req.params.id);
if (!id) return fail(res, "BAD_INPUT", "无效的 todo id");
// 只允许改自己的
const result = await pool.query(
"UPDATE todos SET completed = NOT completed WHERE id=$1 AND user_id=$2 RETURNING id, title, completed, created_at",
[id, req.user.id]
);
if (result.rowCount === 0) return fail(res, "NOT_FOUND", "Todo 不存在或无权限", 404);
return ok(res, { todo: result.rows[0] });
});
// 删除
app.delete("/api/todos/:id", auth, async (req, res) => {
const id = Number(req.params.id);
const result = await pool.query("DELETE FROM todos WHERE id=$1 AND user_id=$2", [id, req.user.id]);
if (result.rowCount === 0) return fail(res, "NOT_FOUND", "Todo 不存在或无权限", 404);
return ok(res, { deleted: true });
});
app.listen(3000, () => console.log("API listening on http://localhost:3000"));
你看,这后端写完之后,你会获得一种非常朴实的快乐:
前端再怎么闹,它闹不出“接口格式不统一”的幺蛾子。(我说的,谁反对谁写文档!😤)
5. 前端:别一上来就“组件洁癖”,先把链路跑通🚴♂️
前端最致命的不是写不出页面,是链路不通:
- token 存哪?
- 请求怎么带 token?
- 401 怎么处理?跳登录还是弹提示?
- 列表分页怎么做?加载态怎么做?
下面我给一个“足够实用但不花里胡哨”的 React 示例。
5.1 请求封装:让错误“像人话”一点
// api.js
export async function apiFetch(path, { token, method = "GET", body } = {}) {
const res = await fetch(path, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const msg = data?.error?.message || `Request failed (${res.status})`;
const code = data?.error?.code || "UNKNOWN";
const err = new Error(msg);
err.code = code;
err.status = res.status;
throw err;
}
return data.data;
}
5.2 登录 + Todo 列表(核心链路)
// App.jsx
import React, { useEffect, useState } from "react";
import { apiFetch } from "./api";
export default function App() {
const [token, setToken] = useState(localStorage.getItem("token") || "");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [items, setItems] = useState([]);
const [title, setTitle] = useState("");
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
async function login() {
setErr("");
try {
const data = await apiFetch("/api/login", {
method: "POST",
body: { email, password },
});
localStorage.setItem("token", data.token);
setToken(data.token);
} catch (e) {
setErr(`${e.message}(${e.code})`);
}
}
async function loadTodos(p = page) {
if (!token) return;
setLoading(true);
setErr("");
try {
const data = await apiFetch(`/api/todos?page=${p}&pageSize=${pageSize}`, { token });
setItems(data.items);
setTotal(data.total);
setPage(data.page);
} catch (e) {
// 401:清 token,让 UI 回到登录
if (e.status === 401) {
localStorage.removeItem("token");
setToken("");
}
setErr(`${e.message}(${e.code})`);
} finally {
setLoading(false);
}
}
async function addTodo() {
if (!title.trim()) return setErr("你倒是写点字啊喂!😅");
setErr("");
try {
await apiFetch("/api/todos", { token, method: "POST", body: { title } });
setTitle("");
await loadTodos(1);
} catch (e) {
setErr(`${e.message}(${e.code})`);
}
}
async function toggle(id) {
setErr("");
try {
await apiFetch(`/api/todos/${id}/toggle`, { token, method: "PATCH" });
await loadTodos(page);
} catch (e) {
setErr(`${e.message}(${e.code})`);
}
}
async function del(id) {
setErr("");
try {
await apiFetch(`/api/todos/${id}`, { token, method: "DELETE" });
await loadTodos(page);
} catch (e) {
setErr(`${e.message}(${e.code})`);
}
}
useEffect(() => {
loadTodos(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
if (!token) {
return (
<div style={{ padding: 24, maxWidth: 420 }}>
<h2>登录</h2>
<input placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<br />
<input placeholder="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<br />
<button onClick={login}>Login</button>
{err && <p style={{ color: "crimson" }}>{err}</p>}
<p style={{ opacity: 0.7 }}>提示:后端返回统一错误,前端就不会像猜谜一样痛苦。</p>
</div>
);
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ padding: 24, maxWidth: 560 }}>
<h2>Todo</h2>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="写个待办(别鸽)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ flex: 1 }}
/>
<button onClick={addTodo}>Add</button>
<button
onClick={() => {
localStorage.removeItem("token");
setToken("");
}}
>
Logout
</button>
</div>
{loading ? <p>加载中...我知道你急,但你先别急 😮💨</p> : null}
{err ? <p style={{ color: "crimson" }}>{err}</p> : null}
<ul>
{items.map((t) => (
<li key={t.id} style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<span
onClick={() => toggle(t.id)}
style={{ cursor: "pointer", textDecoration: t.completed ? "line-through" : "none" }}
title="点击切换完成"
>
{t.title}
</span>
<button onClick={() => del(t.id)}>Delete</button>
</li>
))}
</ul>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button disabled={page <= 1} onClick={() => loadTodos(page - 1)}>
Prev
</button>
<span>
Page {page}/{totalPages}(Total {total})
</span>
<button disabled={page >= totalPages} onClick={() => loadTodos(page + 1)}>
Next
</button>
</div>
</div>
);
}
这一套跑通,你会突然体会到全栈的爽点:
“我不怕某个环节出问题,因为我知道怎么从头追到尾。”
这感觉就像:你家水管漏了,别人喊物业,你直接抄起扳手。🛠️
6. 专业一点的“加分项”:你做了它们,别人就开始尊重你(真的)✨
6.1 安全:别把“能用”当“安全”
- JWT Secret 别硬编码(我示例里写了 dev-secret,是为了提醒你上线要改)
- 密码必须 hash(bcrypt 已做)
- 权限检查必须在 SQL 层约束(
WHERE id=? AND user_id=?这句很关键) - 别把错误栈直接返回给前端(会送漏洞线索)
6.2 性能:不要迷信“服务器不够强”,先看 SQL
- 分页查询的
LIMIT/OFFSET+ 索引,是你最便宜的提速方式 - 热点接口加监控:记录响应时间、SQL 耗时
- 数据量上来后,考虑用
keyset pagination(用 created_at 或 id 做游标),这比 offset 更稳
6.3 可维护:你未来的你,会感谢现在写规范的你
- 统一响应结构(你已经看到了)
- API 路由分层(controller/service/dao)
- 前端请求统一封装(你也看到了)
- 关键逻辑写测试(至少把 auth 和 todo 权限测一下)
7. 我最想说的:全栈不是“全会”,是“全都能扛一下”😄
很多人把全栈当成“技能树点满”,结果一看就疲惫:
“我要学 React、Node、DB、Docker、CI/CD、监控、日志、缓存、消息队列、微服务、K8s……”
听着就想躺平,对吧?😵
但我更喜欢把全栈理解成——
你能用最小的代价,把一个东西从想法变成可交付。
并且你遇到问题能定位:
- 前端问题你不慌
- 后端问题你不躲
- 数据库慢了你不甩锅
你不一定全都精通,但你知道怎么拆、怎么补、怎么把项目推着往前走。
这种人,真的很贵。💰(而且很抢手)
结语:你敢不敢问自己一句——你做的“全栈”,真的能上线吗?😏
写到这里,我反而想对你抛个反问:
你写过多少“跑在本地很开心”的项目,但它离“上线可用”还差多少条你没补的缝?
好啦,今天的内容就先到这里!如果觉得我的分享对你有帮助,给我点个赞,顺便评论吐个槽,当然不要忘了三连哦!感谢大家的支持,记得常回来,我是dly呀等着你们的下一次访问!
更多推荐

所有评论(0)