Vue.js状态管理模式:构建可扩展的应用架构

Vuex 的 state 其实是一个隐藏 Vue 实例的 data,getter 是它的 computed。顺着 3.6.2 的源码看 commit 走的那张扁平表、命名空间为什么只是个字符串前缀、插件为什么全挂在 subscribe 上,以及 Pinia 砍掉 mutation 之后换来了什么。

位置
第 11 篇 / 共 12 篇
预计
16 分钟

状态传到第三层的时候,中间两层只是在转发

一个 user 对象从 GrandParent 传到 GrandChild,中间隔着两层组件。这两层自己一个字段都不用,却各写了一遍 props 声明和一遍事件转发:

Parent.vue · 自己不用 user,只负责往下传、往上转
<template>
<Child :user="user" @update-user="$emit('update-user', $event)" />
</template>
<script>
export default {
props: ['user'],
emits: ['update-user']
};
</script>

Child.vue 里还有一份一模一样的。给字段改个名,三个文件一起改;想知道 user 到底是被谁改的,得顺着 emit 链一层层往上翻。

graph TB
subgraph "无状态管理"
A1[组件A] -->|props| B1[组件B]
B1 -->|props| C1[组件C]
C1 -->|events| B1
B1 -->|events| A1
A1 -->|props| D1[组件D]
D1 -->|events| A1
end
subgraph "有状态管理"
Store[全局Store]
A2[组件A] -->|dispatch| Store
B2[组件B] -->|dispatch| Store
C2[组件C] -->|dispatch| Store
D2[组件D] -->|dispatch| Store
Store -->|state| A2
Store -->|state| B2
Store -->|state| C2
Store -->|state| D2
end

把状态挪到组件树外面之后,每个组件和数据之间只剩两条边:读一次、写一次,中间层不再需要知道 user 的存在。

代价也画在同一张图里。左边那张图虽然线多,但每条线都指名道姓;右边所有组件都直连 store,「谁改了这个字段」这个问题从「翻三层组件」变成了「全局搜一个字符串」。只隔一层的时候 props 比 store 清楚得多 —— store 是要交门槛费的,通信复杂到一定程度才回本。

state 是一个 Vue 实例的 data,getter 是它的 computed

Vuex 没有自己写一套响应式。它把整棵状态树塞进一个隐藏 Vue 实例的 data,借的是 Vue 本来就有的那一套。下面这段是 Vuex 3.6.2 的 src/store.js

Vuex 3.6.2 · src/store.js resetStoreVM(节选)
function resetStoreVM (store, state, hot) {
store.getters = {}
const wrappedGetters = store._wrappedGetters
const computed = {}
forEachValue(wrappedGetters, (fn, key) => {
// use computed to leverage its lazy-caching mechanism
computed[key] = partial(fn, store)
Object.defineProperty(store.getters, key, {
get: () => store._vm[key],
enumerable: true
})
})
// 整棵状态树就活在这个 Vue 实例的 data 里
store._vm = new Vue({
data: { $$state: state },
computed
})
if (store.strict) {
enableStrictMode(store)
}
// 省略:热更新时把旧的 vm 销毁掉
}

同一个文件里,Store 类只给 state 开了读的口子:

Vuex 3.6.2 · Store 上的 state 访问器
class Store {
get state () {
return this._vm._data.$$state
}
set state (v) {
// 开发环境直接 assert 失败
assert(false, `use store.replaceState() to explicit replace store state.`)
}
}

三件事跟着这几行定死了。

getter 自带缓存。 每个 getter 被塞进那个 Vue 实例的 computed 选项,store.getters.doneTodos 读的其实是 store._vm.doneTodos,走的是 Vue computed 的惰性求值和依赖缓存 —— 依赖没变就不重算,和你在组件里写 computed 是同一件事。源码上那行注释写得很直白:use computed to leverage its lazy-caching mechanism

替换整棵 state 只有一个入口。 replaceState 内部做的事就是在 _withCommit 里执行 this._vm._data.$$state = state,绕开这个方法直接赋值,开发环境会 assert 失败。

Vue 2 响应式的限制原样继承过来。 state 里事先没声明的字段直接加进去不会触发更新,得走 Vue.set。Vuex 自己也躲不开这条 —— 动态注册模块时它就是这么把子模块的 state 挂到父级上的,下一节能看到那行 Vue.set(parentState, moduleName, module.state)

