vue

手写Vue2核心(八):vuex实现

2021-03-30  本文已影响0人  羽晞yose

准备工作

如果前面有自行实现过vue-router,那这里就没有工作了,否则移步手写Vue2核心(七):vue-router实现

VueRouter与install

vuex的引用比vue-router拆分得细一点,但实现原理等同于vue-router,一些重复的实现原理就不过多赘述了,直接上代码

// vuex/index.js
import { Store, install } from './store'

// 两种导出方式,方便用户可以通过 import {Store},或者通过 import Vuex,通过 Vuex.Store 和 Vuex.install
export {
    Store,
    install
}

export default {
    Store,
    install
}
// vuex/store.js
import { applyMixin } from './mixin'

export let Vue // 此 Vue 是用户注册插件时传入的 Vue 构造函数
export class Store {
    constructor (options) {
        console.log(options)
    }
}

// 搞得太花里胡哨,但最终还是在 vuex/index.js 中将 install和store 导出,所以这里怎么华丽花哨并不重要,能导出install进行mixin注入即可
// 实现原理依旧等同于 vue-router
export const install = (_Vue) => {
    Vue = _Vue
    applyMixin(Vue)
}
// vuex/minxin.js,等同于vue-router的install.js
function vueInit () {
    if (this.$options.store) {
        this.$store = this.$options.store // 给根属性增加 $store 属性
    } else if (this.$parent && this.$parent.$store) {
        this.$store = this.$parent.$store
    }
}

export const applyMixin = (Vue) => {
    Vue.mixin({
        beforeCreate: vueInit // 继续拆,原理还是一样,通过查找父组件的$store属性来判断获取实例
    })
}

响应式数据,实现state与getters

vuexstate,相当于datagetters相当于computed,因此getters是具备缓存,且不同于computed,是不允许设置值的(vuex中提供的commit和dipath都不会直接操作getters)

vuex是衍生于Vue的,只能供Vue使用,其主要原因在于实现中,是通过创建一个新的Vue实例,来挂载到Store._vm上,这样做的原因是Store是具备响应式数据变化的,当数据变化时,会触发视图渲染

页面中对Store取值时,会触发Vue的依赖收集,但是state本身是没必要去挂载到Vue._vm上的(不会变为实例属性)。Vue中提供了$符,来设置这些属性不会被Vue代理。文档传送门:vue.data

getters是以函数的形式来定义取值的方法,具备缓存功能。而由于所有属性均为函数,所以需要执行才能取值,并且不能默认帮用户全部执行,否则取值就会各种不正确,而是应该在使用时再进行取值

通过Object.defineProperty来对getters进行劫持,当访问属性时,去调用其对应的函数执行。而getters是具备缓存功能的,所以需要将所有getters中定义的属性都放到计算属性中

// vuex/store.js
+ const forEachValue = (obj, cb) => {
+     Object.keys(obj).forEach(key => cb(obj[key], key))
+ }

export let Vue // 此 Vue 是用户注册插件时传入的 Vue 构造函数
export class Store {
    constructor (options) {
+       const computed = {}

+       // getters实现
+       this.getters = {}
+       forEachValue(options.getters, (value, key) => {
+           // 通过计算属性替换直接执行函数获取值的形式,计算属性具备缓存
+           computed[key] = () => value.call(this, this.state)

+           // value 是函数,getter 获取的是属性值,所以在获取的时候再去执行函数获取其对应的值
+           // 而且这样操作是每次取值时都能取到最新结果,否则直接执行函数取值后面就没法变更了
+           Object.defineProperty(this.getters, key, {
+               // 这里要用箭头函数保证this指向,否则里面就不能用 call(this)
+               get: () => {
+                   // 用call是为了防止用户在 getters 中使用了this,当然正常都是通过传入的state state.xxx,而不是 this.state.xxx
+                   // return value.call(this, this.state) // 每次取值都会重新执行用户方法,性能差,所以需要替换成计算属性取值
+                   return this._vm[key]
+               }
+           })
+       })

+       // 用户肯定是先使用 Vue.use,再进行 new Vue.Store({...}),所以这里的 Vue 已经是可以拿到构造函数的了
+       // 必须放到f forEachValue 后面,确保 computed 已经有值
+       this._vm = new Vue({
+           data: {
+               // Vue中不会对 $开头的属性进行代理操作(不会挂到_vm上进行代理)
+               // 但是其属性依旧会被代理到(页面获取时依然会被收集依赖),因为我们不会直接操作state,而是操作state.xxx,性能优化
+               $$state: options.state
+           },
+           computed
+       })
+   }
+   get state () { // 属性访问器
+       return this._vm._data.$$state
+   }
}

