人人都会AI编程

24.3 状态管理原则:状态最小化、就近原则

更新时间:2026-07-11

在真实的项目迭代中,状态管理最常见的坑不是“用错了 Pinia 的 API”,而是把不该全局的状态放到了全局。这不仅让 Store 变得臃肿难维护,还会引发非预期的跨组件影响和难以追踪的 bug。Vue 的生态提供了从组件内部状态到全局 Store 的完整梯度,合理使用它们依赖两条简单原则:状态最小化就近原则

状态最小化

原则:只把必须在多个组件间共享的状态提升为全局状态,其余一律保留在组件内部。

容易过度全局化的典型场景

// ❌ 不合适:表单的临时输入值放进了全局 Store
// stores/form.js
export const useFormStore = defineStore('form', () => {
  const email = ref('')
  const password = ref('')
  const updateEmail = (val) => { email.value = val }
  const updatePassword = (val) => { password.value = val }
  return { email, password, updateEmail, updatePassword }
})

这个登录表单的邮箱和密码只在 <LoginForm> 组件内部使用,路由跳转后就完全没用了。放进全局 Store 的后果是:内存不会自动释放(除非手动清理),组件销毁了数据依然残留,再次进入表单可能看到上次遗留的输入——这些都不是期望的行为。

正确做法:直接用组件内部的 ref 管理。

<script setup>
const email = ref('')
const password = ref('')
</script>

真正的共享状态通常是:

  • 当前登录用户信息(多个页面、导航栏、请求拦截器都需要)
  • 购物车数据(商品列表页、购物车页、结算页共用)
  • 应用级的全局配置(主题、语言、权限列表)

实用判断标准:如果一个状态只有一个组件关心,它就不应该离开那个组件;如果有多个不互为父子的组件需要读写同一个数据,才考虑提升到共享层。

就近原则

原则:状态应该存放在使用它的所有组件中最近的公共祖先处,而不是一提到“共享”就直接扔进全局 Store。

Vue 提供了多层状态共享机制,按照传播距离从近到远排列如下:

1. 父子通信:Props + Emits
如果数据只在父子间传递,这是最直接的方案,无需任何额外状态层。

<!-- Parent.vue -->
<Child :count="count" @update="count = $event" />

2. 兄弟/跨层级通信:状态提升到共同父组件
当两个兄弟组件需要共享某个选中项,把状态放在它们的父组件中,通过 Props 下发、Emits 上报。

Parent (持有 selectedId)
 ├── LeftPanel (props: selectedId; emits: update)
 └── RightPanel (props: selectedId)

这种方式让状态的生命周期和父组件的挂载/销毁保持同步,不会出现全局 Store 遗留脏数据的问题。

3. 跨层级共享(但不适合 Props 逐层传递):provide / inject
当数据需要从顶层组件传送到深层的多个子组件(如表单的校验状态、主题色),逐层传递 Props 会非常繁琐,此时可用 provide/inject

<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('light')
provide('theme', theme)
</script>

<!-- 任意深度的后代组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>

优点:不需要中间组件的配合,也不用创建全局 Store。缺点:数据流向不够显式,调试时需要借助 DevTools 查看 provide 链。适合固定范围内共享的“上下文”类状态,而非频繁变化的业务数据。

4. 全局共享:Pinia / Vuex
只有状态需要被多种独立模块、不同路由页面读写,且不适合在单一的组件树中决定它的“共同祖先”时,才放进全局 Store。

// stores/user.js
export const useUserStore = defineStore('user', () => {
  const userInfo = ref(null)
  const isLoggedIn = computed(() => !!userInfo.value)
  async function login(credentials) { /* ... */ }
  function logout() { userInfo.value = null }
  return { userInfo, isLoggedIn, login, logout }
})

“用户信息”这样的数据,导航栏、个人中心、请求拦截器都需要它,而且这些组件分散在不同页面,没有明显的共同祖先,此时全局 Store 就是最合理的方案。

落地时的几条实用建议

  • 先局部,后共享:新功能开始先在组件内写状态。当第二个页面/组件确实需要同一个数据时,再重构到最近的共享层。过早抽象是万恶之源。
  • 全局 Store 要模块化:即使使用了 Pinia,也应按业务域拆分 Store(如 useUserStoreuseCartStoreuseConfigStore),不要把整个应用的状态塞在一个大对象里。
  • 注意清理:全局 Store 中暂存的数据(如分页列表、搜索条件)在离开页面时如果需要重置,应在路由离开守卫或组件的 onUnmounted 中调用 Store 的清理方法,避免下次进入时残留。
  • 不要滥用 provide/inject:虽然它很方便,但会让组件与特定上下文强绑定,不利于组件的独立复用。优先用 Props 传递明确的数据,只有在“钻井式”传递确实痛苦时才用它。

总结:状态管理的本质不是“用哪个工具”,而是让每个数据恰好被它能接触到的范围所持有。状态放得越高,灵活性越强但维护成本也越大。从组件内部 ref 到父组件提升,再到 provide/inject,最后才用 Pinia——这是 Vue 提供的一条完整梯度,也是每一个开发者应该养成的本能判断。