美文网首页vue
手写Vue2核心(八):vuex实现

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

作者: 羽晞yose | 来源:发表于2021-03-30 17:57 被阅读0次

    准备工作

    如果前面有自行实现过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的逻辑,具体是将模块中的参与合并到全局上,对于用户传入配置分别进行以下处理:

    • state: 将模块中的state合并到全局,通过模块名称作为全局state的键,并设置成响应式
    • getters:将模块中的getters合并到全局,同名的属性,后面的会覆盖前面的,并设置到自行创建的vue.computed上、
    • mutations和actions:实现逻辑一致,就是将其放入栈中,等调用的时候依次调用
      所以逻辑总结起来就两步:将用户传入的配置合并到全局,将数据设置为响应式
    // 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持久化缓存?

    • vuex无法实现持久化缓存,页面刷新的时候就会清除已经保存的数据。而有一个插件就是专门用于解决vuex持久化问题的:vuex-persist
      那为什么有localstorage,还需要借助vuex-persist呢?
    • 因为localstorage数据变了页面数据也不会自动刷新,并非响应式的

    插件接收一个数组,数组每一项均为函数,如果有多个插件,自上而下执行
    官方提供了一个开发使用的插件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 })
    +   }
    }
    

    相关文章

      网友评论

        本文标题:手写Vue2核心(八):vuex实现

        本文链接:https://www.haomeiwen.com/subject/ygmghltx.html