换到 Vue 3 之后,藏起来的那个 Vue 实例没有了

Vuex 4 把这段代码搬进了 src/store-util.js,函数改名叫 resetStoreState。隐藏实例换成一个 reactive 对象,getter 换成一批独立的 computed

Vuex 4.1.0 · src/store-util.js resetStoreState(节选)
export function resetStoreState (store, state, hot) {
store.getters = {}
const wrappedGetters = store._wrappedGetters
const computedObj = {}
const computedCache = {}
const scope = effectScope(true)
scope.run(() => {
forEachValue(wrappedGetters, (fn, key) => {
computedObj[key] = partial(fn, store)
computedCache[key] = computed(() => computedObj[key]())
Object.defineProperty(store.getters, key, {
get: () => computedCache[key].value,
enumerable: true
})
})
})
store._state = reactive({ data: state })
store._scope = scope
// 省略:把上一个 scope 停掉
}

访问器跟着变成 get state () { return this._state.data }effectScope(true) 是收口用的:这批 computed 不属于任何组件,没有组件的生命周期替它们收尾,得有个东西记着,store 重建或销毁时才停得掉。

模块状态的挂载方式也换了。Vuex 3 的 installModule 里写的是 Vue.set(parentState, moduleName, module.state),Vuex 4 同一个位置直接写 parentState[moduleName] = module.state —— Vue 3 的 Proxy 不需要那道手续。严格模式那个侦听器同理,$watch(..., { deep: true, sync: true }) 换成了 watch(..., { deep: true, flush: 'sync' })

建 store 的写法从 Vue.use(Vuex)new Vuex.Store({}) 变成了 createStore({})app.use(store)createStore 里面只有一行 return new Store(options)Store 类照旧导出 —— 两种写法是等价的,换的是习惯不是机制。

commit 查的是一张扁平的表,同名 mutation 会全跑一遍

sequenceDiagram
participant V as View视图
participant A as Action动作
participant M as Mutation变更
participant S as State状态
V->>A: dispatch触发Action
A->>M: commit提交Mutation
M->>S: 修改State
S->>V: 响应式更新View
Note over V,S: 单向数据流确保状态可预测

图里那条链是约定,不是强制 —— 组件完全可以跳过 action 直接 commit,Vuex 不拦。约定的价值在最后一环:state 只能被 mutation 改,所以「这个字段是被谁改的」永远只有一份候选名单。

commit 本身比想象中短:

Vuex 3.6.2 · Store.prototype.commit(节选)
commit (_type, _payload, _options) {
const { type, payload, options } = unifyObjectStyle(_type, _payload, _options)
const mutation = { type, payload }
const entry = this._mutations[type]
if (!entry) {
// 省略:开发环境打印 unknown mutation type
return
}
this._withCommit(() => {
entry.forEach(function commitIterator (handler) {
handler(payload)
})
})
this._subscribers
.slice()
.forEach(sub => sub(mutation, this.state))
}

三处值得停一下。

this._mutations扁平的,key 就是完整的类型字符串,和模块的嵌套结构没有关系。查一次哈希就到位,不用顺着模块树往下走。

entry 是一个数组,不是一个函数。两个模块注册了同名的 mutation 而都没开 namespacedcommit 一次会把它们全部执行一遍,控制台不出任何提示。这才是 namespaced: true 真正要防的东西:不是名字好看,是防这种静默的双写。

找不到 type 的时候,它 return 了 —— 不抛错。commit('prodcuts/ADD_ITEM') 拼错一个字母,页面上什么都不会发生,只有控制台里一行 [vuex] unknown mutation type

mutation 必须同步,理由在 devtools 那边

_withCommit 是一个开关,执行 handler 之前把 _committing 置为 true,执行完还原。严格模式就架在它上面:

Vuex 3.6.2 · _withCommit 与 enableStrictMode
_withCommit (fn) {
const committing = this._committing
this._committing = true
fn()
this._committing = committing
}
function enableStrictMode (store) {
store._vm.$watch(function () { return this._data.$$state }, () => {
assert(store._committing, `do not mutate vuex store state outside mutation handlers.`)
}, { deep: true, sync: true })
}