实现commit与dispatch

简单的实现,没啥好说的,唯一需要讲一下的是这里类的箭头函数,因为我们使用commitdispatch时,是可以通过解构赋值的方式来调用函数的,但这样取值会导致this指向当前执行上下文
而ES7中的箭头函数是通过词法解析来决定this指向的,所以解构赋值取得的this会依旧指向Store

mutationsdispatch实现:

export class Store {
    constructor (options) {
        // code...

+       // mutations实现
+       this.mutations = {}
+       this.actions = {}
+
+       forEachValue(options.mutations, (fn, key) => {
+           this.mutations[key] = payload => fn.call(this, this.state, payload)
+       })
+
+       forEachValue(options.actions, (fn, key) => {
+           this.actions[key] = payload => fn.call(this, this, payload)
+       })
    }
    get state () { // 属性访问器
        return this._vm._data.$$state
    }
+   commit = (type, payload) => { // ES7语法,类的箭头函数,表示this永远指向store实例
+       this.mutations[type](payload)
+   }
+   dispatch = (type, payload) => {
+       this.actions[type](payload)
+   }
}

ES7类的箭头函数示例:

// ES7 类的箭头函数编译结果示例
window.name = 'window'

function Store () {
    this.name = 'Store'
    
    // 注释掉下面四行,则commit方法中的this会指向window
    let { commit } = this
    this.commit = () => { // 获取时,实例上的属性优先于原型上的
        commit.call(this) // 通过call,将commit执行时this指向Store实例
    }
}

Store.prototype.commit = function () {
    console.log(this.name)
}

let {commit} = new Store() // 这里解构取得的commit,this指向的window

// 上面解构赋值后相当于这样,所以调用的时候this指向其调用的上下文环境,所以为window
// let commit = Store.prototype.commit
commit() // 实例上也有一个commit,commit通过箭头函数绑定了this指向

