组件局部状态与全局状态如何划分边界?哪些数据坚决不该放进 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>
  )
}

同分类其他题目