侦听器一响就检查 _committing:为真说明改动发生在 mutation 里面,为假就是有人在外面动了 state。deep: true 意味着状态树上任何一个字段变化都会走一遍这个回调,sync: true 意味着它不进异步队列、改完当场就报 —— 报错的栈里才看得见是谁干的。这两个选项加起来的开销,就是 Vuex 文档要求发布环境关掉严格模式的原因。

同一个标志位也解释了「mutation 里为什么不能写异步」。_withCommit 是同步执行完 fn() 就把 _committing 还原的;mutation 里写一个 setTimeout 改 state,回调跑起来的时候 _committing 早就是 false 了,严格模式当场报错。更根本的一层在调试工具那边,Vuex 文档给的正是这个理由:devtools 要给每条 mutation 记下前后两份状态快照,而 commit 返回的那一刻,setTimeout 的回调还没跑,devtools 也无从知道它什么时候才会跑。改动落在那个回调里,两份快照之间就体现不出来 —— 这次变化对 devtools 来说等于没发生过,时间旅行自然也重放不出来。异步的事归 action,它爱等多久等多久,只要最后落到 commit 上。

命名空间只是拼在名字前面的一段字符串

Vuex 3.6.2 · ModuleCollection.getNamespace
getNamespace (path) {
let module = this.root
return path.reduce((namespace, key) => {
module = module.getChild(key)
return namespace + (module.namespaced ? key + '/' : '')
}, '')
}

模块路径 ['products', 'cart'],两层都开了 namespaced,拼出来就是 'products/cart/'。中间哪一层没开,它的名字就不参与拼接。installModule 拿着这个前缀去注册:

Vuex 3.6.2 · installModule(节选)
function installModule (store, rootState, path, module, hot) {
const isRoot = !path.length
const namespace = store._modules.getNamespace(path)
if (!isRoot && !hot) {
const parentState = getNestedState(rootState, path.slice(0, -1))
const moduleName = path[path.length - 1]
store._withCommit(() => {
Vue.set(parentState, moduleName, module.state) // 子模块的 state 挂到父级上
})
}
const local = module.context = makeLocalContext(store, namespace, path)
module.forEachMutation((mutation, key) => {
registerMutation(store, namespace + key, mutation, local)
})
// getters、actions 同理,名字前面拼同一个 namespace
module.forEachChild((child, key) => {
installModule(store, rootState, path.concat(key), child, hot)
})
}

所以 this.$store.commit('products/cart/ADD_ITEM', payload) 里那两根斜杠不是路径分隔符,它们就是名字的一部分 —— _mutations 表里那个 key 逐字就叫 'products/cart/ADD_ITEM'

模块内部还是写 commit('ADD_ITEM'),能落到正确的模块上,是因为传给 mutation 和 action 的 commit 不是 store 上那个:

Vuex 3.6.2 · makeLocalContext(节选)
function makeLocalContext (store, namespace, path) {
const noNamespace = namespace === ''
const local = {
commit: noNamespace ? store.commit : (_type, _payload, _options) => {
const args = unifyObjectStyle(_type, _payload, _options)
let type = args.type
if (!args.options || !args.options.root) {
type = namespace + type // 模块里写 'ADD_ITEM',打出去的是 'products/cart/ADD_ITEM'
}
store.commit(type, args.payload, args.options)
}
// dispatch 同理
}
return local
}

namespace 被闭包起来,转发之前拼上去;想打到全局,第三个参数传 { root: true } 就跳过这一步。

同一个函数末尾还给 local 定义了 stategetters 两个访问器,都是惰性取值:mutation 表是扁的,state 树是嵌套的,两边的组织方式正好相反,local.state 每次都得现走一遍 getNestedState(store.state, path)。存一份引用会在 replaceState 换掉整棵树之后失效。

整套机制的代价就是那根字符串。'products/cart/ADD_ITEM' 拼错一个字母,编辑器不会提示,构建不会失败,运行时也只是控制台里一行 unknown mutation type —— 它是运行时才发现的错误,而且发现的方式是「页面上什么都没发生」。mapStatemapGettersmapMutationsmapActions 这四个辅助函数把前缀收进一个地方少写几遍,createNamespacedHelpers('products/cart') 更进一步,返回的是已经带上前缀的四个函数。但它们省的是重复,挡不住拼错 —— 名字仍然是字符串,这条要到 Pinia 才真正解决。