写到这里,一个简易版的vuex就实现了,但vuex里有一个东西叫模块modules,这东西的实现,导致上面这个简易版的vuex需要完全重写(只是重写Store
但是上面的代码是很好理解的,所以分开来说,下面开始真正实现官方vuex

vuex中模块的用法

modules,模块化管理,具备命名空间进行数据隔离。通过使用namespaced进行隔离,没有指定该属性中mutations和actions会影响全局
而对到state,会将模块名作为键,将其state作为值,添加到全局上
具体直接看文档吧,说的很清楚了。官方文档传送门:modules

export default new Vuex.Store({
    state: { // data
        name: 'state',
        age: 10
    },
    getters: { // computed
        gettersAge (state) {
            return state.age + 20
        }
    },
    mutations: { // 同步变更
        changeAge (state, payload) {
            state.age = state.age + payload
        }
    },
    actions: {
        changeAge ({ commit }, payload) {
            setTimeout(() => {
                commit('changeAge', payload)
            })
        }
    },
    modules: {
        a: {
            state: {
                name: 'modules-a',
                age: 10
            },
            getters: {
                getName (staste) {
                    return staste.name
                }
            },
            mutations: { // 同步变更
                changeAge (state, payload) {
                    state.age = state.age + payload
                }
            },
            modules: {
                c: {
                    namespaced: true, // 有命名空间
                    state: {
                        name: 'modules-a-c',
                        age: 40
                    }
                }
            }
        },
        b: { // 没有命名空间,则changeAge方法也会影响到该模块中的state属性值
            namespaced: true, // 有命名空间
            state: {
                name: 'modules-b',
                age: 20
            },
            mutations: { // 同步变更
                changeAge (state, payload) {
                    state.age = state.age + payload
                }
            }
        }
    }
})

vuex中的模块收集

其实就是转换成一个树形结构来进行管理,采用递归的方式,将用户传入的store参数转换为树形结构。每个模块都被重新包装成一个module

// module/module.js
export default class Module {
    constructor (rawModule) {
        this._raw = rawModule
        this._children = {}
        this.state = rawModule.state
    }
    getChild (key) { // 获取子节点中的某一个
        return this._children[key]
    }
    addChild (key, module) { // 添加子节点
        this._children[key] = module
    }
}
// module/module-collection.js
import { forEachValue } from '../util'
import Module from './module'

// 将传入的store转成树型结构 _row为该模块键值,_children为该模块modules中的键值(也转为树形结构),_state为该模块中写的state,深度优先
export default class ModuleCollection {
    constructor (options) { // 遍历用户的属性对数据进行格式化操作
        this.root = null
        this.register([], options)
        console.log(this.root)
    }
    register (path, rootModule) {
        const newModule = new Module(rootModule)

        if (path.length === 0) { // 初始化
            this.root = newModule
        } else {
            // 将当前模块定义在父亲身上
            const parent = path.slice(0, -1).reduce((memo, current) => {
                return memo.getChild(current)
            }, this.root)

            parent.addChild(path[path.length - 1], newModule)
        }

        // 如果还有modules就继续递归
        if (rootModule.modules) {
            forEachValue(rootModule.modules, (module, moduleName) => {
                this.register(path.concat(moduleName), module)
            })
        }
    }
}
store构造成树形结构

vuex中的模块实现

这里实现的时没有namespace的逻辑,具体是将模块中的参与合并到全局上,对于用户传入配置分别进行以下处理:

// store.js
/**
 * @param {Object} store store实例
 * @param {Array} path 模块父子关系,初始为空
 * @param {Object} module 转化为树结构后的模块
 * @param {*} rootState 全局store的state
 * @descript 将模块中的mutations和actions都合并到全局上,通过栈的方式依次push,调用的时候依次执行
 * 将模块中的 state 和 getters 也合并到全局上,state会将模块名设置为全局的键,而getters则是没用namespace的话会合并到全局,后面同名的会覆盖前面的
 */
const installMudole = (store, path, module, rootState) => {
    // store => [], store.modules => ['a'], store.modules.modules => ['a', 'c']
    if (path.length > 0) { // 是子模块
        const parent = path.slice(0, -1).reduce((memo, current) => {
            return memo[current]
        }, rootState)

        // vue-router是使用Vue.util.defineReactive,所以这里写成Vue.util.defineReactive(parent, path[path.length - 1], module.state)也可以
        // 因为目标就是要把模块定义成响应式的,源码路径:/src/core/util
        // 这里不用set也能实现响应式,因为下面会把 state 设置到创建的 Vue 上来实现响应式,不过源码中就是用的set
        Vue.set(parent, path[path.length - 1], module.state)
        // parent[path[path.length - 1]] = module.state // 但是这样操作子模块不是响应式的
    }

    module.forEachMutation((mutation, key) => {
        store.mutations[key] = store.mutations[key] || []
        store.mutations[key].push(payload => mutation.call(store, module.state, payload))
    })
    module.forEachAction((action, key) => {
        store.actions[key] = store.actions[key] || []
        store.actions[key].push(payload => action.call(store, store, payload))
    })
    module.forEachChildren((childModule, key) => {
        installMudole(store, path.concat(key), childModule, rootState) // childModule.state
    })
    // 没用namespace,则所有模块的getters默认都会合并到一个对象里,都是直接getters.xxx即可,而不用getters.a.xxx
    module.forEachGetters((getterFn, key) => {
        store.wrapGetters[key] = () => getterFn.call(store, module.state)
    })
}

export class Store {
    constructor (options) {
        // 格式化用户传入的配置,格式化成树结构
        this._modules = new ModuleCollection(options)

        this.mutations = {} // 将用户所有模块的mutation都放到这个对象中
        this.actions = {} // 将用户所有模块的action都放到这个对象中
        this.getters = {}
        this.wrapGetters = {} // 临时变量,存储getters
        const state = options.state // 用户传入的全局state,还是非响应式的

        // 将所有模块中的mutations和actions合并到全局上,合并state和getters到全局上
        installMudole(this, [], this._modules.root, state)
        // 初始化与重置(源码中因为需要对热更新进行判断,热更新需要重置,但这里就是单纯的初始化)
        // 主要干两件事:将state设置成响应式挂到store._vm上(通过new Vue),将getters挂到computed上
        resetStoreVM(this, state)
    }
    get state () { // 属性访问器
        return this._vm._data.$$state
    }
}

function resetStoreVM (store, state) {
    const computed = {}

    forEachValue(store.wrapGetters, (fn, key) => {
        computed[key] = fn // 将是所有的属性放到computed中
        Object.defineProperty(store.getters, key, {
            get: () => store._vm[key]
        })
    })

    // 用户肯定是先使用 Vue.use,再进行 new Vue.Store({...}),所以这里的 Vue 已经是可以拿到构造函数的了
    // 必须放到f forEachValue 后面,确保 computed 已经有值
    store._vm = new Vue({
        data: {
            // Vue中不会对 $开头的属性进行代理操作(不会挂到_vm上进行代理)
            // 但是其属性依旧会被代理到(页面获取时依然会被收集依赖),因为我们不会直接操作state,而是操作state.xxx,性能优化
            $$state: state
        },
        computed
    })
}
parent[path[path.length - 1]] = module.state处理,未定义成响应式

实现commit和dispatch

记录了namespace后,在获取与调用对应方法时,则是通过路径名+方法的方式来调用。比如commit('a/getterAge', 20)dispatch也是如此。因此在初始化installMudole时,需要将mutations/actions/getters都加上对应路径。当然这里的实现是不健全的,vuex中如果存在namespace,则dispatch里使用commit,是不需要带上相对路径的,会去找自己的mutations中对应的方法,这里并未实现

// store.js
const installMudole = (store, path, module, rootState) => {
+   const namespace = store._modules.getNamespace(path)
    // code...

    module.forEachMutation((mutation, key) => {
+       store.mutations[namespace + key] = store.mutations[namespace + key] || []
+       store.mutations[namespace + key].push(payload => mutation.call(store, module.state, payload))
    })
    module.forEachAction((action, key) => {
+       store.actions[namespace + key] = store.actions[namespace + key] || []
+       store.actions[namespace + key].push(payload => action.call(store, store, payload))
    })

    // 没用namespace,则所有模块的getters默认都会合并到一个对象里,都是直接getters.xxx即可,而不用getters[a/xxx]
    module.forEachGetters((getterFn, key) => {
+       store.wrapGetters[namespace + key] = () => getterFn.call(store, module.state)
    })
}

export class Store {
+   commit = (type, payload) => { // ES7语法,类的箭头函数,表示this永远指向store实例
+       if (this.mutations[type]) {
+           this.mutations[type].forEach(fn => fn(payload)) // 不同于之前,现在的mutations已经是个包含模块中mutations的数组
+       }
+   }
+   dispatch = (type, payload) => {
+       if (this.actions[type]) {
+           this.actions[type].forEach(fn => fn(payload))
+       }
+   }
}

module中添加获取命名空间,构建成树结果时可进行命名空间判断,是否需要添加成a/method的形式,否则调用路径依旧为全局直接调方法名的方式

// module/module.js
export default class Module {
+   get namespaced () {
+       return !!this._raw.namespaced
+   }
}

// module/module-collection.js
export default class ModuleCollection {
+   getNamespace (path) {
+       let module = this.root
+       return path.reduce((namespaced, key) => {
+           module = module.getChild(key)
+           // 如果父模块没有namespaced,子模块有,那么调用的时候就只需要写子模块,比如 c/ 否则就是a/c/
+           return namespaced + (module.namespaced ? key + '/' : '')
+       }, '')
+   }
}

插件实现

这里不是在实现vuex,而是在实现自己开发一个vuex插件,因为不会实现plugin,所以需要自行切换成原生vuex

或许大多数人都不知道vuex插件,官网高阶中有写,传送门:vuex插件

面试题:如何实现vuex持久化缓存?

插件接收一个数组,数组每一项均为函数,如果有多个插件,自上而下执行
官方提供了一个开发使用的插件logger,当然因为基本都会安装vue-devtools,所以并不会用到

vuex主要提供了两个方法来让用户自定义插件,分别是subscribereplaceStatesubscribe用于订阅触发commit事件,replaceState用于初始化时替换页面数据
支持自定义模式,这里replaceState只实现storage

插件的实现思路比较简单,就是发布订阅。但是有一个问题,就是installMudole中,之前的实现是通过用户定义的state(挂载store._vm._data.$$state上),初始化模块时,会为commit注册事件
replaceState的实现,更改的是store上的state,导致视图渲染无效。因此需要在commit时重新去store上获取对应的值

+ // 最开始定义的时候,用的是用户传入的 state,但是一旦执行了replaceState,则 $$state 被替换
+ // Vue.set(parent, path[path.length - 1], module.state) 用的是最初传入定义成响应式的state(也就是rootState),而replaceState设置的是store的state
+ // 一个是 module的state,一个是变更的store的state,就会导致commit时数据取值不正确(一直是旧数据),所以需要去store上重新获取
+ const getState = (store, path) => { // store.state获取的是最新状态
+     return path.reduce((rootState, current) => {
+         return rootState[current]
+     }, store.state)
+ }

const installMudole = (store, path, module, rootState) => {
    // code...

    module.forEachMutation((mutation, key) => {
        store.mutations[namespace + key] = store.mutations[namespace + key] || []
-       store.mutations[namespace + key].push(payload => mutation.call(store, module.state, payload))
+       store.mutations[namespace + key].push(payload => mutation.call(store, getState(store, path), payload))
    })
    // 没用namespace,则所有模块的getters默认都会合并到一个对象里,都是直接getters.xxx即可,而不用getters[a/xxx]
    module.forEachGetters((getterFn, key) => {
-       store.wrapGetters[namespace + key] = () => getterFn.call(store, module.state)
+       store.wrapGetters[namespace + key] = () => getterFn.call(store, getState(store, path))
    })
}

export class Store {
    constructor (options) {
        // code...
+       this._subscribe = [] // 因为能传入多个插件,所以会有多个订阅

+       // 默认插件就会被执行,从上往下执行
+       options.plugins.forEach(plugin => plugin(this))
    }
+   subscribe (fn) {
+       this._subscribe.push(fn)
+   }
+   replaceState (newState) {
+       this._vm._data.$$state = newState
+   }
    commit = (type, payload) => { // ES7语法,类的箭头函数,表示this永远指向store实例
        if (this.mutations[type]) {
            this.mutations[type].forEach(fn => fn(payload)) // 不同于之前,现在的mutations已经是个包含模块中mutations的数组

            // 变更后,触发插件订阅执行
+           this._subscribe.forEach(fn => fn({ type, payload }, this.state))
        }
    }
}

断言 - 非法操作实现

原生vuex,如果采用严格模式strict: true,那么在mutations中采用异步待会将会报错,非合法操作也会报错
主要通过在store_withCommiting来包裹合法操作赋值,实现思路是通过watcher进行监听(同步,深度),store中添加标记位,当数据变化时,如果断言为false则会出现报错。挺好懂的,文章已经这么长了,能看到这里你估计只是来复习底层的,直接看代码

const installMudole = (store, path, module, rootState) => {
    if (path.length > 0) { // 是子模块
-       Vue.set(parent, path[path.length - 1], module.state)
+       store._withCommiting(() => Vue.set(parent, path[path.length - 1], module.state))
    }
}

export class Store {
    constructor (options) {
        // code...
+       this.strict = options.strict
+       this._commiting = false
+       this._withCommiting = function (fn) {
+           const commiting = this._commiting
+           this._commiting = true
+           fn() // 修改状态的逻辑
+           this._commiting = !commiting
+       }
    }
    replaceState (newState) {
-       this._vm._data.$$state = newState
+       this._withCommiting(() => (this._vm._data.$$state = newState))
    }
    commit = (type, payload) => { // ES7语法,类的箭头函数,表示this永远指向store实例
        if (this.mutations[type]) {
-           this.mutations[type].forEach(fn => fn(payload))
+           // 执行_withCommiting时,_commiting为true,所以不会报错
+           // 如果mutations中有异步代码,那么异步代码执行后,触发watcher监听变化,此时的_commiting会为false,就会报错
+           this._withCommiting(() => this.mutations[type].forEach(fn => fn(payload))) // 不同于之前,现在的mutations已经是个包含模块中mutations的数组

            // 变更后,触发插件订阅执行
            this._subscribe.forEach(fn => fn({ type, payload }, this.state))
        }
    }
}

function resetStoreVM (store, state) {
    // code...

+   if (store.strict) {
+       // 因为watcher执行时异步的,需要加上 {sync: true} 设置为同步,文档没有,需要自行看源码
+       store._vm.$watch(() => store._vm._data.$$state, () => {
+           console.assert(store._commiting, '非法操作')
+       }, { sync: true, deep: true })
+   }
}
上一篇下一篇

猜你喜欢

热点阅读