组件局部状态与全局状态如何划分边界?哪些数据坚决不该放进 Redux / Pinia?
在大型前端项目中,最常见的反模式是**“把所有状态一股脑塞进全局 Store(Redux / Pinia / Zustand)”**,导致组件失去独立复用能力、单测难以编写且引发大面积无意义重渲染。科学划分状态边界需要遵循 状态就近原则(State Colocation) 与 五维状态分类法。
一、 前端五大状态类型与归属位置决策表
| 状态类型 | 典型数据举例 | 推荐归属位置与工具 | 为什么不该放进全局客户端 Store? |
|---|---|---|---|
| 1. UI 局部交互状态 | 弹窗开关 isOpen、下拉菜单展开、输入框临时草稿、当前悬停项 |
组件内部 useState / ref(或提升至最近公共父组件) |
仅单个组件或父子组件关心,放全局会导致弹窗关闭后状态忘记重置(脏状态残留) |
| 2. URL 路由与筛选状态 | 搜索关键词 ?q=、分页页码 ?page=2、当前激活 Tab、排序字段 |
URL Search Params(useSearchParams / useRoute().query) |
放内存 Store 会导致用户刷新页面或复制链接发给同事时筛选条件全部丢失! |
| 3. 服务端远程缓存状态 | 商品列表、文章详情、订单记录等来自后端 API 的异步数据 | TanStack Query (React Query) / SWR / Nuxt useFetch |
手动在 Redux/Pinia 里写 loading/error/data 样板代码繁琐,且缺乏缓存过期(TTL)、竞态取消、窗口聚焦自动刷新能力 |
| 4. 派生/计算状态 | 购物车已选商品总价、过滤后的列表计数、表单是否可提交 | useMemo / Vue computed / Store Selector |
严禁将派生数据存为独立状态!双源同步极易引发数据不一致 Bug |
| 5. 真正的全局客户端状态 | 当前登录用户信息、亮暗主题色、多语言 Locale、跨页面购物车草稿、全局音乐播放器进度 | Zustand / Pinia / Redux Toolkit | 跨多个不相邻路由页面共享,且生命周期与整个应用会话一致 |
二、 状态就近下沉(State Colocation)性能优化代码对比
// ✗ 反例:将高频变化的局部输入框状态放在顶层或全局 Store,导致整棵页面树每敲一个键都重渲染
function PageBad() {
const [ draft, setDraft ] = useGlobalStore((s) => [s.draft, s.setDraft])
return (
<div>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<VerySlowChart /> {/* 无辜受累:每次敲键盘都跟着重渲染! */}
</div>
)
}
// ✔ 正例:状态就近下沉(Colocation)到最小叶子组件,或把 URL 状态交给 SearchParams
function SearchInputLeaf({ onSubmit }) {
const [draft, setDraft] = useState('')
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit(draft) }}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
</form>
)
}