为什么状态管理容易失控
很多前端项目的状态问题,不是因为少用了某个状态库,而是因为一开始没有判断“这个状态应该放在哪里”。
最常见的坏味道是:
- 页面刷新后关键条件丢失
- 多个组件各存一份同样的数据
- 列表筛选、分页、搜索互相覆盖
- 接口返回数据被手动复制到很多地方
- 弹窗、表单、路由参数、缓存混在一个 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,可以问三个问题:
- 它是否被多个远距离组件使用?
- 它是否需要跨页面保留?
- 它是否有明确的初始化、更新和清理策略?
如果答案都是否,就先别放 store。
一套实用决策流程
遇到新状态时,按这个顺序判断:
只影响当前组件?
是 → 组件 ref
否 ↓
需要刷新/分享/回退恢复?
是 → URL query 或 route param
否 ↓
来自服务端且以后端为准?
是 → useAsyncData / 请求缓存 / 查询层
否 ↓
多个页面长期共享?
是 → store
否 → composable 或父组件管理这个流程不能覆盖所有情况,但能避免大部分“上来就全局”的问题。
常见反模式
把 URL 状态藏进 store
列表筛选条件只放 store,用户刷新后全丢。更好的方式是 URL 保存查询条件,store 只保存必要缓存。
把服务端状态当本地状态随便改
直接改缓存里的对象,但没有提交到后端,会造成 UI 和真实数据不一致。
一个 store 管完整个页面
页面所有弹窗、表单、列表、按钮状态都塞进一个 store,最后变成另一个巨型组件。
没有清理策略
离开页面后还保留上一次表单草稿,用户下次进入看到旧数据,很容易误操作。
总结
状态管理的核心不是选择工具,而是判断归属。
一条简单原则:
状态应该放在离使用它最近、又能满足恢复和共享需求的地方。
能局部就局部,需要恢复就进 URL,以后端为准就别复制太多份,真正跨页面共享再进 store。这样设计出来的状态结构会更小、更稳,也更容易排查问题。
下次见。
-EOF-