vuex 实现原理

125 阅读5分钟

我们可以利用 Vuex 实现状态共享,那么,它是怎么实现的呢?

创建项目

vue create vuex-demo

// Vue CLI v4.5.13
// ? Please pick a preset: Manually select features
// ? Check the features needed for your project: Choose Vue version, Babel, Vuex
// ? Choose a version of Vue.js that you want to start the project with 2.x
// ? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
// ? Save this as a preset for future projects? No

Vue 入口文件注入 Vuex 方式

main.js 根组件注入了 store

import Vue from 'vue'
import App from './App.vue'
import store from './store'

Vue.config.productionTip = false

new Vue({
  store, // 目的是所有组件都能共享到这个 store,所以从根组件注入
  render: h => h(App)
}).$mount('#app')

为什么不直接挂载到 Vue.prototype 上呢?因为啊,我的 vue 可能有多个实例,如果都挂在原型,那不是乱套了嘛!

Vuex 中的模块

模块依赖图

store.js 改造

store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex) // 说明 vuex 有 install 函数

export default new Vuex.Store({ // 说明 vuex 有一个 Store 类
  strict: true, // 开启严格模式
  state: { // 共享状态。类似组件的 data,只不过这个是全局的
    age: 18
  },
  getters: { // 计算属性,类似组件的 computed,全局的
    getAge(state) {
      return state.age - 1;
    }
  },
  mutations: { // 同步更新状态,mutations 是唯一更改状态的方法(严格模式下只能 mutations 更改)
    changeAge(state, payload) {
      state.age += payload;
    }
  },
  actions: { 
    changeAgeAsync({ commit }, payload) {
      return new Promise((resolve) => {
        setTimeout(() => {
          commit('changeAge', payload);
          resolve('成功啦');
        }, 1000);
      });
    }
  },
  modules: {
  }
})

页面使用

App.vue

<template>
  <div id="app">
    我的年龄是:{{ $store.state.age }}
    我弟弟的年龄是:{{ $store.getters.getAge }}
    <button @click="$store.commit('changeAge', 10)">同步修改</button>
    <button @click="$store.dispatch('changeAgeAsync', 10)">异步修改</button>
    <button @click="$store.state.age++">恶意直接修改(严格模式下会报错)</button>
  </div>
</template>

mutations 和 actions 的区别

比如有场景:

  • A 页面有个获取列表的操作,啪啪啪一顿请求接口后,store.commit('changeAge', newVal) 触发了一次 mutations 更新
  • B 页面也有一个获取列表的操作,又啪啪啪一顿请求接口后,store.commit('changeAge', newVal) 触发了一次 mutations 更新 在两个组件都进行了这顿啪啪啪的请求接口,,我希望提取出来,于是我使用 actions 封装了请求的方法,在 A,B 页面的更新方式变为
store.dispatch('changeAge', newVal);

可以看到,**mutations 主要用于同步更新状态,使用 commit 触发,而 actions 主要用于异步更新状态,最后还是使用 mutations 更新数据,使用 dispatch 触发,需要注意的是,mutations 中写异步代码也能更新数据,但是在 使用 devtools 调试的时候会有问题,页面数据更新了,调试时的数据还是原来的,为了解决这个问题,才有了 actions。**看以下场景,体会 actions 的优势~

module 和 namespaced

我们可以使用 module 来分割模块间的配置和变量,但是如果父子模块拥有同样的的 mutations,比如说 changeAge,那么当我 commit('changeAge') 时,会同时触发父子模块中所有 changeAge 操作(被收集为一个数组),解决方法是增加命名空间,它会给每个模块的 changeAge 前拼接模块路径,比如 /a/changeAge。

Vuex plugin 实现持久化存储

vuex 有个比较严重的问题,它不能进行持久化存储能力,刷新页面状态就会重置~ 我们可以利用 vuex 的插件能力,通过 store.subscribe 实现状态收集,通过 store.replaceState 实现状态的替换,比如下面做一个持久化存储的插件。

