- A+
本文首发于掘金,未经许可禁止转载
Vuex4 是 Vue 的状态管理工具,Vuex 和单纯的全局对象有以下两点不同:
- Vuex 的状态存储是响应式的
- 不能直接改变 store 中的状态。改变 store 中的状态的唯一途径就是显式地 提交 (commit) mutation。
本文手写部分分为八个部分,基本包含了 Vuex 的功能。
- 实现获取state并响应式修改state
- 实现getters
- 实现 commit 和 dispatch
- 注册模块
- 注册模块上的 getters,mutations,actions 到 store 上
- 命名空间
- 严格模式
- 插件模式
准备工作
创建名字叫 vuex_source
的工程
vue-cli3 create vuex_source
上面命令和使用 vue create vuex_source
创建项目是等价的,我电脑安装了 vue-cli2
和 vue-cli3
,在 vue-cli3
里面修改了 cmd
文件,所以可以用上面命令。
选择 Vuex,使用空格选择或取消选择
启动项目如果如下图报错
可以试试输入命令
$env:NODE_OPTIONS="--openssl-legacy-provider"
基本使用
使用 createStore 创建一个 store
import { createStore } from 'vuex' export default createStore({ strict:true, state: { count:1 }, getters: { double(state){ return state.count * 2 } }, mutations: { mutationsAdd(state,preload){ state.count += preload } }, actions:{ actionAdd({commit},preload){ setTimeout(() => { commit('mutationsAdd',preload) }, 1000); } } })
main.js 中引入
import { createApp } from 'vue' import App from './App.vue' import store from './store' createApp(App).use(store).mount('#app')
在 app.vue 中使用 store
<template> 数量:{{count}} {{$store.state.count}} <br> double:{{double}} {{$store.getters.double}} <br> <!-- 严格模式下会报错 --> <button @click="$store.state.count++">错误增加</button> <br> <button @click="mutationsAdd">正确增加mutation</button> <br> <button @click="actionAdd">正确增加 action</button> </template> <script> import { computed } from "vue"; import { useStore } from "vuex"; export default { name: 'App', setup(){ const store = useStore() const mutationsAdd = () =>{ store.commit('mutationsAdd',1) } const actionAdd = () =>{ store.dispatch('actionAdd',1) } return { // 来自官网解释:从 Vue 3.0 开始,getter 的结果不再像计算属性一样会被缓存起来。这是一个已知的问题,将会在 3.1 版本中修复。 // 使用 count:store.state.count 返回的话,模板中的 {{count}}并不是响应式的,这里必须加上 computed 此时响应式的 count:computed(() => store.state.count), double:computed(() => store.getters.double), mutationsAdd, actionAdd, } } } </script>
编写源码
实现获取state并响应式修改state
修改 App.vue 的引用,@/vuex 是需要编写的源码的文件夹
import { useStore } from "@/vuex"; // 之前是import { useStore } from "vuex";
修改 store 的引用
import { createStore } from '@/vuex'
在 src 目录下创建 vuex文件夹,里面添加 index.js
在 index.js 中添加 createStore 和 useStore 函数,createStore 用来创建 store,useStore 供页面调用
// vuex/index.js class Store{ constructor(options){ } } // 创建 store,多例模式 export function createStore(options){ return new Store(options) } // 使用 store export function useStore(){}
createStore 创建出的store,在main.js 中 调用 use 方法
createApp(App).use(store)
use 会调用 store 的 install 方法,将 store 安装到 Vue 上,所以 Store 类中还需要添加 install 方法
const storeKey = 'store' // 默认一个 store 名 class Store{ constructor(options){ } install(app,name){ // app 是vue3暴露的对象 // 在根组件上绑定了 store,子组件要用到 store // 根组件就需要 provide 出去,子组件 inject 接收 app.provide(name || storeKey,this) } } // 创建 store export function createStore(options){ return new Store(options) } // 使用 store export function useStore(name){ // inject 去找父组件的 provide 的东西 return inject(name!==undefined?name:storeKey) }
此时在 App.vue 中打印的就是一个空对象
// App.vue const store = useStore() console.log(store);
在 Store 类中绑定传进来的 state
constructor(options){ this.state = options.state }
打印就是
App.vue 中添加如下模板
<template> 数量:{{count}} // 正常打印 1 数量:{{$store.state.count}} // 报错了 <br> <button @click="$store.state.count++">错误增加</button> </template> <script> import { computed } from "vue"; import { useStore } from "@/vuex"; export default { name: 'App', setup(){ const store = useStore() console.log(store); return { count:computed(() => store.state.count) } } } </script>
上面模板中的 {{$store.state.count}}
会报错,是因为 $store 没有绑定到 this 上。vue3 中绑定到 this 可以用 app.config.globalProperties[属性名]
// createApp(App).use(store,name)会调用store的install方法 install(app,name){ // app 是vue3暴露的对象 app.provide(name || storeKey,this) app.config.globalProperties.$store = this } }
$store 绑定到 this 后就不会报错了
但此时点击 错误增加
的按钮没有任何效果,
因为此时 store.state 并不是响应式的,需要增加响应式效果,vue3 为复杂数据提供了 reactive
class Store{ constructor(options){ // 这里给options.state加了一层,用 data 包裹是为了重新赋值的时候可以直接 this._store.data = 。。。 ,而不用再使用 reactive this._store = reactive({data:options.state}) this.state = this._store.data } // createApp(App).use(store,name)会调用store的install方法 install(app,name){ // app 是vue3暴露的对象 app.provide(name || storeKey,this) app.config.globalProperties.$store = this } }
这时候 错误增加
的按钮就有效果了
实现getters
模板中是使用 getters 是以属性的方式:
// App.vue 数量:{{count}} 数量:{{$store.state.count}} <br> double:{{double}} double:{{$store.getters.double}} <br> <button @click="$store.state.count++">错误增加</button>
在 store.js 中定义的getters 是由一个大对象里面包含多个函数组成
getters: { double(state){ return state.count * 2 } },
在 store 中 double 是函数,返回的 state.count * 2
的结果。 在模板中使用的是 $store.getters.double ,这个 double 是 getters 上的一个属性。所以这里需要进行转换
const forEachValue = function(obj,fn){ return Object.keys(obj).forEach((key) =>{ fn(obj[key],key) }) } class Store{ constructor(options){ this._store = reactive({data:options.state}) this.state = this._store.data this.getters = Object.create(null) forEachValue(options.getters,(fn,key) => { // 当模板解析 $store.getters.double 时, // 就去执行 options.getters里面对应属性的函数,并将函数结果赋予该属性 Object.defineProperty(this.getters,key,{ // vue3.2之前的vuex中不能使用计算属性 computed,导致每次访问的时候都会执行函数引发潜在性能问题 // vue3.2修复了这个bug get:() => { return fn(this.state) } }) }) } // createApp(App).use(store,name)会调用store的install方法 install(app,name){ // app 是vue3暴露的对象 app.provide(name || storeKey,this) app.config.globalProperties.$store = this } }
forEachValue 函数接收一个对象参数 obj 和一个处理函数参数 fn;里面会遍历对象,循环调用 fn;
这里遍历 options.getters ,响应式注册到 this.getters 上,这样当模板解析 $store.getters.double 时,就会执行对应的 fn
点击错误增加
按钮,改变 $store.state.count 的值进而导致 getters 值的变化
实现 commit 和 dispatch
commit 和 dispatch 在组件中是这样使用的:
<template> <button @click="mutationsAdd">正确增加 mutation</button> <br> <button @click="actionAdd">正确增加 action</button> </template> <script> import { useStore } from "@/vuex"; export default { name: 'App', setup(){ const store = useStore() const mutationsAdd = () =>{ store.commit('mutationsAdd',1) } const actionAdd = () =>{ store.dispatch('actionAdd',1) } return { mutationsAdd, actionAdd, } } } </script>
store.js 中定义的是这样的:
mutations: { mutationsAdd(state,preload){ state.count += preload } }, actions:{ // 异步调用 actionAdd({commit},preload){ setTimeout(() => { commit('mutationsAdd',preload) }, 1000); } }
调用 mutation : store.commit(mutation类型,参数)
调用 action : store.dispatch(action类型,参数)
在 Store 类中实现 commit:
class Store{ constructor(options){ // 将 store.js 中定义的 mutations 传进来 this._mutations = options.mutations this.commit = function(name,preload){ if(this._mutations[name]!==undefined){ // 根据传进来的类型,调用对应的方法 this._mutations[name](this.state,preload) } } } }
效果如下,数量每次增加 1
在 Store 类中实现 dispatch:
class Store{ constructor(options){ // 将 store.js 中定义的 actions 传进来 this._actions = options.actions this.dispatch = function(name,preload){ if(this._actions[name]!==undefined){ // 根据传进来的类型,调用对应的方法 let fn = this // dispatch 进来调用的是 actionAdd({commit},preload) this._actions[name].apply(fn,[fn].concat(preload)) } } } }
dispatch 调用的参数是({commit},preload),所以这里传进去需要是 (this,preload)
看看效果:
这里报了错,由 dispatch 触发 actions 正常,但 actions 触发 对应的 mutations 出错了,显示 this 是 undefined。那么这里就要修改下之前的 commit 实现了,先用一个变量将 Store 类实例的 this 保存起来
class Store{ constructor(options){ // 这里创建一个 store 变量保存 this 是方便之后嵌套函数里面访问当前 this let store = this this._mutations = options.mutations this.commit = function(name,preload){ if(store._mutations[name]!==undefined){ store._mutations[name](store.state,preload) } } this._actions = options.actions this.dispatch = function(name,preload){ if(store._actions[name]!==undefined){ store._actions[name].apply(store,[store].concat(preload)) } } } }
这样就可以了
注册模块
平常使用中定义 modules 如下
// store/index.js import { createStore } from 'vuex' export default createStore({ // strict: true, state: { count: 1 }, // ... modules: { aCount: { state: { count: 1 }, modules: { cCount: { state: { count: 1 }, }, } }, bCount: { state: { count: 1 }, } } })
组件中使用
// App.vue <template> 数量(根模块):{{$store.state.count}} <button @click="$store.state.count++">增加</button> <br> 数量(aCount模块):{{$store.state.aCount.count}} <button @click="$store.state.aCount.count++">增加</button> <br> 数量(cCount模块):{{$store.state.aCount.cCount.count}} <button @click="$store.state.aCount.cCount.count++">增加</button> <br> </template>
先实现将用户定义的多个 modules 进行格式化,创造父子关系
用户传进来的数据
格式化后的数据
上面的每个 modules 下的数据格式如下
this._raw = modules // 保存不做处理的源数据 this.state = modules.state // 保存状态 this.children = {} // 创建子对象
- _raw 保存不做处理的源数据
- state 保存状态
- children 保存子模块
比如 bCount
// 格式化前 bCount:{ state: { count: 1 }, } // 格式化后 bCount: { _raw: bModule, // 这里放的是格式化前的数据 state: bModule.state, children: {} },
所以这里定义一个类 moduleCollection,专门用来收集模块,将用户写的嵌套 modules 格式化,创造父子关系
class Store { constructor(options) { const store = this // 收集模块,将用户写的嵌套modules格式化,创造父子关系 store._modules = new moduleCollection(options) console.log(store._modules) } }
在 moduleCollection类中定义 register 方法处理数据,定义 this.root 保存处理过后的数据
class moduleCollection{ constructor(rootModule){ // root 存储格式化的数据,方便后续安装到 store.state 上 this.root = null this.register(rootModule,[]) } register(rootModule,path){ } }
register 方法接受两个参数
一个表示当前处理的模块数据 rootModule ,一个表示当前处理的是谁的模块数据 path
path之所以用数组表示,是因为后面建造父子关系时,使用path可以进行关联
比如 path 是空数组,则表示处理的是根模块的数据,是 [a] 则表示处理的是 a 模块,是[a,c] 则表示处理的是 c 模块的数据,并且,c模块的数据要加到 a 模块的 children 中。
对于 register 方法:
首先将用户定义的 store 格式化赋值给 this.root,这里可以抽象出一个类,因为每个模块的格式都是 _raw,state,children
class Module{ constructor(modules){ this._raw = modules this.state = modules.state this.children = {} } getChild(key){ return this.children[key] } addChild(key,module){ this.children[key] = module } } class moduleCollection{ register(rootModule,path){ const newModule = new Module(rootModule) if(path.length===0){ this.root = newModule } } }
然后判断 最外层的 store 中也就是根模块还有没有子模块,如果有,继续递归格式化子模块数据
// 如果根模块下还有子模块,则继续递归注册 if(rootModule.modules){ Object.keys(rootModule.modules).forEach((key) =>{ this.register(rootModule.modules[key],path.concat(key)) }) }
用户定义的 store 中,根模块下定义了子模块,子模块里面分别是 aCount 和 bCount,所以执行到上面代码时, key 就是 aCount,bCount
rootModule.modules[key] 是他们对应的模块数据
当执行到 aCount 模块时,此时的 path 是[a],代表处理 aCount 的数据,这时我们要在根模块上添加 aCount,如果 path 是 [aCount,cCount],则需要在 aCount 模块上添加 cCount 模块,所以这里需要定义一个寻找父模块的方法。
使用 path.slice(0,-1) 得到父模块的 key,默认是根模块
const parent = path.slice(0,-1).reduce((modules,current) =>{ return modules.getChild(current) },this.root)
参数 modules 代表上一次执行结果,current代表当前元素,初始传入根模块
这里如果 path 是 [a], 传入给 reduce 时是 [] ,那么返回的就是 this.root
path 是[a,c],传入给reduce 时是 [a], 当执行module.getChild(current) 实际上就是 this.root.getChild(a)
找到父模块后,给父模块的 children 添加 modules
parent.addChild(path[path.length-1],newModule)
moduleCollection 类完整代码:
class moduleCollection{ constructor(rootModule){ // root 存储格式化的数据,方便后续安装到 store.state 上 this.root = null this.register(rootModule,[]) } register(rootModule,path){ // 注册模块,每个模块的格式都是 // _raw: rootModule, // state: rootModule.state, // children: {} // 所以给传进来的模块都格式化一下 const newModule = new Module(rootModule) // 注册根模块 if(path.length===0){ this.root = newModule }else{ // 注册子模块,将子模块添加到对应的父模块,通过 path路径可以知道对应的父模块 const parent = path.slice(0,-1).reduce((modules,current) =>{ return modules.getChild(current) },this.root) parent.addChild(path[path.length-1],newModule) } // 如果根模块下还有子模块,则继续递归注册 if(rootModule.modules){ Object.keys(rootModule.modules).forEach((key) =>{ this.register(rootModule.modules[key],path.concat(key)) }) } } }
得到格式化数据后,需要将各个模块的 state 安装在 store.state 上,以便之后调用:$store.state.aCount.cCount.count
,$store.state
安装后的样子应该是:
state:{ count:1, aCount:{ count:1, cCount:{ count:1 } }, bCount:{ count:1 } }
创建一个 installModules 函数
function installModules(store,modules,path){} class Store { constructor(options) { const store = this store._modules = new moduleCollection(options) console.log(store._modules) installModules(store,store._modules.root,[]) console.log(store.state) } }
store 是当前 Store 类的实例对象,模块安装的地方
modules 是要安装的模块
path 对应父子关系
installModules 方法和 register 方法类似
function installModules(store,modules,path){ if(path.length===0){ store.state = modules.state }else{ const parent = path.slice(0,-1).reduce((result,current) =>{ return result[current] },store.state) parent[path[path.length-1]] = modules.state } if(modules.children){ Object.keys(modules.children).forEach((key) =>{ installModules(store,modules.children[key],path.concat(key)) }) } }
这样也就得到了一个完整的 state
在组件中引用也能正确显示了
注册模块上的 getters,mutations,actions 到 store 上
class Store { constructor(options) { const store = this // 收集模块,将用户写的嵌套modules格式化,创造父子关系 store._modules = new moduleCollection(options) // 定义私有变量存放对应的 getters,actions,mutations store._getters = Object.create(null) store._mutations = Object.create(null) store._actions = Object.create(null) installModules(store,store._modules.root,[]) } }
同样也是在 installModules 方法里面进行存放操作
在格式化模块时,已经将每个模块定义成这样的数据格式:
this._raw = modules this.state = modules.state this.children = {}
_raw 存放的就是源数据,没有被格式化的数据。
所以,取得模块上的 getters 就是 modules._raw.getters;
取得模块上的 mutations 就是 modules._raw.mutations;
取得模块上的 actions 就是 modules._raw.actions;
遍历 modules._raw.getters ,安装到 store._getters 上。这里需要注意的是
getters的参数是 state,这个 state 本来是 modules._raw.state,但 _raw.state没有响应式,而后面store.state 是响应式的,需要根据 path 取得store.state里面对应的 state
function getCurrentState(state,path){ return path.reduce((result,current) =>{ return result[current] },state) } function installModules(store,modules,path){ ··· ··· if(modules._raw.getters){ forEachValue(modules._raw.getters,(getters,key) =>{ store._getters[key] = () =>{ // 这里的参数不能是 modules._raw.state,没有响应式 // 而后面 store.state 会是响应式的,需要根据 path 取得store.state里面对应的 state return getters(getCurrentState(store.state,path)) } }) } ··· ··· }
注册 mutations
if(modules._raw.mutations){ forEachValue(modules._raw.mutations,(mutations,key) =>{ if(!store._mutations[key]){ store._mutations[key] = [] } store._mutations[key].push((preload) =>{ // store.commit(key,preload) mutations.call(store,getCurrentState(store.state,path),preload) }) }) }
在模块里面,可能有多个同名的 mutations,所以这里可能有多个同名 key,需要用数组包装起来
注册 actions
if(modules._raw.actions){ forEachValue(modules._raw.actions,(actions,key) =>{ if(!store._actions[key]){ store._actions[key] = [] } store._actions[key].push((preload) =>{ // store.dispatch({commit},preload) // actions执行后返回的是promise let res = actions.call(store,store,preload) if(!isPromise(res)){ return Promise.resolve(res) } return res }) }) }
和 mutations 一样,也可能会有多个重名的 actions。区别是 actions 执行完后返回的是一个 promise
命名空间
命名空间的用法,添加 namespaced:true
// store.js aCount: { namespaced:true, state: { count: 1 }, mutations: { mutationsAdd(state, preload) { state.count += preload } }, modules: { cCount: { namespaced:true, state: { count: 1 }, mutations: { mutationsAdd(state, preload) { state.count += preload } }, }, } }
页面中就可以使用 $store.commit('aCount/mutationsAdd',1)
调用 aCount 下的 mutationsAdd
$store.commit('aCount/cCount/mutationsAdd',1)
调用 cCount 下的 mutationsAdd
在安装模块的时候,通过检测模块是否定义 namespaced 为 true,来给安装的模块的 actions,mutations 添加命名空间前缀
function getNameSpace(modules,path){ let root = modules.root // 传入的是 根模块 // 当 path 是[],返回空字符串 // 2、当 path 是 [aCount] ,根据path,取得根模块下的对应的 aCount , modules.getChild(aCount) // 然后判断 aCount 模块下是否定义namespaced,有则返回 aCount/ // 当 path 是 [aCount,cCount] ,重复2,然后根据步骤2 取得的子模块,再往下找子模块cCount, // 然后判断 cCount 模块下是否定义namespaced,有则返回 aCount/cCount/ // [] => '' [aCount] => 'aCount/' [aCount,cCount] => 'aCount/cCount' return path.reduce((module,current) =>{ root = root.children[current] return root.namespaced?(module+current+'/'):'' },'') } function installModules(store,modules,path,root){ ··· // 所以这里先根据path取得设置了 namespaced 的模块名字,拼接后注册到 mutations,actions的名字上 const namespace = getNameSpace(root,path) console.log(namespace) if(modules._raw.mutations){ forEachValue(modules._raw.mutations,(mutations,key) =>{ if(!store._mutations[namespace + key]){ store._mutations[namespace + key] = [] } store._mutations[namespace + key].push((preload) =>{ // store.commit(key,preload) mutations.call(store,getCurrentState(store.state,path),preload) }) }) } }
在 getNameSpace 方法中,传入的参数 path 表示的是有父子关系的模块名组成的数组,通过 path 找到对应的模块,判断是否定义 namespaced 为 true。最终返回命名空间字符串
严格模式
要设置严格模式,在根节点上指定 strict:true 即可。
const store = createStore({ // ... strict: true })
设置了严格模式,没有通过 mutations 改变状态都会弹出一个报错信息(即通过 $store.state.count++ 直接改变状态)
并且官方建议不要在发布环境下启用严格模式
那么这里可以创建一个变量 isCommiting ,用来判断是否通过 mutations 改变状态,只要是执行了 mutations 方法的都去改变 isCommiting。
store.commit = (type,preload) =>{ this.withCommit(() =>{ if(store._mutations[type]){ store._mutations[type].forEach((fn) =>{ fn(preload) }) } }) }
withCommit(fn){ this.isCommiting = true fn() this.isCommiting = false }
上面执行 mutations 之前,isCommiting 为 true,此时只需要知道,当状态变化的时候 isCommiting 不为 true,则提示报错。
这里每个数据状态变化都需要知道 isCommiting 的值,所以需要深度监听整个状态。深度监听会带来一定性能损耗,所以严格模式不建议在生产环境使用。
if(store.strict){ watch(() =>store._store.data,() =>{ console.assert(store.isCommiting,'do not mutate vuex store state outside mutation handlers.') },{deep:true,flush:'sync'}) }
效果如下:
数量(根模块):{{$store.state.count}} <button @click="$store.commit('mutationsAdd',1)">增加</button> <button @click="$store.state.count++">错误增加</button>
插件模式
Vuex 的插件实际上是一个函数,store 作为这个函数的唯一参数。定义插件即在 createStore 中定义 plugins 选项,选项是数组格式,可包含多个插件函数。这些插件函数会在创建 store 时依次执行
const plugins1 = (store) => { // 当 store 初始化后调用 store.subscribe((mutation, state) => { // 每次 mutation 之后调用 // mutation 的格式为 { type, payload } }) } const store = createStore({ // ... strict: true, plugins:[plugins1], })
在插件中可以调用 subscribe 方法,参数是当前调用的 mutation 和调用 mutation 后的 state ,此时的 state 是最新的。并且 subscribe 中的函数都是在每次 mutation 之后调用。
根据这些,来实现 subscribe 方法
class Store { constructor(options) { const store = this // ... store._subscribe = [] store.subscribe = (fn) =>{ store._subscribe.push(fn) } const plugins = options.plugins plugins.forEach(fn => { fn(store) }); }
定义 _subscribe 私有变量数组,用来存储插件中 subscribe 的函数。如果定义了 plugins 选项,那么依次执行选项中的插件函数。
store.commit = (type,preload) =>{ this.withCommit(() =>{ if(store._mutations[type]){ store._mutations[type].forEach((fn) =>{ fn(preload) }) store._subscribe.forEach(fn => { fn({type:type,preload:preload},store.state) }); } }) }
在调用完 mutation 后,循环调用 _subscribe 的函数。这样每个函数中的 state 参数都是最新的
现在来实现一个持久化存储的插件,将状态存储在 sessionStorage 中,页面刷新后,从 sessionStorage 中取出并替换为最新状态。
const customPlugin = (store) =>{ const local = sessionStorage.getItem('vuexState') if(local){ store.replaceState(JSON.parse(local)) } store.subscribe((mutation,state) =>{ sessionStorage.setItem('vuexState',JSON.stringify(state)) }) }
在 store.subscribe 参数函数中,每次调用 mutation 后,将状态存储在 sessionStorage 中。store.replaceState 则是一个替换状态的方法。
replaceState(newState){ this.withCommit(() =>{ this._store.data = newState }) }
总结
Vuex 在项目中用了很久,只知其然不知其所以然,故研究学习并实现出来。
从理解思路到手写出来,然后将实现过程记录下来就有了这篇文章,这个过程断断续续持续了大概一个月,项目和文章基本都是利用下班时间写的,确实挺累的,不过实现出来后往回看,还是学到很多东西,还挺欣慰的;文章有不足的地方还请各位大佬指正;