返回首页

前端状态设计:从组件状态到 URL、缓存与服务端状态

前端状态管理不是先选 Pinia 或 Redux,而是先判断状态属于哪里。本文用一套分层方法梳理组件状态、URL 状态、客户端缓存和服务端状态的设计边界。

40 约 4 分钟 · 2903 字 前端

为什么状态管理容易失控

很多前端项目的状态问题,不是因为少用了某个状态库,而是因为一开始没有判断“这个状态应该放在哪里”。

最常见的坏味道是:

  • 页面刷新后关键条件丢失
  • 多个组件各存一份同样的数据
  • 列表筛选、分页、搜索互相覆盖
  • 接口返回数据被手动复制到很多地方
  • 弹窗、表单、路由参数、缓存混在一个 store 里
  • 为了共享一个小状态,把整个模块都全局化

状态库能解决“怎么共享”,但不能替你决定“该不该共享”。一个设计不清的状态,放进全局 store 只会让问题扩散得更快。

先按生命周期分类

设计状态前,先问它能活多久。

状态类型 生命周期 例子
临时 UI 状态 当前组件存在期间 弹窗开关、输入框草稿、hover 状态
页面状态 当前页面访问期间 当前 tab、局部筛选条件
URL 状态 可被分享和恢复 搜索关键词、分页、排序、分类
客户端缓存 多页面复用一段时间 用户信息、字典表、工具配置
服务端状态 以后端为准 文章列表、订单详情、权限数据

很多混乱来自把短生命周期状态放得太远。比如一个只控制弹窗显隐的布尔值,通常不需要进全局 store。相反,一个会影响列表查询结果的筛选条件,如果不放进 URL,用户刷新或分享链接时就会丢上下文。

组件状态:能局部就局部

组件状态的原则是:

只有当前组件和它的直接子组件关心的状态,优先留在组件内部。

适合放组件内部的状态:

  • 表单输入草稿
  • 下拉框展开/收起
  • 当前是否 loading
  • 当前是否显示确认弹窗
  • 局部错误提示

示例:

<script setup lang="ts">
const keyword = ref('')
const submitting = ref(false)
const showConfirm = ref(false)
</script>

不要因为“以后可能别的地方也要用”就提前全局化。真正出现第二个使用点时,再考虑抽 composable 或 store。

URL 状态:能被分享的就放 URL

URL 状态经常被低估。只要一个状态影响页面内容,并且用户可能希望刷新、收藏或分享,它就应该考虑进入 URL。

适合放 URL 的状态:

  • 搜索关键词
  • 当前页码
  • 排序字段
  • 分类、标签、状态筛选
  • 日期范围

例如后台列表页:

/admin/posts?keyword=nuxt&status=published&page=2

这样做的好处:

  • 刷新页面不丢筛选条件
  • 用户可以复制链接给别人
  • 浏览器前进后退能恢复状态
  • 调试问题时可以直接复现现场

需要注意的是,URL 不是垃圾桶。不要把大对象、敏感信息、临时草稿放进去。URL 状态应该短、稳定、可序列化。

客户端缓存:不是服务端事实源

客户端缓存适合保存“短时间内可以复用,但最终仍以后端为准”的数据。

例如:

  • 当前登录用户信息
  • 角色权限列表
  • 菜单配置
  • 字典项
  • 最近一次列表查询结果

缓存的关键不是“存起来”,而是定义失效策略:

  • 什么时候重新拉取?
  • 用户切换身份时是否清空?
  • 修改成功后是乐观更新还是重新请求?
  • 多个页面依赖同一份缓存时,谁负责刷新?

如果没有失效策略,缓存迟早会变成脏数据。

服务端状态:不要复制太多份

服务端状态是以后端数据为准的状态。前端可以缓存它,但不应该随意复制和改写它。

坏例子:

const rawPost = ref<Post>()
const postTitle = ref('')
const postTags = ref<string[]>([])

// 接口返回后手动拆散
postTitle.value = rawPost.value.title
postTags.value = rawPost.value.tags

如果后续 rawPost 更新,拆出来的字段可能不同步。

更好的做法:

const post = ref<Post | null>(null)
const postTitle = computed(() => post.value?.title ?? '')

只有在“编辑草稿”场景下,才需要把服务端状态复制成表单状态。此时要明确这是 draft,保存成功后再刷新服务端状态。

Store 的使用边界

全局 store 适合这些场景:

  • 多个页面都要用的用户信息
  • 权限、主题、语言等应用级状态
  • 跨组件共享且不适合 URL 表达的状态
  • 需要集中管理的客户端缓存

不适合:

  • 单个弹窗开关
  • 单个页面的表单草稿
  • 一次性请求结果
  • 只为了少传一层 props 的状态

判断一个状态是否进 store,可以问三个问题:

  1. 它是否被多个远距离组件使用?
  2. 它是否需要跨页面保留?
  3. 它是否有明确的初始化、更新和清理策略?

如果答案都是否,就先别放 store。

一套实用决策流程

遇到新状态时,按这个顺序判断:

只影响当前组件?
  是 → 组件 ref
  否 ↓
需要刷新/分享/回退恢复?
  是 → URL query 或 route param
  否 ↓
来自服务端且以后端为准?
  是 → useAsyncData / 请求缓存 / 查询层
  否 ↓
多个页面长期共享?
  是 → store
  否 → composable 或父组件管理

这个流程不能覆盖所有情况,但能避免大部分“上来就全局”的问题。

常见反模式

把 URL 状态藏进 store

列表筛选条件只放 store,用户刷新后全丢。更好的方式是 URL 保存查询条件,store 只保存必要缓存。

把服务端状态当本地状态随便改

直接改缓存里的对象,但没有提交到后端,会造成 UI 和真实数据不一致。

一个 store 管完整个页面

页面所有弹窗、表单、列表、按钮状态都塞进一个 store,最后变成另一个巨型组件。

没有清理策略

离开页面后还保留上一次表单草稿,用户下次进入看到旧数据,很容易误操作。

总结

状态管理的核心不是选择工具,而是判断归属。

一条简单原则:

状态应该放在离使用它最近、又能满足恢复和共享需求的地方。

能局部就局部,需要恢复就进 URL,以后端为准就别复制太多份,真正跨页面共享再进 store。这样设计出来的状态结构会更小、更稳,也更容易排查问题。


下次见。

-EOF-