模块可以在运行时注册,store.registerModule('orders', orderModule) 走的是同一个 installModule,路由里按需装一个模块是可行的。要注意它注册完还调了一次 resetStoreVM:那个藏起来的 Vue 实例和全部 getter 的 computed 会整个重建一次。这件事适合发生在路由切换的时候,不适合放在滚动或者输入的回调里。想先判断有没有装过,store.hasModule(path) 能问 —— 这个方法是 3.2 才加的。

getter 有缓存,但返回函数的那种没有

想给 getter 传参数,常见的写法是让它返回一个函数:

按 id 取实体的 getter
getters: {
getUserById: state => id => state.entities.users[id]
}

组件里 this.$store.getters.getUserById(3)。实体按 id 存成一张表、列表只存 id 的时候,这种查表 getter 到处都是。

但缓存只到外面那一层。被 computed 缓存住的是「返回的那个函数」,函数体每调用一次就跑一次。Vuex 文档专门写了这条:通过方法访问的 getter,每次调用都会重新执行,结果不做缓存。

知道这条之后,下面这种写法的毛病就看得出来了:

有问题的写法:这个缓存永远不会失效
getItemById: (state) => {
const cache = new Map()
return (id) => {
if (cache.has(id)) return cache.get(id) // state 变了也照样命中
const item = state.items.find(item => item.id === id)
if (item) cache.set(id, item)
return item
}
}

外层函数体里根本没有碰过 state —— state.items 是在内层那个箭头函数里才被读到的。computed 求值时收集到的依赖是空的,于是这个 getter 一辈子只算一次,cache 这个 Map 建起来之后再没有任何东西能让它失效。state.items 里那一项被换掉之后,getItemById(3) 返回的还是旧对象。

要缓存就把工作挪到外层去,让 computed 自己管失效:外层先扫一遍 state.items 建出 id → item 的映射,再返回一个查这张表的函数。这样 state.items 是在求值期间被读到的,它一变,整个 getter 连同映射表一起重建。

插件就是挂在 store.subscribe 上的一个函数

commit 的最后三行已经把插件系统交代完了:mutation 执行完,_subscribers 里每个函数都会收到 (mutation, state),其中 mutation{ type, payload }。持久化、日志、埋点全挂在这一个钩子上。

持久化插件 · 写进 plugins 数组
const persistPlugin = store => {
const saved = localStorage.getItem('vuex-state')
if (saved) store.replaceState(JSON.parse(saved))
store.subscribe((mutation, state) => {
localStorage.setItem('vuex-state', JSON.stringify(state))
})
}
const store = new Vuex.Store({
// ...
plugins: [persistPlugin]
})

状态持久化这件事的骨架就这么大,剩下的都是加法:筛掉不值得存的 mutation、只存某几个模块、换一个存储后端、给写盘做节流。

两笔代价要算清楚。一是 JSON.stringify(state) 每次 mutation 都跑一遍,序列化的是整棵状态树,而不是这次改动的那部分;状态树大、mutation 密的页面上这不是小开销。二是 JSON.parse 回来的是纯对象,state 里原本的 MapDate、类实例全部退化成普通值 —— 存进去和取出来的不是同一种东西。

日志插件不用自己写,Vuex 自带一个:import { createLogger } from 'vuex'。3.5 起和 4.x 都从包名直接导出,3.5 之前要写成 import createLogger from 'vuex/dist/logger'。想监听 action 而不是 mutation 的话,store.subscribeAction 收的是 { before, after, error } 三个钩子。

Pinia 把 mutation 整个砍掉了

Vuex 官网现在首页就挂着一句 Pinia is now the new default:Vuex 3 和 4 继续维护,但基本不会再加新功能,Vue 官方推荐的状态管理库换成了 Pinia。

同一个 store,Pinia 有两种写法。选项式和 Vuex 长得像,少了 mutation 这一层:

Pinia · Option Store
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
// state 是一个函数,返回值就是这个 store 的初始状态
state: () => ({
profile: null as UserProfile | null,
preferences: {} as UserPreferences
}),
getters: {
isLoggedIn: (state) => !!state.profile,
username: (state) => state.profile?.name ?? 'Guest'
},
actions: {
// 同步异步都在这儿,直接改 this 上的字段,没有 commit
async login (credentials: Credentials) {
this.profile = await api.login(credentials)
}
}
})

