# Vuex 的基本实现
# 中心化架构
首先,Vuex 是一个集中式、中心化架构的全局状态管理库。集中式,是指其数据(也就是状态 state)都挂载到同一个实例上,这个实例也就是所谓的中心。虽然 Vuex 提供了模块划分 modules 方式,可以让我们把一个大的 store 划分出多个 modules,每个 module 都有其独立的 mutations、getters...,但是,从底层来看,这些模块并不是完全独立的,而是相互影响的,原因如下:
- 所有 modules 的 state 全部挂载在 store 的根 state 中,并且不同模块的 state 间存在嵌套关系
- 所有模块的 mutations 和 actions 全部挂载在根 store 上,同名的 mutations/actions 方法会被维护成数组,当某个模块中的事件触发,会导致所有模块中的同名方法被调用
- getters 同理,全部挂载在根 store 上
- 命名空间并没有实现模块完全独立。所谓命名空间,其实只是给方法的名称前面加上了
路径前缀,对于数据和方法的存储方式和位置没有任何影响。
综上可知,Vuex 的各个模块间并没有像 pinia 那样实现完全独立,其架构为集中式架构,一般来说,只存在一个 store。
# install
所有 Vue 插件不可避免的都需要提供 install 方法供 Vue.use 调用,我们来看 Vuex 的 install 方法:
export let Vue
export default (_Vue) => {
Vue = _Vue
Vue.mixin({
// 保证每一个实例上都挂载上 $store,让每个组件共享
beforeCreate() {
if (this.$options.store) {
// 如果 vue 实例上的选项中传入了 store,那一定是根实例
this.$store = this.$options.store
} else {
if (this.$parent && this.$parent.$store) {
// 若当前 vue 实例没有被传入 store,则非根节点
this.$store = this.$parent.$store // 向父节点取 $store
}
}
}
})
}
Vuex 的 insatll 方法很简单,其实就是调用 mixin 方法,将用户传入的 store 选项(注意:是store选项不是store实例)挂载到每个实例上 ,供实例调用。
# ModuleCollection
对于用户传入 store 的配置对象,我们需要进一步进行处理,因为该对象并不能直观的表达模块间的父子关系。比如对于一个包含 b、c 两个子模块的 a 模块,我们想要获取其子模块,该如何获取?需要去寻找 a 下的 modules 模块,并遍历 modules 的所有属性才能获取到其子模块。所以,我们希望对传入 store 的配置对象进行一个初步的处理,将其所有的子模块挂载到该对象的 children 属性上,模块的状态 state 挂载到 state 属性上。
import Module from "./module"
// 收集模块间的父子关系,形成树形结构
export default class ModuleCollection {
constructor(options) {
this.root = null
this.register([], options)
}
// 根据路径获取该模块命名空间前缀
getNamespaced(path) {
let res = ''
let root = this.root
path.forEach(key => {
// 判断是否为该模块的子模块
root = root.getChild(key)
// 若该模块存在 namespace,则收集该模块名称
res = root._raw.namespaced ? (res + `${key}/`) : res
})
return res
}
/**
* 递归构建模块父子关系
* @param {Array} path 路径,记录了模块从根模块到该模块的路径
* @param {Object} options 模块
*/
register(path, rootModule) {
// _raw 用于保存原本的模块
let newModule = new Module(rootModule)
rootModule.newModule = newModule
if (!this.root) {
// root 无值,当前模块是根模块,根模块不可能是其他模块的子模块
this.root = newModule
} else {
// 不是根模块,需要维护模块间的父子关系
// 通过 path 找当前模块的父模块是谁,从根模块 root 开始找
let parent = this.root
path.slice(0, -1).forEach(key => {
// 按照 path 一层层去寻找父级
parent = parent.getChild(key)
})
// 找到了父级模块,将子模块添加给父级模块
parent.addChild(path[path.length - 1], newModule)
}
if (rootModule.modules) {
// 如果传入的模块有 modules 选项,需要递归处理子模块
Object.keys(rootModule.modules).forEach(key => {
this.register(path.concat(key), rootModule.modules[key])
})
}
}
}
我们在每次进入一个模块之前,向 path 数组内加入该模块的名称,通过 path 数组来表示从根模块到该模块的路径。在该模块中,我们可以通过 path 数组轻松的从根模块出发,找到当前模块的父模块,再将当前模块加入其父模块的 children 中去即可。
ModuleCollection 是对用户传入的 store 配置对象的处理,处理结果将会作为 _modules 属性挂载到 store 对象上。
# Store
Store 是 Vuex 的核心,用户通过向 Store 构造函数中传入配置对象,来配置 Vuex。
const store = new Vuex.Store({
strict: true,
plugins: [
],
state: {
baseCount: 1
},
getters: {
...
},
mutations: {
},
actions: {
},
modules: {
})
配置好 store 后将其传入 Vue 的构造函数中,作为其 options 即可。
下面来看 Store 构造函数中做了什么:
class Store {
constructor(options) {
// 由于 modules 嵌套,需要预处理这些模块,获取模块间的父子关系。即模块收集
this._modules = new ModuleCollection(options)
this._mutations = Object.create(null)
this._actions = Object.create(null)
this._warppedGetters = Object.create(null)
this.plugins = options.plugins || []
this.subscribes = []
this.strict = options.strict
this._commiting = false
// state
const state = this._modules.root.state
// 安装模块
installModule(this, state, [], this._modules.root)
// 注意此时 state 并不在 store 实例上
// 创建 vue 实例,并将 state 放置在 data 中,getters 放置在 computed 中
resetStoreVM(this, state)
// 执行插件(插件就是函数),插件方法接收一个参数,store
this.plugins.forEach(plugin => plugin(this))
}
/**
* 如果开严格模式,修改的state必须通过此方法包装,否则判定为非法修改
* @param {Function} fn 方法
*/
_withCommiting(fn) {
this._commiting = true
fn()
this._commiting = false
}
// 通过 commit 调用 mutations
commit = (funcName, payload) => {
this._mutations[funcName].forEach(fn => fn.call(this, payload))
}
// 通过 dispatch 调用 actions
dispatch = (funcName, payload) => {
this._actions[funcName].forEach(fn => fn.call(this, payload))
}
/**
* 订阅 store 的 mutation。handler 会在每个 mutation 完成后调用,接收 mutation 和经过 mutation 后的状态作为参数:
* @param {Function} handler 每个 mutation 完成后调用的函数
*/
subscribe(handler) {
if (typeof handler === 'function') {
this.subscribes.push(handler)
}
}
// 取 state 时需要向 vm 上去取
get state() {
return this._vm._data.$$state
}
/**
*
* @param {Array | String} path 路径,指定要将模块添加到哪个父模块下
* @param {Object} module
*/
registerModule(path, module) {
// 注册模块
this._modules.register(path, module)
// 安装这个模块
installModule(this, this.state, path, module.newModule)
// 处理 getters
resetStoreVM(this, this.state)
}
/**
* 替换 store 的根状态,仅用状态合并或时光旅行调试。
* @param {Object} state 替换后的状态
*/
replaceState(state) {
this._withCommiting(() => {
this._vm._data.$$state = state
})
}
}
可以看到,初始化 Store 的主要步骤如下:
- 安装模块
- 创建 vm 实例
- 执行插件方法
下面我们先讲解安装模块和**创建 vm 实例(实现响应式)**两个步骤
# 安装模块
通过 ModuleCollection ,我们已经将 modules 安装在 store 上了,但其他选项:state、mutations、actions 和 getters 还没有安装在 store 上,也就是通过 store 无法访问到这些属性。Store 中通过 installModule 去安装这些模块:
function installModule(store, rootState, path, rootModule) {
if (path.length > 0) {
// 是子模块,需要将子模块的 state 挂在父模块的 state 上
// 找父模块
let parent = rootState
path.slice(0, -1).forEach(key => {
// 按照 path 一层层去寻找父级
parent = parent[key]
})
// 将子模块的 state 挂载到父模块的 state 上
store._withCommiting(() => {
Vue.set(parent, [path[path.length - 1]], rootModule.state)
})
}
let namespaced = store._modules.getNamespaced(path)
// 将 mutations 中的方法安装到根实例上,维护成数组,同名的方法放在同一个数组中
rootModule.forEachMutations((key, val) => {
store._mutations[namespaced + key] = (store._mutations[namespaced + key] || [])
store._mutations[namespaced + key].push((payload) => {
store._withCommiting(() => {
val(getState(store, path), payload)
})
// 执行 subscribe 中传入的函数
store.subscribes.forEach(fn => {
fn({ type: key, payload }, store.state)
})
})
})
// 将 actions 中的方法安装到根实例上,维护成数组,同名的方法放在同一个数组中
rootModule.forEachActions((key, val) => {
store._actions[namespaced + key] = (store._actions[namespaced + key] || [])
store._actions[namespaced + key].push((payload) => {
val(store, payload)
})
})
// 计算属性不用维护成一个数组,计算属性不能重名
rootModule.forEachGetters((key, val) => {
if (store._warppedGetters[namespaced + key]) {
// 计算属性重名报错
throw new Error(`duplicate getter key '${key}'`)
}
store._warppedGetters[namespaced + key] = () => {
return val(getState(store, path))
}
})
// 如果存在子模块,递归处理
rootModule.forEachChildren((key, val) => {
installModule(store, rootState, path.concat(key), val)
})
}
该函数接收四个参数,store 为当前 Store 实例,rootState 为根 state 这两个参数在递归时是不变的。path 参数为模块的路径,与 ModuleCollection 中的 path 参数用法一致,rootModule 为当前递归处理的模块。
在每次处理模块时,先处理 state ,将子 state 挂载到父 state 下。至于如何寻找到父 state,方式与 ModuleCollection 寻找父模块的方式相同,通过 path 路径,从根 state 开始寻找。
然后就是处理 mutations 和 actions,前面我们已经提到过,对于同名的 mutations 方法或者 actions 方法,需要维护在同一个数组中。mutations 中的方法会挂载到 store._mutations 上,该属性值为对象,以方法名作为 key,value 为同名 mutations 方法的数组。actions 挂载在 store._actions 下,其他同理。
最后是 getters,getters 会挂载在 store._warppedGetters 上,值得注意的是,getters 不允许同名(在没有开启命名空间的情况下)。
# 创建 vm 实例(实现响应式)
Vuex 中的数据,其实都是响应式的,其中的数据变化,视图需要自动重新渲染。那么我们该如何去实现这个响应式呢?首先我们想到的是和 Vue Router 中一样,通过 Vue 提供的 defineReactive API 来实现响应式。这对于 state 中的数据来说当然是可行的,但是 Vuex 还有一个选项中可以定义数据,那就是 getters。并且 getters 需要依赖于其他数据,无法直接通过 defineReactive 来添加响应式。
所以 Vuex 中采取了另一种方式,那就是通过创建 Vue 实例,将 state 中数据放置在 data 中。我们知道,Vue 会自动将 data 中的数据定义为响应式数据,这样就很简单的实现了 state 的响应式。至于 getters 的响应式,在上一篇文章介绍 getters 的用法时就提到过,其语法和特性与 Vue 中的计算属性几乎完全一致,所以 getters 其实就是被放置在了 Vue 的 computed 选项中,这样就可以简单而优雅的实现 getters 的响应式了。
function resetStoreVM(store, state) {
let oldVm = store._vm
const computed = {}
store.getters = {}
// 将 getters 添加给 computed
Object.keys(store._warppedGetters).forEach(key => {
computed[key] = () => {
return store._warppedGetters[key](state)
}
// 代理,取 getter 时返回 computed 的值
Object.defineProperty(store.getters, key, {
get: () => {
return store._vm[key]
}
})
})
store._vm = new Vue({
data: {
// 将 state 存放在 vue 的 data 中,目的是为其添加响应式
// 添加 $ 是为了不让 vue 将 state 直接代理到 vm 上,不希望用户可以直接这样访问到 state
$$state: state
},
// 用于实现计算属性
computed,
})
// 严格模式下监听 state 变化
if (store.strict) {
store._vm.$watch(() => store._vm._data.$$state, () => {
if (!store._commiting) {
throw new Error("Do not mutate vuex store state outside mutation handlers");
}
}, { sync: true, deep: true })
}
if (oldVm) {
Vue.nextTick(() => oldVm.$destroy())
}
}
关于 data 中的为什么命名为 $$state 而不是 state,原因在于 Vue 中在初始化 data 中的数据时,会将不以 $ 和 _ 开头的变量直接代理到 vm 实例上,通过 vm.变量名 的方式就能访问到变量。而由于 Vuex 的设计初衷,我们并不希望用户随意去修改 state 中的数据,所以我们不能让用户就这么简单的获取到 state 中的数据。故采用了 $$state 的方式。
通过 defineproperty 劫持,当我们访问 store 上的_warppedGetters 时,实际上获取到的就是 store._vm上的计算属性值。
对于 state ,没有采用 defineproperty 的方式,而是利用了 calss 中的 get 方法,当获取 store 上的 state 时,实际上获取到的是 store._vm._data.$$state 上的值
// 取 state 时需要向 vm 上去取
get state() {
return this._vm._data.$$state
}
通过以上方式,我们就已经实现了 Vuex 的基本功能。
← Vuex 的概念与基本使用 Vuex 插件 →