
如果你在一个前后端分离的项目里待过一阵子大概率遇到过这类对话前端说“这个状态放前端就行刷新丢了也没关系”后端说“不行这个状态必须后端存不然没法校验”。两边说得都挺有道理但代码一旦合到一起就是没完没了的bug——购物车数据对不上、按钮重复提交、页面状态和服务端状态互相覆盖。我转全栈之后最深的感受是很多冲突根本不是接口定义不清而是两边对“状态”这两个字的默认理解完全不在一个频道上。这篇文章我不打算给你一套万能框架而是想结合自己踩过的坑聊聊前后端状态认知差异到底在哪以及设计边界时到底该看哪几个判据适合正在往全栈方向走、或者已经在全栈项目里被状态折腾过的朋友。1. 我见过最典型的状态“翻车”现场先聊为什么全栈容易卡在状态上1.1 一个购物车案例引发的两套数据打架很早之前我参与过一个电商后台的改造前端用 Vuex 管理购物车后端 session 里也维护了一份购物车。前端为了体验流畅用户点“加入购物车”后立刻更新本地状态同时发请求告诉后端。看起来没什么问题直到出现这种场景用户加了三个商品前端本地显示 3 件后端异步处理慢了一步session 里还是 2 件。用户刷新页面后端返回购物车 2 件前端一看“我加的东西怎么没了”开始骂后端丢数据。后端也很冤“你请求还没到我这里刷新的时候我已经尽力返回了”。这个案例里前后端都认为购物车是“自己的状态”但都没有说清楚以谁为准。更麻烦的是谁也没有在接口层做合并机制最终只能靠“以后端为准前端必须等响应再更新”来强行收敛体验结果用户每点一次都要转圈体验又变差了。1.2 前后端对“状态”的默认值完全不同我后来总结前端工程师默认的“状态”是眼前这一屏的东西弹窗开没开、下拉选了什么、输入框里打了什么字、当前路由是哪一页。这些东西生命周期很短页面一关就没了丢了也不心疼。后端工程师默认的“状态”则是要能恢复、能验证的东西订单走到哪一步、用户是否登录、库存还剩多少、这笔钱有没有入账。这些东西如果丢了轻则报错重则资损。一个默认状态要“瞬时”一个默认状态要“可靠”两边在设计接口时就会互相觉得对方不可理喻。前端觉得“你把用户ID放token里返回给我就完了干嘛每次都要我传”后端觉得“你每次都要传我才能确定你是你不然我凭什么信你”。这不是技术分歧是语义分歧。1.3 全栈工程师的真正处境全栈工程师之所以常常被拉去填坑就是因为你需要同时接住这两种语义并且主动帮团队划定边界。你不能只站在前端立场说“刷新别丢就行”也不能只站在后端立场说“一律服务端存”。你得能回答一个问题这个状态到底是谁的谁的生命周期更长谁丢了影响更大一旦开始用这三个问题审视很多争议会瞬间清晰。所以下面的内容我会先把“状态”拆开再给出三个判据最后用具体的重复提交、状态同步案例演示怎么落地。2. 拆解“状态”这个词前端三类状态与后端三类状态2.1 前端的三种状态前端口中的“状态”其实可以继续拆成三类拆完你才会知道哪些适合放前端哪些不适合。第一类是界面瞬时状态。灰色按钮、弹窗开合、输入框的值、当前展开的菜单项。这类状态只影响当前页面当前组件的表现关闭页面就该消失。放前端组件内部完全没问题你把它同步到后端反而会制造大量无意义的请求。第二类是会话内跨页面状态。用户在几个子页面之间跳转希望某些信息还保留比如登录态、当前选中的项目、筛选条件、表单草稿。这类状态需要跨路由存活一般放在前端全局 store、URL 参数或者 sessionStorage 里。刷新页面之后有些需要恢复有些允许丢失需要单独设计。第三类是本地持久状态。用户偏好、主题色、上次浏览位置、离线草稿。这类状态可以放在 localStorage 或 IndexedDB 里跨会话、跨刷新存活。但它只对当前设备有效换一台设备就没了。这三类状态有一个共同特征默认是某个用户在某个浏览器里的私有体验服务端不需要也不应该知道。2.2 后端的三种状态后端的状态同样可以拆成三类但生命周期完全不同。第一类是请求级状态。一次 HTTP 请求从进来到返回中间产生的临时变量、数据库事务、中间计算结果请求结束就销毁。一个好的后端接口请求级状态越少越好这样单个接口才容易理解、容易水平扩展。第二类是会话级状态。通过 Session ID、Token 或 Cookie 关联起来的用户上下文比如“当前登录的用户是谁”“这个用户属于哪个租户”“他有哪些权限”。这类状态可以放在服务端内存、Redis 里也可以编码进 JWT 让对方带回来。它的生命周期比一次请求长但比一条业务记录短。第三类是持久化状态。订单状态、账户余额、商品库存、已支付标记。这些状态写入数据库以后要长期存在丢了就是事故。它们需要事务、需要备份、需要审计。2.3 一张表看清状态属性差异维度前端瞬时状态前端本地持久状态后端会话状态后端持久化状态生命周期毫秒到分钟关页即失跨刷新、跨会话限本机分钟到天可过期天到永久写入数据库可见范围当前组件当前浏览器当前用户会话所有有权访问者丢失成本极低低到中中极高一致性要求基本不需要单机内一致会话内一致强一致或最终一致典型载体组件 state、reflocalStorage、IndexedDBRedis、JWT、SessionMySQL、PostgreSQL、MongoDB后端三类状态里真正影响架构的是第三类。你听到的“后端要无状态”针对的是第一类和第二类而不是第三类。如果一个系统连数据库里的订单状态都想砍掉那它就不是系统是玩具。这类误解是很多全栈新人最大的坑我在后面专门展开说。3. 状态归属的三个判据谁能看见、谁需要持久、谁负责正确性3.1 判据一这个状态是用户私有的还是多用户共享的判断一个状态该放哪第一个要问的问题是它只属于正在操作的这个用户/浏览器还是需要被其他人、其他设备看到比如“当前弹窗是否打开”除了当前用户当前页面没有任何人需要知道放前端。比如“购物车商品清单”用户换了设备/清了浏览器之后如果还希望存在就必须后端持久化单纯放在前端 localStorage 里换设备就丢不是购物车而是草稿箱。再比如“直播间在线人数”这是所有观众共享且需要实时看到的状态必须在后端汇总后下发。这条判据背后的逻辑是状态放在离使用者最近、同时又能满足共享需求的位置。私有状态尽量留在前端共享状态必须后端收敛。前端也可以管理共享状态但那个“前端管理”只是表现层最终的数据来源仍然要回到后端。3.2 判据二刷新页面/重新登录之后它必须在吗第二个问题是生命周期问题。刷新是前端状态最无情的检验器。如果某个状态刷新后就必须重建例如“当前正在输入的搜索词、当前展开的折叠面板”那它天然属于前端瞬时状态硬要后端把它记住纯属浪费资源。如果状态刷新以后必须还在例如“购物车里的商品”“用户是否已经实名认证”那它就不能只待在前端内存里。前端可以用 store 缓存一份来加速渲染但源头必须落在服务端。这里有个典型的中间态表单草稿。用户填了一半关掉页面下次打开希望还能看到。这个状态放 localStorage 可以满足单机场景但用户换设备就没了放后端草稿箱需要额外设计保存和恢复接口。具体怎么选不取决于技术取决于产品需求对“跨设备”的要求。全栈工程师的价值就是能把这个选择成本给产品讲清楚而不是默认“存后端”或“存前端”。3.3 判据三它被篡改之后会导致什么后果这是最硬核的一条判据也是前后端协作中最容易出问题的地方。如果状态被用户篡改后只是影响他自己的页面观感那放前端无所谓。比如按钮颜色、列表排序方式他想改就改反正只影响自己。但状态一旦涉及业务正确性——订单金额、支付状态、优惠券是否已使用、库存扣减——就绝对不能只信任前端传回来的值。这类状态的校验必须发生在后端以后端持久化数据为准前端传回来的只能作为“意图”不能作为“事实”。最常见的反面例子是“前端根据接口返回的 isPaid 来判断要不要显示支付成功页”。表面上看没问题但一个成熟系统里支付成功页的最终依据必须是后端订单状态/支付回调而不是前端本地存的布尔值。前端本地布尔值过期、被篡改、多端不同步都会导致页面展示与真实业务不一致。3.4 用三个判据快速筛选一个具体例子拿“购物车角标数字”和“订单是否已支付”对比一下。购物车角标它是用户的私有体验新鲜度要求高被篡改也只是显示数字不对。根据判据一私有状态判据二刷新后最好还在判据三篡改影响低。所以结论是前端可以先用本地状态乐观更新“加车成功”让用户立刻看到反馈同时把它同步到后端购物车存储真正的购物车数据以后端为准下次刷新页面时以后端返回重建角标。订单是否已支付它是共享的吗支付平台、商家、用户、客服都可能关心答案是共享。它需要持久吗必须持久。它被篡改的后果是什么直接关系钱款极其严重。所以它完全不能依赖任何一端的内存状态只能以后端持久化订单状态为准前端显示的页面只是这个事实的投影。一旦这样想你就不会写出“前端把 isPaid 传回后端”这种离谱设计了。3.5 常见误区“无状态服务”等于“完全不要状态”我多次听到有人把“后端无状态”挂在嘴边说“我们接口都是无状态的所以状态全部放前端”。这是对无状态最大的误读。无状态指的是单个服务节点在请求之间不保存用户状态这样任意一个节点都能处理任意一次请求方便水平扩展。但它没有说系统不能有状态——状态被搬到了 Redis、数据库、对象存储里由独立的基础设施统一管起来。你拆掉了进程内存里的状态代价是让 Redis、数据库变成新的状态中心同时把一致性、超时、并发控制的复杂度转移到了这些存储层。所以全栈设计里你真正要做的不是“消灭状态”而是决定每一份状态放在哪一层并明确每一层的所有权和同步机制。4. 一个最常被问的边界问题按钮重复提交到底该前端拦还是后端拦4.1 纯前端方案为什么永远不够很多前端团队防重复提交的方式是点击按钮后立刻 disabled或者用一个 loading 状态把按钮锁住再配合防抖、节流。这些手段能防住大部分正常用户的手滑和双击但它们拦截的是“同一时刻同一页面的重复操作”拦截不了以下情况用户弱网第一次请求还挂在路上前端 loading 被异常分支误重置用户以为没点成功再点一次请求发出后用户刷新页面前端状态归零但第一个请求还在后端处理刷新后页面恢复正常用户又提交一次后端处理了两次脚本、接口调试工具、多标签页绕开前端 UI 直接打接口前端禁用按钮对它完全无效分布式网关重试、消息队列重投后端收到了两次同一笔写操作。只要操作会产生副作用下单、支付、扣款、发消息前端方案就只是一个体验手段不是正确性保障。写操作的幂等性必须由后端负责这条边界不要模糊。4.2 后端怎么做幂等键 唯一约束 状态机后端处理重复提交的标准做法是引入一个“本次操作唯一身份”业界通常叫 Idempotency Key。核心思路是前端每次发起写操作前生成一个全局唯一的 ID放在请求头或请求体里带给后端后端第一次收到这个 ID 时执行操作并保存结果后续再收到相同 ID直接返回第一次的结果或者返回冲突/处理中不再重复执行业务逻辑。具体落地时有三层保障第一层幂等键存储。把幂等键作为 Redis 的 keyvalue 存处理结果或处理状态设置合理过期时间。如果 Redis 里已经存在说明是重复请求直接返回缓存结果。注意这里要处理并发两个相同 ID 同时到达需要用 SETNX 或 Lua 脚本来保证只有一个请求能抢到执行权。第二层数据库唯一约束。在业务表上建唯一索引幂等键作为一个业务字段落库。即使 Redis 丢了、缓存失效、服务重启数据库的唯一索引仍然能卡住重复插入。Redis 是快速通道数据库唯一约束是兜底。第三层业务状态机。订单从 PENDING 到 SUCCESS 只能前进一次从 SUCCESS 不允许再改成 PENDING。底层用更新条件WHERE order_id ? AND status PENDING保证状态转换原子性。即便是两个完全不同的请求只要同时想把状态改成终态也只有一个能成功。4.3 前后端协作的完整链路示例前端负责生成幂等键它不需要去“判断到底有没有重复”只需要把身份交给后端。下面是一个极简的代码示意。前端部分// 每次写操作生成一个 uuid同一个业务操作重试时复用同一个 key const idempotencyKey crypto.randomUUID(); async function submitOrder(payload) { const res await fetch(/api/orders, { method: POST, headers: { Content-Type: application/json, X-Idempotency-Key: idempotencyKey }, body: JSON.stringify(payload) }); return res.json(); }后端部分以 Express Redis 为例const express require(express); const redis require(redis); const app express(); const client redis.createClient(); app.use(express.json()); app.post(/api/orders, async (req, res) { const key req.headers[x-idempotency-key]; if (!key) { return res.status(400).json({ error: missing idempotency key }); } // SETNX 保证同一个 key 只有一个请求能继续执行 const acquired await client.setNX(idem:order:${key}, processing, { EX: 3600 }); if (!acquired) { // 说明这个 key 已经处理过或正在处理 const cached await client.get(idem:order:${key}); if (cached cached ! processing) { return res.json(JSON.parse(cached)); } return res.status(409).json({ error: duplicate request }); } try { // 真正的业务逻辑订单表里同时存 idempotency_key 并建唯一索引 const order await createOrder({ ...req.body, idempotencyKey: key }); await client.set(idem:order:${key}, JSON.stringify(order), { EX: 3600 }); res.json(order); } catch (err) { // 唯一索引冲突说明并发下已有重复记录 if (err.code ER_DUP_ENTRY) { return res.status(409).json({ error: duplicate request }); } throw err; } });这个示例里前端只做了两件事生成身份、传递身份。后端承担了“这个身份是否已经执行过”的判断和执行结果的存储。如果网络发生了重试或者用户刷新后再点一次只要前端复用了同一个幂等键后端就能识别并返回同一个结果不会重复下单。4.4 这个案例里的核心边界原则从重复提交案例可以提炼出一条通用边界前端负责“发起操作的身份标识”后端负责“操作身份的去重校验和结果权威性”。还有一些具体细节踩过坑才会注意幂等键不能拿当前时间戳拼随机数凑合最好用 UUID 或带业务前缀的唯一 ID同一个业务操作在重试时必须复用同一个幂等键而不是每次点击都生成新的前端可以在生成后存进 sessionStorage请求失败重试时取出来继续用幂等键过期时间要大于业务最长处理时间建议至少 1 小时支付类场景可以更长不要把所有接口都强制要幂等键只对有副作用的写操作POST/PUT/PATCH做要求GET 天然幂等。5. 状态同步的三种姿势轮询、长连接和“看起来无状态”的设计5.1 状态轮询的本质是把服务端状态搬到前端全栈项目里最常见的一种状态同步场景是后端某个任务在跑数据导入、生成报表、批量发送消息前端要实时显示进度。大多数团队第一反应就是轮询前端 setInterval 每隔几秒拉一次接口。轮询本身不难难在把轮询设计得不浪费资源、不闪屏、不覆盖用户操作。我总结过几个关键点第一轮询间隔不能拍脑袋定。用户能看到的状态变化频率是有限的一个导入任务的进度条每 500ms 刷新一次已经足够明显如果状态很少变化间隔可以逐步退避不能一个定时器从头打到尾。前端最好根据“页面是否可见”动态暂停轮询document.hidden 的时候还继续发请求纯属浪费。第二接口要支持增量判断。最蠢的做法是每次轮询都返回全量任务列表和完整历史状态前端全量替换。状态一旦多起来接口响应体变大、前端渲染也会抖动。更好的做法是后端返回一个 version 或 updatedAt 字段前端记录上一次的版本如果没变化就跳过更新有变化时再拉增量数据。第三轮询返回的状态不要盲目覆盖本地用户操作。比如用户在表格里勾选了几行轮询返回的数据里有一个字段和用户当前勾选状态冲突前端如果直接把整行数据替换掉用户的勾选就消失了。处理方式是前端维护一份“用户正在编辑的临时状态”白名单轮询只更新白名单之外的服务端字段。5.2 轮询、SSE、WebSocket 怎么选轮询适合服务端状态变化不频繁、允许秒级延迟、不想维护长连接的场景。SSEServer-Sent Events适合服务端有持续状态推送、且主要是单向通知的场景比如任务进度、告警通知、股价变动。WebSocket 适合需要双向实时交互的场景比如聊天、协同编辑、实时白板。很多团队一看到“实时”就上 WebSocket结果维护了心跳、重连、消息顺序、断线补拉一堆逻辑。如果需求只是“订单支付成功以后前端要立刻知道”用 SSE 就够了如果需求是“用户要能实时收到服务端消息也能随时发消息给服务端”再考虑 WebSocket。我个人的选择习惯是需求特征推荐方案状态几秒变一次延迟 1-3 秒可接受轮询带版本号状态变化频繁或不可预测但前端只读SSE前端需要随时给服务端发消息且要低延迟WebSocket担心连接稳定性、断线恢复成本优先轮询或SSE5.3 “无状态服务”的真相状态外置而不是消灭前面已经提过所谓无状态是指服务节点不保存对话上下文所有需要跨请求存在的状态统一放进 Redis、数据库等外部存储。对于全栈工程师来说这个认知直接影响接口怎么写。举个实际例子。很多后端新人会把用户信息放在一个全局变量里比如let currentUser { id: 1, name: 张三, role: admin };因为它是进程内存里的可变状态一旦服务起了多个实例用户请求打到不同实例currentUser 就串了服务重启状态直接丢。这就是典型的“应当无状态却写了状态”的反例。正确做法是接口根据请求头里的 Token 解析出用户身份需要用户信息时从 Redis 或数据库读取缓存可以有一层但缓存也要有统一失效策略。看起来像是把简单问题复杂化了但换来了任意实例都能处理任意请求、滚动发布不丢会话、流量突增可以随时加机器。这个取舍值得。5.4 我实际做任务进度功能时踩过的坑我负责过一个大文件解析的任务解析耗时几分钟前端需要展示“解析中-进度-完成”。第一版我用了全量轮询每个任务进度接口把任务列表里所有字段都返回包括大段的原始文件名和日志摘要前端每次拿到都 setState。上线后两个问题一是浏览器页面卡顿明显二是数据库压力不小因为几百个用户同时轮询同一个接口。后来我改了设计接口只返回“当前用户正在看的任务”里有限几个字段task_id、status、percent、updated_at前端记录lastVersion传给后端后端只在任务状态或进度发生变化时返回完整数据否则返回 304 和空体页面隐藏时暂停轮询回到前台立即拉一次任务结束或失败后前端收到终态立即停止轮询。改完以后接口平均响应从一两百毫秒降到几十毫秒页面也不再因为无关字段的更新而重新渲染。状态同步这种活很多时候不是把它做出来难而是把它做得不打扰用户、不浪费资源才难。6. 从状态设计反推接口语义全栈项目里接口到底该传数据还是传状态6.1 一个常见矛盾前端要页面状态后端要业务数据做中后台系统时前端经常希望接口直接返回“当前步骤是第几步”“这个按钮能不能点”“这个 tab 是否显示”因为省得自己算。后端则倾向于只返回业务事实比如“订单状态码是 30付款时间是什么时候”把“步骤条该停在第几步”交给前端推导。这两种习惯冲突时我倾向的边界是接口传事实前端推导表现状态。原因很简单同一个业务事实在不同端可能呈现完全不同的表现状态。PC 端看到一个完整表格移动端可能只看到卡片这个角色有编辑权限那个角色只读。这些是展示层逻辑应该由前端自己根据事实、设备、权限组合出来。比如获取订单详情的接口后端返回{ orderId: A123, status: PAID, paidAt: 2025-01-01T10:00:00Z, amount: 199.00 }前端拿到 status 和 paidAt 自己决定显示“已支付”标签以及是否展示“申请退款”按钮。后端不需要知道按钮长什么样。一旦后端接口返回了showRefundButton: true这种表现状态前端就失去了灵活调整的空间每次需求调整都得改后端接口。6.2 接口可以传状态但要分清是哪一层状态“接口只传事实”并非绝对。例如 BFFBackend For Frontend层它的定位就是为前端聚合数据专门把多个内部接口的结果加工成前端友好的结构。在 BFF 层返回一些“已经归类好的状态”完全合理因为 BFF 本来就是展示层的前置。但在核心业务接口上我强烈建议保持“事实语义”。一旦核心接口混入 UI 状态比如登录接口返回isLoggedIn支付接口返回showSuccessPage你会陷入没完没了的接口变更里。UI 状态变化太频繁核心接口会跟着腐烂。服务端渲染场景又是另一回事。Next.js/Nuxt 这类全栈框架允许服务端在首屏时直接把初始化状态注入页面此时服务端返回的“状态”本质是给前端 hydration 用的初始值它仍然是事实的投影拿来当缓存用没问题但不能当成唯一的权威数据源。6.3 从操作层面看状态边界会反推接口设计当你明确了某个状态该放哪一层接口的形态通常也就固定了。如果状态属于后端持久化接口就要支持增删改查和校验如果状态属于前端临时态接口甚至都不用存在。很多接口设计之争落实到状态归属上会变得异常清晰。举个搜索页的例子搜索关键词应该放哪里它是用户私有、刷新后通常希望保留的状态按判据二放前端 URL query 参数最合适这样刷新、分享链接都能保留后端不需要为此单独存一份。但如果产品要求“用户历史搜索词云端同步、跨设备可见”那它就从私有状态升级为共享状态必须后端保存前端 URL 只能当临时缓存。状态归属一变接口就要多设计两个。6.4 全栈项目维护一张“状态归属表”会省很多事我在做全栈项目时有个习惯动手写代码前先花半小时整理一张状态矩阵把项目中所有能叫出名字的状态都列一遍。表格长这样状态名所属层生命周期权威来源同步方式边界说明当前登录用户前端会话 后端会话会话期后端 Token/Session登录接口下发前端只存展示副本购物车内容前端显示 后端持久化长期后端购物车服务操作后同步、刷新拉取前端允许乐观更新表单草稿前端本地跨刷新前端 localStorage本地读写不上送后端订单状态后端持久化永久后端订单表轮询/SSE前端仅展示投影按钮 loading前端瞬时毫秒级前端组件无不需要后端参与幂等键前端生成 后端存储分钟到小时后端 Redis/DB请求头传递写操作必须携带这张表有两个作用。第一它让前后端同事面对同一个状态时有一份公认的“所有权声明”减少“我觉得这个状态在前端”的争论。第二它逼着我们把每一个状态都过一遍“谁可见、要不要持久、被篡改的后果”这三个判据状态边界不会只靠拍脑袋决定。我自己在后面的项目里宁可稍微晚点写接口也会先把这张矩阵画出来。状态边界一旦定清楚前后端联调的时间能省掉将近一半。这句话是我踩了无数次状态坑之后才真正信的。