Vue状态管理扫盲篇:如何设计一个合理的全局状态树 | 用户、权限、字典、布局配置

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。

一、先搞清楚:什么是全局状态?为什么要设计?

1.1 一句话理解

全局状态:多个页面、多个组件都需要读取和修改的同一份数据,放在一个统一的"仓库"里统一管理。

典型场景:

  • 登录后的用户信息,很多页面都要用
  • 按钮/菜单权限,决定能否显示或操作
  • 字典数据(如性别、状态、类型),下拉框到处用到
  • 侧边栏是否折叠、主题色等布局配置

如果每个组件各自发请求、各自存一份,就会:

  • 重复请求、浪费资源
  • 数据不同步,容易出 bug
  • 刷新页面后数据丢失

把这些数据抽成全局状态树统一管理,可以:

  • 只请求一次,多处复用
  • 单一数据源,更新一致
  • 持久化后刷新也能恢复

1.2 用 Vuex / Pinia 做什么?

  • Vuex:Vue 2 官方状态库,概念多(state、mutation、action、module)。
  • Pinia:Vue 3 官方推荐,API 更简洁,支持 TypeScript。

后面示例会以 Pinia 为主,思路和模块划分同样适用于 Vuex。

二、整体设计思路:按业务域拆分模块

不要把用户、权限、字典、布局全部塞进一个 store 里,建议按业务域拆成模块:

python 复制代码
store/
├── modules/
│   ├── user.ts        # 用户信息
│   ├── permission.ts  # 权限(路由、按钮)
│   ├── dict.ts        # 字典
│   └── layout.ts      # 布局配置
├── index.ts           # 入口,挂载所有模块

原则:

  • 每个模块只负责一类数据
  • 模块间尽量少耦合
  • 跨模块逻辑放在 action 里或单独的 service 中

三、模块一:用户状态(User)

3.1 存什么?

  • 基本信息:idusernamenicknameavataremail
  • 登录态:tokentokenExpire
  • 组织信息(若有多租户):orgIdorgName

3.2 基本结构示例

typescript 复制代码
// store/modules/user.ts
import { defineStore } from 'pinia'

export interface UserInfo {
  id: string
  username: string
  nickname: string
  avatar?: string
  email?: string
  phone?: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '' as string,
    userInfo: null as UserInfo | null,
    // 可选:记录登录时间,用于 token 续期判断
    loginTime: 0 as number
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
    displayName: (state) => state.userInfo?.nickname || state.userInfo?.username || ''
  },

  actions: {
    setToken(token: string) {
      this.token = token
    },
    setUserInfo(info: UserInfo | null) {
      this.userInfo = info
    },
    login(token: string, userInfo: UserInfo) {
      this.token = token
      this.userInfo = userInfo
      this.loginTime = Date.now()
    },
    logout() {
      this.token = ''
      this.userInfo = null
      this.loginTime = 0
    }
  },

  // 持久化:token 和 userInfo 要持久化,刷新后还能用
  persist: {
    key: 'user-store',
    storage: localStorage,
    paths: ['token', 'userInfo']
  }
})

3.3 常见坑点

现象 建议
token 不持久化 刷新页面就掉线 pinia-plugin-persistedstate 等持久化 token
敏感信息进 localStorage token 可能被 XSS 利用 敏感系统用 httpOnly cookie 存 token
userInfo 和 token 不同步 有 token 没 userInfo,或反之 登录时一起设置;退出时一起清空

四、模块二:权限状态(Permission)

4.1 存什么?

  • 路由权限:用户可访问的路由/菜单,用于动态路由和侧边栏
  • 按钮权限 :如 user:adduser:edit,用于控制按钮显隐

4.2 常见后端数据结构(示例)

json 复制代码
// 后端可能返回的权限结构
{
  "menus": [
    {
      "path": "/user",
      "name": "用户管理",
      "children": [
        { "path": "/user/list", "name": "用户列表", "perms": ["user:list"] },
        { "path": "/user/add", "name": "新增用户", "perms": ["user:add"] }
      ]
    }
  ],
  "permissions": ["user:list", "user:add", "user:edit", "user:delete"]
}

4.3 权限 Store 示例

