1. 为什么做rea:一个小全栈项目的由来
1.1 rea到底是个什么东西
第一次看到“rea”这个代号的人,多半会问一句:这名字是什么意思?其实它没那么多玄机,就是我当时手头一个前端小项目的英文缩写拼在一起:R(前端界面)、E(后端接口)、A(认证逻辑)。项目本身很简单——一个“灵感收集登记系统”。用户可以注册账号、登录、提交一条文本灵感,也能查看自己提交过哪些内容。听起来很普通,但它几乎覆盖了一个正经Web应用应该有的所有环节:前端的表单交互、界面跳转、登录态保持;后端的接口设计、数据存储、身份校验;以及最终的前后端联调和部署上线。
这项目的价值恰恰就在于“麻雀虽小五脏俱全”。现在很多教程教前端就只写前端,教后端就只写后端,但真实工作中最让人头疼的往往是中间那一层:前端怎么把token带给接口、后端怎么判断请求是否合法、部署到服务器后为什么接口404。我刚入行那几年里,这些坑是一个不落全踩过。所以当我要给团队里的新人做培训时,就选了这样一个“小但完整”的项目当教材。你读这篇文章,不需要多高深的基础,只要能看懂基本的JavaScript语法,跟着把代码敲一遍,就能亲手拼出一个能跑通全流程的应用。这个体验,比单纯看一百篇教程都管用。
1.2 技术栈选择背后的几点考虑
先说前端界面。我当时没有用任何重量级的UI组件库,核心就靠一个主流前端框架,配合几段自己写的CSS。原因很简单——这个项目的重点不在“界面多好看”,而在“数据怎么流转”。组件库会帮你省掉很多样式工作,但也会让你忽略页面状态是怎么一层层传下去的。自己写,反而能把props和state的关系看得更清楚。
后端接口选了一个极简的Node框架,几句话就能起一个服务。它最大的优点是“不挡事”,中间件体系简单直观,很适合用来演示“一个请求从进来到返回,中间发生了什么”。认证这块,用的是目前前后端分离项目里最常见的JWT(JSON Web Token)方案。相比传统的session,JWT不用在服务器端维护会话状态,用户登录成功后拿到的就是一个带签名的token,后续每次请求带上它,后端验签通过就放行。这个机制理解起来也不难:token就像一张游乐园的当日手环,入口处看一眼手环上的特殊标记,是真的就让你进,撕下来或者过期了就只能去售票处重新换。
至于数据存储,我更是在一开始就刻意做了“减法”:没有上数据库,而是直接用一个JSON文件存数据。很多有经验的人看到这里可能会皱眉——生产环境用文件存储,并发一高就完蛋。但在这个项目里,数据量基本维持在几十条,读写频率极低,文件存储完全够用。更重要的是,它能把“数据从哪来、存到哪去”这件事彻底摊开给你看——打开文件就是一堆明文记录,字段长什么样一目了然。等以后需要扩展成数据库,无非是把读写文件的这部分代码替换成数据库操作,整个项目的骨架不用动。
提示:技术选型没有绝对的对错,关键是看你当前的目标是什么。如果是学原理、搭原型,文件存储是最低成本的方案;如果一开始就追求“生产级”,反而容易被细节拖垮。
2. 后端核心实现:接口设计与认证逻辑
2.1 接口划分:先把业务边界划清楚
动手写代码之前,我习惯先把接口列表列出来。这步看起来简单,却能省下大量后期返工的时间。这个项目一共只需要四个接口:
| 方法 | 路径 | 作用 | 是否需要登录 |
|---|---|---|---|
| POST | /api/auth/register | 用户注册 | 否 |
| POST | /api/auth/login | 用户登录,返回token | 否 |
| GET | /api/items | 获取当前用户提交的灵感列表 | 是 |
| POST | /api/items | 提交一条新灵感 | 是 |
注意列表里那两个受保护的接口——它们才是这次项目的真正难点。很多新人写后端时,接口能查数据就算完事,根本不校验“这个请求是不是来自合法用户”。结果就是随便谁拿个接口地址,就能读到别人的数据。真正的业务系统里,这种漏洞比页面样式丑严重一百倍。所以rea项目的后端核心,不是那四个接口怎么写,而是“JWT认证中间件”怎么写。
数据文件我把它叫做data.json,初始结构是用户数组和灵感数组分开两条线。用户对象里放id、用户名、密码哈希、创建时间;灵感对象里放id、用户id、内容、标签、创建时间。这样设计有一个直接的好处:查“某个用户的灵感”时,只需要按用户id过滤数组就能拿到结果,不需要做复杂的表关联。虽然文件存储和数据库在底层实现上差很多,但这个“按id关联”的思路是相通的,以后换数据库时数据结构几乎可以平移过去。
2.2 JWT认证落地:一段能直接用的中间件
我在写认证中间件时,核心就三件事:解出token、验证签名、把当前用户信息挂到请求上。下面这段代码是后端里最值得反复看的部分:
const jwt = require('jsonwebtoken'); function authMiddleware(req, res, next) { // 前端会通过 Authorization 头把 token 带过来,格式是 "Bearer <token>" const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ message: '缺少登录凭证' }); } try { const payload = jwt.verify(token, process.env.JWT_SECRET); // 校验通过后,把用户id挂到req上,后续处理器直接使用 req.userId = payload.userId; next(); } catch (err) { return res.status(401).json({ message: '登录凭证无效或已过期' }); } }这段代码的逻辑很直白:先把请求头里的token取出来,用同一个密钥去验签。验签通过就说明这个token确实是服务器签发的、没被篡改、也没过期,然后把token里携带的用户id拿下来,交给后续的接口使用。我特别想强调的是process.env.JWT_SECRET这个值——签名密钥绝对不能写死在代码里。我见过不止一个项目,把密钥明文放在代码仓库里,等于把整个认证体系的安全钥匙挂在门口。正确做法是通过环境变量注入,比如在本地准备一个.env文件,配合dotenv这个包来加载。
注册和登录的接口实现也不复杂。注册时要做两件事:检查用户名是否已经存在,然后把密码做哈希处理后再保存。密码处理这块要单独说一句:绝对不要存明文密码。演示代码里我会用sha256加盐的方式凑合一下,但真实项目请务必使用专门的密码哈希库,它们内部实现了加盐和慢哈希,暴力破解的成本会高好几个数量级。登录接口则简单得多——根据用户名找到用户,验证密码哈希是否一致,一致就签发一个有效期一两小时的token返回给前端。
2.3 存储层取舍:先用文件存,别上来就上数据库
文件存储的代码写起来很接地气。我封装了一个简单的读写工具,每次操作都走“读文件 → 改数据 → 写回文件”的流程:
const fs = require('fs'); const path = require('path'); const DB_PATH = path.join(__dirname, 'data.json'); function readDB() { return JSON.parse(fs.readFileSync(DB_PATH, 'utf8')); } function writeDB(data) { fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2), 'utf8'); }新增一条灵感的接口,就是读数据、推到数组里、再写回文件,顺手把当前用户id存进去。这里藏着一个非常典型的“并发陷阱”:如果有两个请求几乎同时到达,都先读了旧数据,然后各自往内存里的数组推了一条,最后分别写回文件——后写的那个会把先写的覆盖掉。解决这个问题最简单的办法是给写操作加一个队列,让同一时刻只有一个写任务在跑。这个细节我在第5章的问题排查里还会再展开。
注意:文件存储方案只适合这种单人维护、低并发的项目。一旦有两个人同时在用,就可能出现数据互相覆盖的情况。认识到这个边界的适用范围,和学会写代码本身同样重要。
3. 前端核心实现:页面状态与登录态管理
3.1 页面结构:三个页面撑起一个完整流程
前端部分我用那套主流框架的组件化方式,把整个应用拆成了三个页面:登录/注册页、提交页、列表页。之前提到的路由本身就可以在主组件里通过useState来手动切换页面视图,不急着引路由库,让逻辑更直白。
登录/注册页是这个应用的入口。用户第一次进来看到的是登录框,下面放一个“没有账号?去注册”的切换链接,点击后同一个表单变成注册模式。在这页处理完的返回值,可能是一条“注册成功,请登录”的提示,也可能是登录成功后拿到的token。提交页其实就是一个表单:一个输入框、一个标签输入框、一个提交按钮。提交成功后清空表单,并且提示用户“已记录”。列表页就再简单不过了——调用接口拉取当前用户的灵感数组,用列表渲染出来,每条数据展示内容、标签和提交时间。
这三个页面串起来就是一个完整的业务闭环:“没账号先注册 → 没登录进不来 → 登录后提交内容 → 提交的内容能在列表页看到”。整个流程没有任何多余的功能,每个页面都有明确的存在意义。新手跟着做的时候,脑子里随时要绷着一根弦:当前页面需要哪些数据、这些数据从哪来、用户做了什么操作之后页面要变成什么样。带着这些问题看页面,比复制代码有意义得多。
3.2 登录态管理:别把token只存在变量里
登录态管理是前端最容易出错的地方。很多初学者会把token保存在组件里的一个变量中,然后刷新页面就发现登录态丢了,接口全部401。原因很简单——组件变量是内存数据,页面一刷新就清零。正确做法是把token持久化到本地存储里,每次应用启动时先从本地存储里读出来,再放到内存中供各个组件使用。
写一个简单直观的封装代码:
const TOKEN_KEY = 'rea_token'; export function getToken() { return localStorage.getItem(TOKEN_KEY); } export function setToken(token) { localStorage.setItem(TOKEN_KEY, token); } export function clearToken() { localStorage.removeItem(TOKEN_KEY); }这种封装的好处是,以后如果想改成cookie存储,只需要改这个文件,业务组件完全不用动。登录态管理还有一个比较隐蔽的点:退出登录时不仅要清除前端保存的token,还要把内存里跟用户相关的其他状态一起清干净。比如列表数据,如果不清理,用户退出后再登上另一个账号,界面上可能还残留上一个人的数据痕迹,这个细节在评比级别的项目里很加印象分。
3.3 表单交互与请求封装
然后说说请求的封装。每个页面如果都直接调用fetch,代码会非常冗长且容易出错。我习惯抽出一个request函数,统一处理三件事:
async function request(url, options = {}) { const token = getToken(); const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; const res = await fetch(url, { ...options, headers }); if (res.status === 401) { clearToken(); // 这里可以跳回登录页,或者触发一个全局提示 throw new Error('登录状态已失效,请重新登录'); } const data = await res.json(); if (!res.ok) { throw new Error(data.message || '请求失败'); } return data; }这个封装的价值在“统一”。统一加请求头、统一处理401、统一解析错误信息,后续每个接口调用只需要一句话。对了,我在写表单的时候也栽过几次跟头,特别想提醒一件事:提交按钮要加一个“提交中”的状态,在请求发出时禁用按钮并显示“提交中”。否则用户在慢网环境下多点几下,就会重复提交好几条相同的数据。这个细节不解决,列表页迟早出现一堆重复内容。
4. 联调、部署与上线踩坑记录
4.1 本地联调:跨域问题这样处理
当后端接口已经写通、前端页面也画好了,真正的“战争”才刚开始——联调。第一次把前端和后端连起来跑的时候,十有八九会遇到跨域报错:前端地址是localhost:5173,后端端口是3000,浏览器会拦截跨源请求。
我当时用的方案很清爽:给前端开发服务器配一个代理(proxy)。代理的作用相当于一个“中间人”,前端的请求先发给同源的开发服务器,再由它转发给后端。因为服务器与服务器之间的通信不受浏览器的同源策略限制,所以跨域问题直接从源头消失。
// vite.config.js export default { server: { proxy: { '/api': 'http://localhost:3000' } } }配完之后,前端所有接口请求都写成相对路径比如/api/items,不用关心后端到底在哪个端口。这个方法强烈推荐,因为开发环境和生产环境的代码可以完全一致——生产部署时也是把前端静态文件交给后端服务托管,同样使用相对路径。如果一开始图省事,在后端直接写死了允许跨域的域名,后面部署时域名一变,又是一通折腾。
4.2 部署注意点:静态资源与接口路径
部署上线是另外一道坎。我的建议是把前端构建后的静态文件,直接交给后端服务来托管。构建完成后会生成一个dist目录,里面有打包好的HTML、JavaScript和CSS文件。后端只需要加一行中间件,把这个目录当成静态资源目录,再配置一下“非接口路径全部返回首页HTML”,前端路由刷新时就不会404了。
这里有个容易忽略的问题:开发环境下可以用proxy,但生产环境完全不一样。生产环境的前端和后端同源,就不需要代理;如果前后端部署在不同域名下,你必须为生产环境单独配置跨域白名单,并且确保接口的baseURL是按环境区分的。我见过太多项目上线后页面白屏、接口请求路径还是localhost的乌龙——排查一下,基本都是因为环境变量没分开配。我的习惯是在前端代码里永远不写死API地址,而是用构建时注入的环境变量控制,这样开发、测试、生产三套环境同一份代码,改一份配置文件就行。
部署完成后,我还会做一轮“清空缓存式”的自测:用浏览器的无痕模式打开页面,从头走一遍注册、登录、提交、查看的完整流程。这一步能过滤掉大量“本地好好的,上线就崩”的低级问题,比测一个页面就完事靠谱得多。
5. 常见问题与排查清单
5.1 典型的五个坑和对应解法
这个项目写下来,我统计了一下,新手容易踩的坑高度集中在这五类:
| 症状 | 可能原因 | 排查与解法 |
|---|---|---|
| 前端请求接口404 | 请求路径写错,或后端路由没匹配上 | 先看浏览器Network面板,确认实际请求的URL和请求方法 |
| 报跨域错误 | 没有配代理或后端没设允许跨域 | 本地优先用proxy,生产环境按部署拓扑单独配置 |
| 接口返回401 | token没带上、过期了、被篡改 | 打印请求头,确认Authorization字段是“Bearer + 空格 + token” |
| 页面刷新后登录态丢失 | token只存在内存中 | 改用localStorage持久化保存 |
| 多条数据重复提交 | 按钮没做防抖或提交状态锁定 | 请求进行中禁用提交按钮,并显示加载状态 |
这五类问题覆盖了从联调到上线的绝大部分事故,也是我自己反复踩过几轮才总结出来的。碰到问题别慌,顺序永远是:先看请求是否发出、再看请求能否到达服务器、最后看服务器返回了什么。大多数bug都能在这个定位路径里被快速揪出来。
5.2 我的几点实操心得
第一个心得,mocking的数据永远不能替代一次真实联调。我在很多教程里见过“前端用假数据,后端也返回假数据,两边各自美滋滋”的做法,等最后接到一起才发现字段名对不上、类型对不上、状态码约定不一致。建议从一开始就确定好字段格式写一个简单的接口文档,哪怕只有两三个接口,也先定好再动手。
第二个心得,把JWT密钥和数据库地址这种敏感配置放环境变量里,并且养成不把.env文件提交到代码仓库的习惯。这个问题在rea里就已经显得重要,放到真实团队项目里更是致命的。我后来帮别人排查安全问题时,第一眼就是看代码仓库里有没有泄露的密钥文件,这已经是安全评审的必要检查项了。
第三个心得,项目再小,也要写README。虽然rea只是个演示项目,我还是在里面写清楚了启动步骤、端口约定、接口列表和环境变量说明。过了两三个月再翻这个项目,你不可能记住每一个细节。README不仅是给别人的说明书,更是给未来的你自己的备忘。
最后再分享一个小技巧:如果后续想给rea加功能,优先级最高的其实是“改文件存储为真正的数据库”,因为这是整个项目唯一一个明显的单点瓶颈。把存储层替换成一套成熟的数据库方案,再加上基础的查询优化,这个小项目就能平滑升级成一个颇具规模的应用雏形了。亲手完成这样的演进,你会发现之前打的每一个地基都没有白费。