const plugin = () => {
  return store => { // 每次刷新初始化 vuex 都会走这里
    let preState = JSON.parse(localStorage.getItem('@@VUEX_STATE')); // 取缓存的值

    if (preState) { 
      store.replaceState(preState); // 取到则替换默认值
    }
    
    store.subscribe((type, state) => { // 每次操作都会触发 subscribe 回调
      // type: 触发的 mutations 事件名和传递的值的,每次修改都会触发
      // state: 更新后的 state 
      console.log(type, state);
      localStorage.setItem('@@VUEX_STATE', JSON.stringify(state));
    });
  }
}


export default new Vuex.Store({
  plugins: [plugin()],
  strict: true, 
  state: {
    age: 18
  },
  getters: {
    getAge(state) {
      return state.age - 1;
    }
  },
  mutations: { 
    changeAge(state, payload) {
      state.age += payload;
    }
  },
  actions: { 
  }
})

实现 Vuex

我们从上面的使用中可以看出来,实现 Vuex 需要以下几步:

  • 需要先实现一个 Vuex 插件(可以 use),里面包含用户注入根组件使用的 Store 和 install 函数,将用户注入的 store 变为每个组件的 sotre(页面可以this.sotre(页面可以 this.store 调用)
  • 实现 state,getters,actions,mutations

实现代码

Vuex 入口文件 src/ys-vuex/index.js

  1. 提供 Store 类,保存着用户状态树,mutations 树和 actions 树,当然还有用户的 getter 方法~
  2. 提供装载子级树的方法 installModule,并使用命名空间+key作为 getters,mutations,actions 的方法名。
  3. 提供 setStoreVM 方法,该方法是 vuex 的核心,它声明了一个 vue 实例挂载到自己 store._vm 上,并且页面取 vuex 值会从被代理到 store._vm.xxx,以此触发依赖收集和视图更新,而 Vuex 的getter 也被挂载为 vue 的 computed 方法,以此利用 vue 的计算属性,实现取值缓存和动态更新,可以看到,vuex 是借助了 vue 的核心能力的。
  4. 提供 vuex 初始化方法 install,该方法会使用用户传递来的 Vue 类(不写死,避免版本冲突),然后通过 Vue.mixin 注入 beforeCreate 方法,将根组件注册在 this.options中的store,挂载为每个组件实例的options 中的 store,挂载为每个组件实例的 store,以便组件模板内直接使用 $store 变量。
code
import { forEachValue } from "./utils";
import ModuleCollection from "./module/module-collection";

let Vue;

// root = {
//     _raw:默认显示用户原始的内容
//     _children:{
//         a:{ _raw: a模块的原始内容, _children:{},state:aState},
//         a:{ _raw: b模块的原始内容, _children:{},state:bState}
//     },
//     state:'根模块的状态'
// }

class Store {
  constructor(options) {
    const store = this;
    // 1. 对用户的数据进行格式化操作
    this._module = new ModuleCollection(options);

    // 2. 收集所有模块的 action 和 mutation、getters
    this._actions = {}; // 收集用户定义的所有 actions
    this._mutations = {};  // 收集用户定义的所有 mutations
    this._wrappedGetters = {}; // 收集用户定义的所有 getter
    let state = options.state; // 根状态

    // 从根递归装载每个模块内 store 的 _actions,_mutations,_wrappedGetters
    installModule(store, [], store._module.root, state);

    // 给 store 增加 _vm「_vm 是一个 vue 实例,我们使用了 vue 的数据劫持和计算属性」
    setStoreVM(store, state);

    console.warn('整体的 Store', this);
  }

  commit = (type, payload) => { //  this永远指向store容器
    console.log(this._mutations, type);
    this._mutations[type].forEach(fn => fn(payload))
  }
  dispatch = (type, payload) => { //  this永远指向store容器
    this._actions[type].forEach(fn => fn(payload))
  }
  get state() {
    return this._vm._data.$$state // 响应式数据
  }
}

/**
 * 
 * @param {*} store  store属性
 * @param {*} path   构造的递归栈结构
 * @param {*} module 当前安装的模块
 */
const installModule = (store, path, module, rootState) => { // 注册所有的getter,mutation,actions
  // 计算当前的命名空间,在订阅的时候,每个 key 前面都增加一个命名空间
  // root -> 从根开始查找 path 路径 ['a', 'c'] 如果都有 namespaced,拼成 a/c
  let namespaced = store._module.getNamespace(path); // 从模块开始进行 nameSpace 路径拼接
  console.error(namespaced);

  if (path.length > 0) { // 循环的是子模块 [a]
    // 给rootState 添加模块的属性
    let parent = path.slice(0, -1).reduce((memo, current) => memo[current], rootState)
    // 后续数据可能是动态注册的模块,如果原来没有属性,新增了不会导致视图更新
    // 这一步是把子模块的名字定义在父亲上,以便$store.state.a.b.c.xxx能取到root.a.b.c模块的值
    Vue.set(parent, path[path.length - 1], module.state)
  }
  
  module.forEachMutation((mutation, key) => {
    store._mutations[namespaced + key] = (store._mutations[namespaced + key] || [])
    store._mutations[namespaced + key].push((payload) => {
      mutation(module.state, payload); // mutation执行
    })
  });
  module.forEachAction((action, key) => {
    store._actions[namespaced + key] = (store._actions[namespaced + key] || [])
    store._actions[namespaced + key].push((payload) => {
      action(store, payload); // action执行
    })
  });
  module.forEachGetter((getter, key) => {
    store._wrappedGetters[namespaced + key] = function () {
      return getter(module.state); // 计算属性执行
    }
  });

  console.log(store._actions);
  module.forEachChildren((child, key) => { // 递归
    installModule(store, path.concat(key), child, rootState);
  })
}

function setStoreVM(store, state) {
  let computed = {};
  store.getters = {}; 
  forEachValue(store._wrappedGetters, (fn, key) => {
    computed[key] = () => {
      return fn(store.state);
    }
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key]
    })
  })
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  })
}