typescript 复制代码
// store/modules/permission.ts
import { defineStore } from 'pinia'

export interface MenuItem {
  path: string
  name: string
  icon?: string
  perms?: string[]
  children?: MenuItem[]
}

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    menus: [] as MenuItem[],
    permissions: [] as string[]  // 扁平化的权限码,用于按钮级控制
  }),

  getters: {
    hasPermission: (state) => (code: string) => {
      // 超级管理员通常拥有所有权限
      if (state.permissions.includes('*')) return true
      return state.permissions.includes(code)
    }
  },

  actions: {
    setMenus(menus: MenuItem[]) {
      this.menus = menus
    },
    setPermissions(perms: string[]) {
      this.permissions = perms
    },
    reset() {
      this.menus = []
      this.permissions = []
    }
  }
})

4.4 按钮级权限:自定义指令

typescript 复制代码
// directives/permission.ts
import type { Directive } from 'vue'
import { usePermissionStore } from '@/store/modules/permission'

export const vPermission: Directive = {
  mounted(el, binding) {
    const permissionStore = usePermissionStore()
    const code = binding.value as string
    if (!code) return
    if (!permissionStore.hasPermission(code)) {
      // 无权限:直接移除 DOM
      el.parentNode?.removeChild(el)
    }
  }
}

// main.ts 或入口文件注册
// app.directive('permission', vPermission)

使用示例:

html 复制代码
<template>
  <el-button v-permission="'user:add'">新增用户</el-button>
  <el-button v-permission="'user:edit'">编辑</el-button>
</template>

4.5 常见坑点

现象 建议
刷新后权限丢失 侧边栏空了或路由 403 登录后把 menus/permissions 持久化,或刷新后重新拉取
指令在 store 未初始化时执行 报错或误判 在路由守卫中确保权限已加载再渲染页面
权限码不统一 前端 user:add 后端 user_add 和后端约定统一格式

五、模块三:字典状态(Dict)

5.1 存什么?

下拉选项、状态文案等:如 gender(男/女)、userStatus(启用/禁用)等。

5.2 设计要点

  • dictType 分组存储
  • 支持懒加载(用到再请求)
  • 适当缓存,减少请求

5.3 字典 Store 示例

typescript 复制代码
// store/modules/dict.ts
import { defineStore } from 'pinia'

export interface DictItem {
  label: string
  value: string | number
  [key: string]: any
}

export const useDictStore = defineStore('dict', {
  state: () => ({
    // dictType -> DictItem[]
    dictMap: {} as Record<string, DictItem[]>,
    // 记录哪些类型已加载过,避免重复请求
    loadedTypes: [] as string[]
  }),

  getters: {
    getDict: (state) => (dictType: string) => {
      return state.dictMap[dictType] || []
    },
    getDictLabel: (state) => (dictType: string, value: string | number) => {
      const list = state.dictMap[dictType] || []
      const item = list.find((d) => d.value === value)
      return item?.label ?? value
    }
  },

  actions: {
    async loadDict(dictType: string) {
      if (this.loadedTypes.includes(dictType)) {
        return this.dictMap[dictType]
      }
      // 实际项目中替换为你的 API 请求
      const res = await fetch(`/api/dict/${dictType}`).then((r) => r.json())
      const list = res.data || []
      this.dictMap[dictType] = list
      this.loadedTypes.push(dictType)
      return list
    },
    setDict(dictType: string, list: DictItem[]) {
      this.dictMap[dictType] = list
      if (!this.loadedTypes.includes(dictType)) {
        this.loadedTypes.push(dictType)
      }
    }
  }
})

5.4 在组件中使用

html 复制代码
<template>
  <el-select v-model="form.gender" placeholder="请选择性别">
    <el-option
      v-for="item in dictStore.getDict('gender')"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
  <!-- 或用于展示:根据 value 显示 label -->
  <span>{{ dictStore.getDictLabel('gender', form.gender) }}</span>
</template>

<script setup lang="ts">
import { useDictStore } from '@/store/modules/dict'
import { onMounted } from 'vue'

const dictStore = useDictStore()

onMounted(async () => {
  await dictStore.loadDict('gender')
})
</script>