组合式的写法把这三样换成了 refcomputed 和普通函数:

Pinia · Setup Store(同一个 store 的另一种写法)
export const useUserStore = defineStore('user', () => {
const profile = ref<UserProfile | null>(null)
const isLoggedIn = computed(() => !!profile.value)
async function login (credentials: Credentials) {
profile.value = await api.login(credentials)
}
return { profile, isLoggedIn, login }
})

高亮那行的 return 是有讲究的:Setup Store 里没被 return 出去的 ref 不算 state,devtools 看不到它,$patch 和服务端渲染的状态注水也管不到它。想在 store 里藏一个私有变量,得接受它脱离这一整套机制。

defineStore 的第一个参数是 store 的 id。devtools 里显示的是它,服务端渲染时状态序列化用的 key 也是它,Pinia 内部那张 store 表拿它当键 —— 所以一个应用里它得是唯一的。

砍掉 mutation 之后有两件事跟着变了。

没有类型字符串了。 Vuex 里 commit('products/cart/ADD_ITEM') 拼错只换来一行 console,Pinia 里 store.addItem() 拼错是 TypeError,编辑器在你敲下去之前就红了。这是 Pinia 相对 Vuex 最实在的一处改善 —— 命名空间这个概念整个不存在了,一个 store 就是一个模块,用哪个 import 哪个。

同步和异步之间那道墙没了。 action 里可以随便 await,改 state 不需要绕回 mutation。方便的另一面是「谁改了这个字段」不再有单一入口 —— 拿到 store 实例的任何地方都能直接写 store.profile = x$patch 也能一次改一批。Vuex 那层仪式挡掉的正是这种写法。

还有一处容易踩:store 实例是响应式对象,直接解构会把响应式丢掉。

Pinia · 解构要走 storeToRefs
const store = useUserStore()
const { profile, isLoggedIn } = storeToRefs(store) // state 和 getter,拿到的是 ref
const { login } = store // action 是函数,直接解构就行

$reset() 也不是两种写法都有:它靠的是重新执行一遍 state() 那个函数,所以只有选项式的 store 能用,组合式的得自己写一个把每个 ref 归位。

不装库的时候,一个 reactive 对象就够用了

Vue 3 里 reactiveref 本来就能脱离组件用。一个模块导出一个 reactive 对象,import 它的组件共享同一份数据 —— 这就是一个能用的 store,只是没有 devtools、没有插件、也没有任何约束:

store/counter.js · 最小的共享状态
import { reactive } from 'vue'
export const counter = reactive({
count: 0,
increment () {
this.count++
}
})

这条路有一个坑在服务端渲染上,Vue 官方文档专门提过:模块作用域里的这个对象在 Node 进程里只有一份,多个请求会共用它,上一个用户的数据可能出现在下一个用户的页面上。浏览器里每次刷新都是新的 JS 环境,所以本地开发一路顺畅,问题要到上了 SSR 才露头。Pinia 之所以要 createPinia()app.use(),一部分原因就是每个请求得拿到自己那份实例。

范围更小的共享用 provide / inject:状态只给某一棵子树看得见,跨出这棵树就取不到。它不解决「谁改的」这个问题,但它把「谁能看见」限制住了 —— 一个只在弹窗内部流转的表单状态,放进全局 store 是过度设计。顺带一提,Vue 2 那个 new Vue() 当事件总线的老办法在 Vue 3 里行不通了,实例上的 $on / $off / $once 被移除了。

真正该问的问题不是「Vuex 还是 Pinia」,而是「这份状态要活多久、要被几个互不相识的组件看见」。只有父子两层就用 props;只在一棵子树里流转就用 provide;跨路由存在、多处读写、还想在 devtools 里回看它怎么变的,才轮到 store。而从接口拉回来的数据是另一类东西 —— 它有失效时间、有重新拉取的时机,塞进 store 之后这些都得自己管,状态管理库并不负责这件事。

这篇是 Vue.js 内部机制深度解析的第 11 篇。前一篇是 Vue.js 组件系统架构深度解析,后一篇是 Vue.js 性能优化实践 - 基于内部机制的深度优化策略