ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Vuex 4 状态管理全解析:核心原理、模块拆分与高频踩坑

Vuex 4 状态管理全解析:核心原理、模块拆分与高频踩坑 Vuex 这玩意儿现在提起来有点微妙。新项目里 Pinia 基本是默认选项很多人一上手 Vue 3 就没碰过 Vuex。但你去翻一翻手上维护的那些跑了三四年的中后台系统十有八九还在store/index.js里躺着几十个 mutation改一个字段要先找 action、再找 mutation、最后才敢动 state。这时候你要是连 Vuex 的执行链路都说不清楚排查问题基本靠猜。这篇就把 Vuex 的知识点掰开揉碎讲透从“到底什么时候该用它”到“store 怎么拆模块”再到“为什么我的 commit 没生效”这类真实踩坑全部过一遍。适合两类人看一类是刚接手老项目、被mapState和各种root: true绕晕的新手另一类是想把状态管理这块彻底搞清楚、以后不管换 Pinia 还是别的库都能快速上手的同学。代码以 Vuex 4 Vue 3 为主线涉及 Vue 2 的差异我会单独标出来。1. 先搞清楚什么时候真的需要 Vuex1.1 状态管理的边界到底在哪我在带人的时候发现一个特别普遍的问题很多人学完 Vuex 的第一反应是“好东西全塞进去”。结果一个只有三个页面的小工具store 里堆了二十多个字段其中一半是某个组件内部的弹窗开关。这就属于把状态管理当成了全局变量仓库最后维护成本比不用还高。判断一个状态该不该进 store我一般看三条第一这份数据是不是被两个以上互不相邻的组件同时使用。父子组件之间传值props加emit就够了爷孙层级深一点provide/inject也能解决。只有当兄弟组件、或者跨了好几层的远房亲戚都要读写同一份数据时才轮到 Vuex 出场。第二这份数据是不是跨越了路由。比如登录态、用户信息、权限菜单从 A 页面跳到 B 页面还得用这种放在组件里必然会随着组件销毁而丢失必须提上去。第三这份数据变更后是不是需要留下可追溯的痕迹。Vuex 配 devtools 能记录每一次 mutation 的时间旅行对于复杂的表单联动、购物车增删改这个调试能力值不少时间。反过来说哪些不该进 store组件的 loading 状态、某个列表的滚动位置、弹窗的显隐开关这些都属于“局部状态”跟着组件生命周期走才是最干净的。我见过有人把dialogVisible放进 store结果两个页面复用同一个弹窗组件时互相干扰排查了半天才发现是状态被共享了。记住一句话状态放得越高耦合就越重能不升就不升。还有一种常见误区是把接口返回的原始数据全量塞进 store然后组件里再filter一遍。这样做的问题在于 store 变成了缓存层但你又没做失效策略最后数据新旧不一致的 bug 特别难查。我的习惯是 store 只存“经过业务加工、有明确语义”的状态原始响应体在 action 里处理完就丢掉。1.2 Vuex 和 Pinia 到底怎么选这个话题绕不开。Pinia 确实是官方现在主推的方案API 更简洁、TypeScript 支持更好、没有 mutation 这一层。但“新项目用 Pinia”不等于“Vuex 就没用了”。我整理了一张对照表你对着自己项目的情况看就行。对比维度Vuex 4Pinia变更入口mutation 提交action 触发直接改 stateaction 里也能改模块机制modules namespaced 嵌套每个 store 天然独立TypeScript 支持需要额外类型声明较繁琐原生推导几乎零配置devtools 时间旅行支持粒度到 mutation支持粒度到 action代码量一个功能要写 state/getter/mutation/action 四份基本一份 setup 或 option 就够学习曲线概念多链路长概念少接近组合式函数存量项目兼容原生可与 Vuex 共存逐模块迁移适合场景老项目维护、团队已有约定新项目、TS 重度使用我的实际经验是已经在跑的 Vuex 项目别为了追新去动它。迁移本身不难难的是迁移期间两套状态管理并存带来的心智负担以及测试覆盖不足时的回归风险。真要迁也是按模块一个个搬先把用户模块搬过去跑一周没问题再搬下一个。反倒是新起的项目没有历史包袱直接上 Pinia 更省事。不过有一点要说清楚Vuex 那套“mutation 必须同步、action 处理异步”的约束虽然写起来啰嗦但它强迫你把“改数据”和“做业务”分开这个思维在团队协作里是有价值的。很多 Pinia 新手直接在 action 里一顿this.xxx yyy出了问题连是哪一步改的都定位不到。所以就算你用的是 Pinia也建议保留“纯函数式改状态”的习惯。1.3 一张判断清单帮你决定要不要引入我给自己团队写过一个很土但很好用的清单五个问题中三个以上才考虑上 Vuex有没有一份数据被三个以上的组件读取且这些组件不在同一条父子链上这份数据会不会因为路由切换而需要保留这份数据的修改是否需要多人协作、且改动点分散在多个文件出问题时是否需要回放“谁在什么时候改了什么”项目规模是不是已经超过二十个页面或者五个以上的业务模块如果只中一两条老老实实用props加组合式函数封装别给自己找事。状态管理的本质是降低沟通成本不是炫技。2. Vuex 的五个核心概念一次捋直2.1 state单一状态树和它的设计原则Vuex 把所有状态收拢到一棵树里官方叫单一状态树Single Source of Truth。好处是任何一个时刻你都能通过store.state拿到应用完整的数据快照调试的时候特别直观。坏处是这棵树如果没人管长到几百个字段之后新人进来根本看不懂哪个字段属于哪个业务。我的做法是按业务域切分而不是按数据类型切分。什么叫按数据类型切就是state.list、state.detail、state.loading这么平铺看着整齐实际上一旦业务变多就全乱。按业务域切就是user、order、permission各占一块每个模块自洽。具体到写法Vuex 4 里定义模块的 state 一定要用函数返回对象// store/modules/user.js const state () ({ profile: null, token: , roles: [], permissionLoaded: false })为什么必须是函数因为模块在注册时会被合并进根 state如果直接写对象字面量多个组件实例或者热更新场景下会共享同一份引用出现“A 页面改了 B 页面也变了”的灵异事件。这个坑我在服务端渲染的项目里踩过一次排查了整整一个下午。另外 state 里的字段命名要带语义。data、info、list这种词尽量别用orderList、userProfile、pendingTaskCount一眼就知道是什么。字段一多命名就是最好的文档。2.2 getters派生状态和它的缓存机制getters 可以理解为 store 里的 computed。它的返回值会基于依赖做缓存依赖不变就不重新计算。这个特性用好了能省不少性能用不好反而会成为 bug 来源。const getters { isLogin: state Boolean(state.token), displayName: state state.profile?.nickname || state.profile?.username || 未登录, // 依赖其他 getter第二个参数是当前模块的 getters welcomeText: (state, getters) ${getters.displayName}欢迎回来, // 依赖根 state第四个参数是 rootState hasPermission: (state, getters, rootState) code { return state.roles.includes(code) || rootState.user.roles.includes(code) } }这里有个关键点getter 返回函数时缓存就失效了。上面hasPermission返回的是一个函数每次调用都会重新执行因为 Vue 没法追踪函数内部用到的参数。这种写法适合做权限判断这类需要传参的场景但别在模板里高频调用它尤其是列表渲染里v-for每一项都调一次性能会明显下降。真要这么用先在 computed 里把结果算好。还有一个容易翻车的地方getter 里返回新对象。比如// 不推荐每次都返回新数组缓存形同虚设 filteredList: state state.list.filter(item item.active)因为filter每次执行都会产生新数组引用Vue 的依赖追踪会认为结果变了导致依赖这个 getter 的组件反复更新。如果这个列表很大、更新又频繁页面会明显卡顿。正确做法是把过滤逻辑放到组件 computed 里或者用shallowRef之类的思路做手动缓存。我在一个万级数据的表格项目里就吃过这个亏后来把 getter 改成了只做简单映射流畅度立刻回来了。2.3 mutations为什么它必须是同步的这是 Vuex 里被问得最多的问题。“我就在 mutation 里发个请求怎么了”答案藏在 devtools 的工作原理里。devtools 记录状态变化的方式是每执行一次 mutation就拍一张 state 的快照存进时间线。如果你在 mutation 里发异步请求那么“mutation 开始执行”和“state 真正被改”之间存在时间差devtools 拍到的那张快照可能是请求还没回来的中间态。等你点时间旅行回到某一步看到的状态和实际不符调试就彻底失去了意义。所以 Vuex 强制 mutation 同步是为了保证每一帧快照都是确定性的。const mutations { SET_PROFILE(state, payload) { state.profile payload }, SET_TOKEN(state, token) { state.token token }, ADD_ROLE(state, role) { if (!state.roles.includes(role)) { state.roles.push(role) } }, RESET(state) { // 重置时不要直接替换整个 state 对象Vue 会丢失响应式追踪 Object.assign(state, { profile: null, token: , roles: [], permissionLoaded: false }) } }mutation 的命名我统一用全大写加下划线这是社区惯例好处是在 devtools 列表里一眼就能和 action 区分开。action 用小驼峰比如fetchProfile。这套约定看起来是小事但团队人多的时候能省掉很多沟通。还有一个细节mutation 里不要做副作用。什么叫副作用发请求、写 localStorage、弹 toast这些都算。mutation 应该是一个纯函数给它同样的输入state 就变成同样的结果。这样你写单元测试的时候只需要断言 state 变化不用 mock 一堆东西。2.4 actions异步编排和业务逻辑的落脚点action 是 Vuex 里唯一允许写异步的地方它不直接改 state而是通过commit触发 mutation。这个设计看起来多绕了一层实际上把“业务流程”和“数据变更”解耦了。const actions { async fetchProfile({ commit, state, dispatch }, force false) { if (state.profile !force) { return state.profile } const res await api.getUserProfile() commit(SET_PROFILE, res.data) // 跨模块调用要加 root: true await dispatch(permission/loadRoutes, res.data.roles, { root: true }) return res.data }, async login({ commit, dispatch }, { username, password }) { const { token } await api.login({ username, password }) commit(SET_TOKEN, token) await dispatch(fetchProfile, true) }, logout({ commit }) { commit(RESET) commit(permission/RESET, null, { root: true }) } }action 的返回值很重要。因为 action 内部是 Promise你可以在组件里await store.dispatch(login, form)拿到结果后再做跳转或者提示。很多老代码里 action 不 return组件只能靠 watch 某个 state 来判断成功与否这种写法非常脆弱。养成习惯action 有结果就 return 出去。跨模块 dispatch 的{ root: true }是新手最容易漏的参数。不加这个参数Vuex 会只在当前模块内查找 action找不到就静默失败或者报unknown action type。我第一次遇到的时候盯着代码看了半小时最后发现就是少了个 root。2.5 modules 和 namespaced拆分与隔离项目一大单文件 store 必然爆炸。modules 就是用来拆的而namespaced: true决定了拆完之后彼此是否隔离。// store/index.js import { createStore } from vuex import user from ./modules/user import permission from ./modules/permission import cart from ./modules/cart export default createStore({ strict: import.meta.env.DEV, modules: { user: { namespaced: true, ...user }, permission: { namespaced: true, ...permission }, cart: { namespaced: true, ...cart } } })开不开命名空间我的答案是业务模块一律开。不开的话所有模块的 mutation 都注册在全局两个模块都定义了SET_LOADING后注册的会覆盖前一个这种 bug 隐蔽性极强。开了之后方法名变成user/SET_LOADING虽然写起来长一点但边界清晰。全局的通用状态比如 app 级别的主题、语言可以单开一个不开命名空间的 root 模块但数量要克制。嵌套模块的调用链路也要理清楚。假设有个setting模块挂在user下面那么在setting的 action 里调用兄弟模块路径要写成dispatch(cart/addItem, item, { root: true })。这个 root 参数指的是“从根开始找”不管你现在在哪一层。记不住的话就记一句只要跨出当前模块就带 root: true。3. 从零搭一个能落地的 Store 结构3.1 目录设计和模块拆分原则先看目录这是我用了几年、觉得最不容易乱的一种src/ ├── store/ │ ├── index.js // 入口注册模块和插件 │ ├── plugins/ │ │ └── persist.js // 持久化插件 │ ├── modules/ │ │ ├── user.js // 用户信息、登录态 │ │ ├── permission.js // 路由权限、菜单 │ │ └── cart.js // 业务模块示例 │ └── helpers.js // 通用工具比如生成 mutation types拆分粒度怎么定我的一般原则是一个模块对应一个业务域而不是一个页面。比如订单列表和订单详情都属于 order 模块不要拆成两个。反过来用户和权限虽然是相关的但职责不同用户管“我是谁”权限管“我能看什么”拆开更好维护。模块内部的结构我习惯按state → getters → mutations → actions → 导出的顺序写和 Vuex 的执行链路一致读代码的人从上往下看正好是数据流向。文件末尾统一导出export default { namespaced: true, state, getters, mutations, actions }有一点要注意如果模块之间有数据依赖不要在模块文件里直接import另一个模块的 state那样会造成循环依赖。正确方式是通过rootState读取或者用dispatch让对方的 action 来处理。3.2 完整实现一个购物车模块光说概念没意思直接上一个能跑的。假设我们要做一个购物车需求是加商品、改数量、删除、算总价、选中状态管理。// store/modules/cart.js const state () ({ items: [], // { id, name, price, count, checked } submitting: false }) const getters { totalCount: state state.items.reduce((sum, i) sum i.count, 0), checkedItems: state state.items.filter(i i.checked), totalPrice: (state, getters) { // 金额用分做单位避免浮点误差最后再除以 100 const cents getters.checkedItems.reduce((sum, i) sum i.price * 100 * i.count, 0) return cents / 100 }, isAllChecked: state state.items.length 0 state.items.every(i i.checked) } const mutations { ADD_ITEM(state, product) { const exist state.items.find(i i.id product.id) if (exist) { exist.count 1 } else { state.items.push({ ...product, count: 1, checked: true }) } }, UPDATE_COUNT(state, { id, count }) { const item state.items.find(i i.id id) if (item) { item.count Math.max(1, Math.min(99, count)) } }, TOGGLE_CHECK(state, id) { const item state.items.find(i i.id id) if (item) item.checked !item.checked }, TOGGLE_ALL(state, checked) { state.items.forEach(i { i.checked checked }) }, REMOVE_ITEM(state, id) { state.items state.items.filter(i i.id ! id) }, SET_SUBMITTING(state, flag) { state.submitting flag }, CLEAR(state) { state.items [] } } const actions { async checkout({ commit, state, rootState }) { if (!state.items.length) return { ok: false, msg: 购物车是空的 } const payload state.items .filter(i i.checked) .map(i ({ productId: i.id, count: i.count })) commit(SET_SUBMITTING, true) try { const res await api.submitOrder({ items: payload, userId: rootState.user.profile?.id }) commit(REMOVE_ITEM_BATCH, res.data.removedIds) return { ok: true, orderId: res.data.orderId } } catch (e) { return { ok: false, msg: e.message } } finally { commit(SET_SUBMITTING, false) } } } export default { namespaced: true, state, getters, mutations, actions }这里有几个我自己踩过坑之后固定下来的写法。金额全部用“分”来算最后展示时再转回元0.1 0.2那种经典浮点问题在电商场景真的会出事。数量做了Math.max(1, Math.min(99, count))的钳制防止用户在输入框里手打负号或者超大批量。checkout的返回值统一成{ ok, msg }结构组件层不用 try-catch直接看ok就行。另外REMOVE_ITEM_BATCH我在 mutations 里没写实际项目中应该补上避免循环提交单条删除。这类细节在真实业务里会因为批量操作导致几十次 commitdevtools 面板直接被刷屏。3.3 组件里怎么调用四种方式对比Vuex 提供的调用方式有好几种很多人只会mapState遇到方法就不知道咋办了。列个表对照着记场景选项式写法组合式写法setup读 statecomputed(() this.$store.state.user.profile)const profile computed(() store.state.user.profile)读 getterthis.$store.getters[user/isLogin]computed(() store.getters[user/isLogin])触发 mutationthis.$store.commit(user/SET_TOKEN, t)store.commit(user/SET_TOKEN, t)触发 actionawait this.$store.dispatch(user/login, f)await store.dispatch(user/login, f)批量映射...mapState(user, [profile])storeToRefs(store.state.user)配合解构组合式 API 里推荐用useStore加storeToRefsimport { computed } from vue import { useStore } from vuex import { storeToRefs } from pinia // 注意Vuex 没有官方 storeToRefs这里用 toRefs 替代 export default { setup() { const store useStore() // 直接解构会丢失响应式必须转成 ref const { profile, token } toRefs(store.state.user) const isLogin computed(() store.getters[user/isLogin]) function handleAdd(product) { store.commit(cart/ADD_ITEM, product) } return { profile, token, isLogin, handleAdd } } }补充一句上面代码里storeToRefs是 Pinia 的 APIVuex 项目要用 Vue 的toRefs配合store.state.模块名。这个区别我见过不止一个人写混。toRefs只能处理普通响应式对象store.state本身就是响应式的所以能用但store.getters不行因为 getters 是每次访问时动态计算的得用computed包一层。选项式 API 里用辅助函数会简洁很多import { mapState, mapGetters, mapActions } from vuex export default { computed: { ...mapState(cart, [items, submitting]), ...mapGetters(cart, [totalPrice, isAllChecked]), // 自己再算的派生值 isEmpty() { return this.items.length 0 } }, methods: { ...mapActions(cart, [checkout]), async onSubmit() { const res await this.checkout() if (res.ok) { // 跳转逻辑 } } } }有个小坑要提醒mapState映射出来的名字如果和组件data里的字段重名Vue 会告警而且行为不确定。命名的时候尽量错开或者在映射时用对象形式重命名...mapState(cart, { cartItems: items })。3.4 严格模式、持久化插件和 devtools 配置严格模式我强烈建议只在开发环境开const store createStore({ strict: import.meta.env.DEV, modules: { ... } })严格模式下任何不经过 mutation 的 state 修改都会抛错[vuex] do not mutate vuex store state outside mutation handlers。这个检查很耗性能因为它需要深度监听整棵 state 树。生产环境开严格模式在大型应用里能明显感觉到卡顿。用 Vite 的话import.meta.env.DEV就是现成的webpack 环境用process.env.NODE_ENV ! production。持久化这块Vuex 本身不提供社区有插件但也都不太维护了我一般手写一个三十行的插件// store/plugins/persist.js const KEY APP_STORE_V1 export default function createPersistPlugin(paths []) { return store { // 恢复 const raw localStorage.getItem(KEY) if (raw) { try { const saved JSON.parse(raw) paths.forEach(path { const [module, field] path.split(/) const target module ? store.state[module] : store.state if (saved[path] ! undefined) { target[field] saved[path] } }) } catch (e) { localStorage.removeItem(KEY) } } // 保存做节流避免频繁写入 let timer null store.subscribe((mutation, state) { clearTimeout(timer) timer setTimeout(() { const data {} paths.forEach(path { const [module, field] path.split(/) const target module ? state[module] : state data[path] target?.[field] }) localStorage.setItem(KEY, JSON.stringify(data)) }, 300) }) } }用它的时候只白名单需要的字段createStore({ plugins: [createPersistPlugin([user/token, cart/items])] })为什么只白名单而不是全量存因为submitting、loading这类瞬时状态被持久化下来刷新页面后按钮会一直处于 loading 状态用户以为卡死了。我见过一个项目把整个 store 都存了 localStorage结果用户重新打开页面上次没提交完的表单数据混着新数据出现问题反馈了一堆。4. 高频踩坑与排查实录4.1 报错信息对照表先会看再看懂Vuex 的报错信息其实写得挺清楚只是新手不知道往哪查。整理一张表遇到问题先对号入座报错内容大概率原因处理方式[vuex] unknown mutation type: xxx模块没开 namespaced或漏写模块前缀检查模块配置commit 时补上模块名/[vuex] unknown action type: xxx跨模块调用漏了{ root: true }dispatch 第三个参数加上 root[vuex] unknown getter: xxxgetters 没导出或访问路径错确认 export default 里有 gettersdo not mutate vuex store state outside mutation handlers开发环境严格模式直接改了 state把所有赋值搬进 mutation[vuex] module namespace not found in mapState()模块名拼错或模块还没注册检查 createStore 的 modules 键名Cannot read property xxx of undefined模块 state 写成了对象但被复用state 改成函数返回数据改了但页面没更新直接改数组下标或对象新增属性用 mutation 里整体替换或Vue.setVue 2这张表我自己贴在工位旁边贴了两年新人来的时候直接拍照。说实话Vuex 报错里 80% 都是“路径写错”或者“没开命名空间”这两类剩下 20% 是响应式丢失。4.2 响应式丢失的几种典型情况Vue 2 时代最经典的问题state.list[0] newItem页面不更新。原因是Object.defineProperty拦截不到数组下标赋值。Vue 3 换成 Proxy 之后这个问题基本解决了但如果你维护的是 Vue 2 Vuex 3 的项目还是得注意数组按下标赋值用splice或者整体替换数组对象新增属性用Vue.set(obj, key, val)删除属性用Vue.delete(obj, key)Vue 3 里虽然 Proxy 能拦截新增和删除但还是有两个容易忽略的点。一是reactive对象被解构之后失去响应式比如const { profile } store.state.user后面profile就是普通对象了怎么改页面都不动。二是把 state 里的对象直接赋给组件里的变量做双向绑定v-model一改就直接改了 state绕过 mutation开发环境立刻报严格模式错误。正确的v-model写法是这样computed: { keyword: { get() { return this.$store.state.cart.keyword }, set(val) { this.$store.commit(cart/SET_KEYWORD, val) } } }啰嗦是啰嗦但这条链路是完整的devtools 能记录到每一次输入。如果嫌麻烦就在组件内用本地 ref提交时再一次性写回 store。4.3 性能问题什么时候 store 成了瓶颈Vuex 的性能问题我遇到过的就那么几类但每一类都很要命。第一类是大列表全量放进 store。有个项目把五千条订单数据放在 state 里每次筛选都要经过 getter页面卡得没法用。后来改成 store 只存筛选条件列表数据留在组件里通过 action 拿问题立刻解决。记住store 适合存“状态”不适合存“数据集合”。第二类是在 getter 里做重计算。比如对列表做排序、格式化日期、算分组小计这些操作如果依赖频繁变化getter 会被反复触发。我的做法是把这类计算移到组件 computed或者在 store 里缓存一份计算结果只在源数据变化时重算。第三类是过度订阅。mapState把整个模块的 state 都映射进来其中某个字段高频变化导致组件整体重新渲染。用computed精确取需要的字段能缓解一部分更好的办法是把高频变化的状态比如输入框的值、鼠标坐标留在组件本地不要进 store。还有一种不太容易发现的devtools 开着跑生产构建。Vuex 的 devtools 集成在开发模式下才有但如果你不小心在生产包里引入了开发版本的 Vue性能会差好几倍。打包配置里确认一下mode和define有没有问题这个坑我踩过一次找了一周。4.4 老项目迁移与工程化建议如果手上的 Vuex 项目要重构我建议按这个顺序推进别一把梭。第一步先把所有this.$store的直接调用收敛成统一的 API 层。写一个src/store/api.js把常用的读写封装成函数比如useUser()、useCart()。这一步不改逻辑只是加一层壳风险极低。第二步把模块的 mutation 命名规范化。原来可能叫setUser、updateUser、changeUser统一成SET_USER。这一步靠grep加人工核对工作量不大但收益明显后面迁移 Pinia 时一一对应就能改。第三步按模块迁移。先挑一个依赖最少的模块比如cart改成 Pinia 的defineStore然后在入口同时注册两套。Vuex 和 Pinia 是可以共存的只要别让同一个业务状态两边都存就行。第四步清理。所有模块迁完之后删掉 Vuex 依赖把mapState之类的 import 全部替换。这一步用 ESLint 的自定义规则能扫出一部分剩下的靠编译报错兜底。迁移过程中有个细节值得说Pinia 的storeToRefs和 Vuex 的toRefs行为不完全一样直接在组件里解构很容易出问题。我当时的做法是先把所有组件里的解构都改成computed迁完之后再按需优化。宁可多写几行也别在响应式上翻车。5. 几个真实场景下的套路组合5.1 登录态与路由守卫的配合登录态是最典型的需要 store 的场景但它和路由守卫的配合有个时序问题必须处理。守卫在页面渲染前执行这时候如果 store 里的用户信息还没加载权限判断就会失败用户被误踢回登录页。我的处理方式是token 从 localStorage 同步恢复用户信息异步加载但守卫里做“是否已加载”的判断没加载就先触发 action 并等待。// permission.js router.beforeEach(async (to, from, next) { const store useStore() const token store.state.user.token if (!token) { return to.meta.public ? next() : next({ path: /login, query: { redirect: to.fullPath } }) } if (!store.state.user.permissionLoaded) { try { await store.dispatch(user/fetchProfile) await store.dispatch(permission/loadRoutes) } catch (e) { store.commit(user/RESET) return next(/login) } } if (to.meta.roles !store.getters[user/hasPermission](to.meta.roles)) { return next(/403) } next() })这里的关键是permissionLoaded这个标记位。没有它每次路由跳转都要重新拉一次用户信息接口压力大不说页面还会闪。有了它第一次加载完之后所有跳转都是同步判断体验就顺了。异步路由用router.addRoute动态注册注册完再next({ ...to, replace: true })重新进入一次否则会白屏。5.2 跨模块协作别让模块之间互相 import项目一大模块之间必然有协作。比如下单成功后要清空购物车、刷新订单列表、更新用户积分。这三件事分属三个模块怎么串起来最差的做法是在order模块里import cart from ./cart然后直接调。这样写会形成循环依赖打包时可能报错运行时也可能拿到 undefined。正确做法是通过 action 互相 dispatch// order 模块的 action async createOrder({ commit, dispatch }, payload) { const res await api.createOrder(payload) commit(ADD_ORDER, res.data) // 跨模块通知各自处理各自的逻辑 await dispatch(cart/clearChecked, null, { root: true }) await dispatch(user/refreshPoints, null, { root: true }) return res.data }这样order模块不需要知道cart内部怎么实现只要知道有这么个 action 就行。耦合度低测试也好写。如果模块数量多、协作关系复杂可以再引入一个“编排层”专门写跨模块的 action把业务流程集中在一处。还有个小技巧跨模块读取数据用 getters不要直接读rootState.xxx.yyy。getters 是一层抽象将来内部结构变了只要 getter 的返回不变调用方就不用改。5.3 表单草稿和列表缓存的取舍后台系统里最常见的两个需求表单填一半切走了回来要恢复列表翻到第五页点进详情再返回要停在第五页。表单草稿我的做法是不进 store用sessionStorage加组件内的watch做防抖保存。原因很简单草稿是页面级的状态多个表单同时编辑时放 store 会互相干扰还要处理各种清理逻辑。sessionStorage 天然按标签页隔离关掉就没了符合直觉。列表缓存反而适合进 store。因为筛选条件、页码、排序方式这些是需要跨路由保留的而且详情页返回时要用。做法是在list模块里存一份query对象列表页onMounted时读出来发起请求条件变化时同步更新。要注意的是从菜单重新点进列表时应该重置从详情返回时应该保留这个区分靠路由的meta.keepAlive或者判断from路径来做。const actions { async loadList({ state, commit }, { reset false } {}) { if (reset) { commit(RESET_QUERY) } const res await api.getList(state.query) commit(SET_LIST, res.data) } }reset参数由调用方决定模块本身不猜。这种“让调用方明确表达意图”的设计比在模块里写一堆 if 判断当前路由要清晰得多。6. 我个人在实际项目里的几点体会写到这里Vuex 的主要知识点基本都覆盖了。最后分享几个不太适合写进文档、但实际干活时确实管用的经验。第一别怕写重复代码。Vuex 的四段式state/getters/mutations/actions看起来很啰嗦但每多写一行将来排查问题时就少猜一次。我曾经为了省事把 mutation 和 action 合并成一个函数结果 devtools 里全是匿名调用出问题时完全不知道是谁改的。后来老老实实拆开调试效率反而高了。第二给 store 加注释比加文档有用。每个模块文件顶部写清楚这个模块管什么、依赖谁、被谁依赖比单独维护一份文档靠谱得多。文档会过期代码不会。我现在接手新项目第一件事就是看 store 模块的顶部注释五分钟就能摸清业务脉络。第三调试时善用 devtools 的 import/export。Vuex 面板可以把当前状态导出成 JSON也能导入一份状态来复现问题。遇到用户反馈的诡异 bug让对方导出一份状态文件本地导入后就能完全复现当时的场景比反复沟通效率高太多。这个功能知道的人不多但真的好用。第四测试只测 action 和 getter。state 和 mutation 太简单测了也是形式主义。真正有价值的是 action 里的业务流程和 getter 里的计算逻辑这两块写单测覆盖率能上一个大台阶。测 action 的时候记得 mock 掉commit和dispatch用jest.fn()或者vi.fn()都行断言调用参数就够了。最后提一句状态管理这块工具会换但“什么状态该放在哪一层、变更走什么路径、如何保证可追溯”这三个问题不会变。把 Vuex 这套东西吃透换成 Pinia、换成任何别的方案思路都是通用的。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表