export default {
  Store,
  install(_vue) {
    Vue = _vue;

    // Vue.prototype.$store 比较暴力 全部都添加了,我只希望覆盖到自己的子组件即可
    Vue.mixin({
      // 实现所有的组件能共享store属性
      beforeCreate() {
        const options = this.$options;
        if (options.store) { // 根组件
          this.$store = options.store; // 根组件上有一个$store属性
        } else { // 要么是子组件要么是没有注册store选项的另一个根组件 (排除掉平级组件)
          if (this.$parent && this.$parent.$store) {
            this.$store = this.$parent.$store
          }
        }
      }
    })
  }
}


遍历的辅助方法 src/ys-vuex/utils/index.js

code
export const forEachValue = (obj, callback) => {
  Object.keys(obj).forEach(key => {
      callback(obj[key], key);
  })
}


模块树生成方法 src/ys-vuex/module/module-collection.js

code
export const forEachValue = (obj, callback) => {
  Object.keys(obj).forEach(key => {
      callback(obj[key], key);
  })
}


模块初始化方法 src/ys-vuex/module/module.js

code
import { forEachValue } from "../utils";

export default class Module {
  constructor(rootModule) {
    this._raw = rootModule;
    this._children = {}
    this.state = rootModule.state
  }
  get namespaced() {
    return !!this._raw.namespaced; // 用于标识这个模块是否有 namespaced 属性
  }
  getChild(key) {
    return this._children[key]
  }
  addChild(key, module) {
    this._children[key] = module;
  }
  forEachMutation(callback) {
    if (this._raw.mutations) {
      forEachValue(this._raw.mutations, callback)
    }
  }
  forEachAction(callback) {
    if (this._raw.actions) {
      forEachValue(this._raw.actions, callback)
    }
  }
  forEachGetter(callback) {
    if (this._raw.getters) {
      forEachValue(this._raw.getters, callback)
    }
  }
  forEachChildren(callback) {
    if (this._children) {
      forEachValue(this._children, callback)
    }
  }
}