5.5 常见坑点

现象 建议
一进来就拉所有字典 首屏慢 按需 loadDict,用到再加载
value 类型不一致 选项选了不显示 统一用 stringnumber,和接口一致
字典更新不生效 改了后台,前端还是旧数据 提供 clearDict(type) 或刷新逻辑,必要时登出清缓存

六、模块四:布局配置(Layout)

6.1 存什么?

侧边栏折叠、主题、标签页、语言等,这类配置通常需要持久化。

6.2 布局 Store 示例

typescript 复制代码
// store/modules/layout.ts
import { defineStore } from 'pinia'

export const useLayoutStore = defineStore('layout', {
  state: () => ({
    sidebarCollapsed: false,
    theme: 'light' as 'light' | 'dark',
    // 多标签页的页面栈(可选)
    visitedViews: [] as { path: string; title: string }[]
  }),

  getters: {
    sidebarWidth: (state) => (state.sidebarCollapsed ? '64px' : '220px')
  },

  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
    },
    setTheme(theme: 'light' | 'dark') {
      this.theme = theme
      document.documentElement.setAttribute('data-theme', theme)
    },
    addVisitedView(route: { path: string; meta?: { title?: string } }) {
      const view = { path: route.path, title: route.meta?.title || '未命名' }
      const exist = this.visitedViews.find((v) => v.path === route.path)
      if (!exist) this.visitedViews.push(view)
    }
  },

  persist: {
    key: 'layout-store',
    storage: localStorage,
    paths: ['sidebarCollapsed', 'theme']
  }
})

6.3 常见坑点

现象 建议
持久化体积过大 visitedViews 太多 只持久化 sidebarCollapsedtheme 等必要字段
主题切换闪烁 先亮后暗 在 HTML 最前面根据存储的 theme 设置 class,或做骨架屏

七、整体挂载与初始化顺序

7.1 Pinia 入口

typescript 复制代码
// store/index.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia

7.2 路由守卫中的初始化流程

typescript 复制代码
// router/index.ts
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.isLoggedIn) {
    // 已登录:确保权限已加载
    if (permissionStore.permissions.length === 0) {
      try {
        await fetchUserPermissions()  // 调用你的接口
        // 动态添加路由...
      } catch (e) {
        userStore.logout()
        next('/login')
        return
      }
    }
    next()
  } else {
    if (to.path === '/login') {
      next()
    } else {
      next('/login')
    }
  }
})

退出登录时建议统一清理:

typescript 复制代码
async function logout() {
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()
  userStore.logout()
  permissionStore.reset()
  router.push('/login')
}

八、小结:一份自检清单

设计全局状态树时,可以按下面检查:

  1. 按业务域拆分:user、permission、dict、layout 等独立模块。
  2. 明确持久化范围:token、userInfo、布局配置要持久化;权限按需持久化或登录后拉取。
  3. 权限与路由联动:登录后加载权限 → 动态路由 → 再渲染页面。
  4. 字典按需加载 :用到再 loadDict,并做好缓存。
  5. 退出时清理:logout 时重置 user、permission 等,避免脏数据。

学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

相关推荐
没想好d1 小时前
通用管理后台组件库-9-高级表格组件
前端
阿虎儿2 小时前
React Hook 入门指南
前端·react.js
阿懂在掘金2 小时前
defineModel 是进步还是边界陷阱?双数据源组件的选择逻辑
vue.js·源码阅读
核以解忧2 小时前
借助VTable Skill实现10W+数据渲染
前端
WangHappy2 小时前
不写 Canvas 也能搞定!小程序图片导出的 WebView 通信方案
前端·微信小程序
李剑一2 小时前
要闹哪样?又出现了一款新的格式化插件,尤雨溪力荐,速度提升了惊人的45倍!
前端·vue.js
闲云一鹤2 小时前
Git LFS 扫盲教程 - 你不会还在用 Git 管理大文件吧?
前端·git·前端工程化
阿虎儿3 小时前
React Context 详解:从入门到性能优化
前端·vue.js·react.js
Sailing3 小时前
🚀 别再乱写 16px 了!CSS 单位体系已经进入“计算时代”,真正的响应式布局
前端·css·面试