测试文件

src/main.js

code
import Vue from 'vue'
import App from './App.vue'
import store from './store'

Vue.config.productionTip = false

new Vue({
  store, // 目的是所有组件都能共享到这个 store,所以从根组件注入
  render: h => h(App)
}).$mount('#app')


src/store/index.js

code
import Vue from 'vue'
// import Vuex from 'vuex'
import Vuex from '@/ys-vuex';
import moduleA from './modules/module-a';
import moduleB from './modules/module-b';

Vue.use(Vuex) // 说明 vuex 有 install 函数

export default new Vuex.Store({ // 说明 vuex 有一个 Store 类
  strict: true, // 开启严格模式
  state: { // 共享状态。类似组件的 data,只不过这个是全局的
    age: 18
  },
  getters: { // 计算属性,类似组件的 computed,全局的
    getAge(state) {
      return state.age - 1;
    }
  },
  mutations: { // 同步更新状态,mutations 是唯一更改状态的方法(严格模式下只能 mutations 更改)
    changeAge(state, payload) {
      state.age += payload;
    }
  },
  actions: { 
    changeAgeAsync({ commit }, payload) {
      return new Promise((resolve) => {
        setTimeout(() => {
          commit('changeAge', payload);
          resolve('成功啦');
        }, 1000);
      });
    }
  },
  modules: {
    a: moduleA,
    b: moduleB
  }
})


src/store/modules/module-a.js

code
// 加了 namespaced 之后,想修改触发本模块下 changeAge,需要 commit('a/changeAge', newVal);
// 特殊情况,比如没有 a 命名空间,但是有 c 的命名空间,那么页面就直接 commit('c/changeAge', newVal) 即可
// 不过取值依旧要 $state.a.c.cAge
export default {
  namespaced: true,
  state: {
    aAge: 200
  },
  mutations: {
    changeAge(state, payload) {
      state.aAge += payload;
    }
  },
  modules: {
    c: {
      namespaced: true, // commit('a/c/changeAge', newVal); 
      state: {
        cAge: 400
      },
      mutations: {
        changeAge(state, payload) {
          state.cAge += payload;
        }
      }
    }
  }
}


src/store/modules/module-b.js

code
// 加了 namespaced 之后,想修改触发本模块下 changeAge,需要 commit('b/changeAge', newVal);
export default {
  namespaced: true,
  state: {
    bAge: 300
  },
  mutations: {
    changeAge(state, payload) {
      state.bAge += payload;
    }
  }
}


src/App.vue

code
<template>
  <div id="app">
    我的年龄是:{{ $store.state.age }}
    我弟弟的年龄是:{{ $store.getters.getAge }}
    <button @click="$store.commit('changeAge', 10)">根同步修改</button>
    <button @click="$store.dispatch('changeAgeAsync', 10)">根异步修改</button> 
    <button @click="$store.commit('a/changeAge', 10)">a 模块状态同步修改</button>
    <button @click="$store.commit('b/changeAge', 10)">b 模块状态同步修改</button> 
    <button @click="$store.commit('a/c/changeAge', 10)">a.c模块状态同步修改</button> 
    <button @click="$store.state.age++">恶意直接修改(严格模式下会报错)</button>
  
    <div>A模块中的状态: {{ $store.state.a.aAge }}</div>
    <div>B模块中的状态: {{ $store.state.b.bAge }}</div>
    <div>A.C模块中的状态: {{ $store.state.a.c.cAge }}</div>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  },
  mounted() {
    console.log(this)
  }
}
</script>