源码篇(五):手写vuex版mini源码分析vuex的执行过程。附送简版vuex源码

11,989 阅读10分钟

适合人群

本文适合1.5~4年的开发经验vue前端开发人员。本文不做vuex基本使用的讲解,如没有接触过vue或vuex,建议学习后再看本文。

前言

关于全局缓存变量的储存,在传统时代,根据cookies,localstorage,session等,已经有了很友好的方案,基本已经可以完成变量的存储。但是在数据驱动的时代,他们却无法满足我们的需求。以vue为实例,所有的数据需要双向绑定才能完成数据驱动,但是明显cookies,localstorage,session等,他是无法完成双向绑定的,那就意味着,但数值变化时,我们的页面无法同步展示,因为我们压根监听不到他们。

那么解决方案是什么呢?每个单页都有自己的完善的状态处理器,例如vue的vuex,react的react-redux等。 我们先总结一下vue到底有共享数据的方式。

**吐槽:**本文之前巴拉巴拉写了上万字,放在草稿中。结果掘金系统更新,把草稿箱还原到了两天前,所以本文二次抒写比较粗糙。

vue的通信方式

vue的通信方式大概汇总方式,本文重点为vuex,这里简述一下其他通信方式的场景,以及优势与缺陷,方便突出vuex的优势。如已经有一定的认知,可跳过,直接看源码部分。

1)子父通信,父子通信

这个有三天vue工作经验相信都有用过,本章不做过多讲解。它的确十分方便,属于二八原则的二之中。

它的弊端就是,只能存在于具有父子的传递中,一旦组件层次多级,或者组件非父子关系,逻辑将变得十分复杂,且问题多层级问题不易定位。

弊端:

  • 只能限制父子关系
  • 不适合多层级关系,多层级写法麻烦,且问题不易定位

2)parent/children/ref

那么单层关系,非父子组件如何通信呢?如兄弟组件通信。ref跟parent也是一个不错的选择(当然日常中,直接用vuex更多)。

他就是拿到类似“dom对象”,再去调用具体的方法。这样的话,即可完成兄弟之间的通信。

这里也十分好理解,通过 $refs 找到子组件,调用子组件中的函数。parent与children一样,这里不再说明。

弊端:只适合简单组件关系的调用,一旦多级组件,即会使逻辑十分复杂。

// parent.vue
<template>
    <Child1 />
    <Child2 />
</template>

// child1.vue
click(){
    this.$parent.$emit('child1Click', 'child1 点击,发送消息,触发事件')
}

// child2.vue
mounted(){
   this.$parent.$on('child1Click', (msg) => {
       console.log(msg)
   })

}

parent/children/ref弊端:

  • 可以是父子关系,也可以是兄弟关系,但只限于简单层面关系,难于用于多组件层级关系。

3)attrs/listeners

上述的vue通信,都是针对层级不深的组件关系。那么如果存在一个父控件,多层级子组件的数你据关系呢?你可能会想起props。但是props无关组件中的逻辑业务一种增多了,无关代码多了,后续代码维护也就变得困难了。

这时候attrs跟listeners是一个不错的选择。他的中间组件,可以不受影响,去传递给子孙组件。

举个栗子:新建一个“人”为主体的组件,先插入个人的“基本信息”的组件,“基本信息”下再嵌入“个人兴趣”的组件。这种情况,我们可能获取到一个“人”的详细信息,再分发到各个子组件。

// 组件person
Vue.component('Person', {
  template: `<div>
      <p>我的个人介绍:</p>
      <MyBasic :myName="myName" :myCharact="myCharact"  ></MyBasic>
    </div>`,
  data() {
    return {
      myName: 'weizhan',
      myCharact: 'A person who likes blogs' //传递给下下级组件的数据
    }
  }
});

// 组件MyBasic
Vue.component('MyBasic', {
  template: `
    <div>
      ${thisName}
      <MyCharactComp></MyCharactComp>
    </div>
  `,
  props: ['myName'],
  data(){
    return {
      thisName: this.myName
    }
  },
});

// 组件MyCharactComp
Vue.component('MyCharactComp', {
  template: `
    <div>
      ${$attrs.myCharact}
    </div>
  `,
  methods: {
  }
});

由代码可以了解,我们只需要把所有的参数定义在父级中,这样我们的子集,只要没有通过props,剩下的即可拿到剩余参数。所以,在我们多层级的组件中,其数据需要定义同一个祖父中的,attrs/listeners是个不错的选择方式。

attrs/listeners的弊端:

  • 只针对同一祖父组件关系,非统一祖父无法使用
  • 数据前倾向于,只能给一个组件使用(因为props获取之后,剩下attr就再也拿不到了)
  • 数据传递模式类似props,属于非响应式。需要手动监听。

4)provide/inject

那如果同一祖父中,一个数据有多个子组件同时使用呢?这时候我们的provide/inject就登场了。事实上,provide/inject的应用场景偏冷门,如果不去系统的看vue的api,可能你还不知有这个东西。事实上,官方也不推荐这种写法。

写UI库还是插件库,相信比例不会超过vue开发者的1%;为方便对比vuex,利用官方给的提示,我们来写一个简单的组件(例如利用provide来注入整个from表单的字体大小):

// MyForm
  Vue.component('MyForm', {
    template: `<div>
        <FromItem >
          <MyButton></MyButton>
        </FromItem>
         <MyButton></MyButton>
      </div>`,
      provide:{
        myStyle: {font-size: '16px'} // 这里不接收动态传参,但是可以直接传this过去,从而实现动态响应式
      }
  });

  // 组件FromItem
  Vue.component('FromItem', {
    template: `
      <div>
      	<solt/>
      </div>`,
     inject:['myStyle']
  });

  // 组件MyButton
  Vue.component('MyButton', {
    template: `
      <div>
        <button :style="style"></button>
      </div>`,
    inject:['myStyle']
  });

注意到案例中有两个不同层级的MyButton,但是他们却都可以拿到myStyle的参数。这样的话,provide的优势就很明显了,只要在一个祖父下,无论什么层级,无论如何嵌套,他都能inject到provide的。且案例中,是不可响应的。当父级的myStyle改变,也不影响子元素。这就十分方便我们的组件库的初始化。

当然,我们组件库,也有切换整个表单字体大小的需求。那么很简单,把provide的值改成可响应即可。

provide/inject的弊端:

  • 只能存在与祖父关系中。
  • 祖父级定义好,无论下面的层级如何嵌套,都不会影响inject的注入。这意味着,provide的定义要非常的规范,而且需要统一。一旦有一个开发人员,没有理解“myStyle”的作用,将造成组件库的异常。这就是为什么有人说:万一有人用provide写了垃圾代码,就成一团垃圾了。

5)EventBus

以上四种情况,均属于祖父,或者简单组件关系。

如果即要求,层级复杂深入,且两个组件之间没有任何关系。他们如何通信呢?如果是是在小项目中,EventBus就是个不错的选择。

EventBus,是一种消息订阅与发布的设计模式,使用上也十分简单。他也不仅仅是前端的概念,Android,Java都有EventBus发布/订阅事件总线的概念。他的原理也相对简单,将事件发布者和订阅者分离,利用“反射”将事情触发。我们可以利用vue的emit跟on原理,来实现一个简版的EventBus(源码借鉴来源结尾标准)。

class EventBus{
    constructor(){
        this.event=Object.create(null);
    };
    //注册事件
    on(name,fn){
        if(!this.event[name]){
            //一个事件可能有多个监听者
            this.event[name]=[];
        };
        this.event[name].push(fn);
    };
    //触发事件
    emit(name,...args){
        //给回调函数传参
        this.event[name]&&this.event[name].forEach(fn => {
            fn(...args)
        });
    };
    //取消事件
    off(name,offcb){
        if(this.event[name]){
            let index=this.event[name].findIndex((fn)=>{
                return offcb===fn;
            })
            this.event[name].splice(index,1);
            if(!this.event[name].length){
                delete this.event[name];
            }
        }
    }
}

从简版的EventBus,即是注册,反注册(取消),触发。他类似一个中间,不管你组件如何嵌套,只要连上同一个EventBus,即可完成通信。

EventBus的弊端:

  • 1.一个EventBus,可以理解成类,一个js文件。定义几个变量,的确没有问题。但是如果变量多的话,一个文件很难去调配好。 那如果多个EventBus,他们之间又没有关联关系,需要不断的引入。(再看看下边的vuex的modules就知道优势在哪了)
  • 2.EventBus本身性能不高,需要遍历所有的注册对象去轮询。
  • 3.注册即是一个监听,你不注销,永远都常驻在内存中。需及时的去处理注册与反注册。

6)vuex

本文的重点vuex。我们手写后再总结。

手写vuex

借鉴一下大神的图片vuex数据流程图。在实现之前,我们总结一下vuex到底是个什么东西?

1.内部用state存储数据,提供外部getters方法,暴露数据。

2.页面可以通过dispatch触发actions,commit触发mutations。其中actions不可改变数据源state,而mutations可以。

3.另外,我们的store可以支持多个modules模块。

下面我们来实现这个过程。

1)基本架子的搭建

我们搭一个普通的基于vuex的案例,项目结果如下:

首先新建项目:

  • npm install --global vue-cli
  • vue init webpack my-vuex
  • npm install
  • npm install vuex save

然后我们导入vuex.

新建vuex/store.js:

import Vue from 'vue'
import Vuex from 'vuex'
import otherModel from './moduels/other-model'
import userModel from './moduels/user-model'

Vue.use(Vuex);

export default new Vuex.Store({
    modules: {
        userModel,
        otherModel
    },
})

新建vuex/moduels/other-model.js:

const state = {
    status: '欢迎小姑凉', //
}
const getters = {
    getStatus(state) {
        return state.status;
    }
}
const mutations = {
    changeStatus(state, status) {
        state.status = status;
    }
}
const actions = {
    changeStatusActions({
        commit
    }, value) { // 参数解构,拿到上下文
        setTimeout(() => {
            commit('changeStatus', value);
        }, 1000);
    }
}

export default {
    state, getters,mutations, actions
}

新建vuex/moduels/user-model.js:

const state = {
    name: '前端小伙', //
    url: 'https://juejin.cn/user/4195392104696519', //
}

const getters = {
    getName(state) {
        return state.name;
    },
    getUrl(state){
        return state.url;
    }
}

const mutations = {
    setName( value ){
        this.state.name = value;
    }
}

const actions = {
    setNameActions({commit}, value){
        setTimeout(() => {
            commit('setName', value );
        }, 500 );
    }
}

export default {
    state,
    getters,
    mutations,
    actions
}

再修改一下main.js:

import App from './App.vue'
import store from './vuex/store.js'

new Vue({
  render: h => h(App),
  store
}).$mount('#app')

一个基本的轮子就已经实现。另外自己写一份组件交互vuex的api调用方法。懒得写,可以直接拷贝案例。

2)手写如何引入vuex

看一下我们的基本轮子,import Vuex from 'vuex'。用的是官方的vuex。那我们来实现一个自己vuex实现。

这里可推荐成两部分:

  • 根据Vue.use(Vuex);我们可以知道,他肯定暴露了自己的install方法。这个install的作用是什么?明显,是将store插入全局变量中。
  • export default new Vuex.Store({});可以看出,他暴露了Store类。这里我们支持多个modlues,我们可以推出,Store把多个modlues合并了。

我们可以尝试写代码:

function install(Vue, storeName = '$store') {
    // 混入:把store选项指定到Vue原型上
    Vue.mixin({
        beforeCreate() {
            // 判断main.js的当期组件选择中,是否有sotre   
            if (this.$options.store) {
                Vue.prototype[storeName] = this.$options.store
            }
        }
    })
}

class Store {
    constructor( options ) {
    }
}

export default {
    Store,
    install
}

再写一个组件引入我们的vuex:

<template>
  <div>
      <div>姓名:{{getName}}</div>
      <div>博客:{{getUrl}}</div>
      <div>备注:{{getStatus}}</div>
      <div @click="changeStatus()" style="color:red;text-align: center;" >点我更新数据</div>
  </div>
</template>

<script>
export default {
  name: 'CompB',
  props: {
    msg: String
  },
  computed:{
    getName(){
      return this.$store.getters.getName;
    },
    getUrl(){
      return this.$store.getters.getUrl;
    },
    getStatus(){
      return this.$store.getters.getStatus;
    }
  },
  methods:{
    changeStatus(){
      this.$store.dispatch('changeStatusActions', 'vuex数据变化,欢迎小姑凉,也欢迎小帅哥' );
    }
  }
}
</script>

此时,修改我们的 import Vuex from 'vuex'成import Vuex from './myVuex',项目已经不会报错,已经帮我们全局引入store。那么store到底是什么?

3)手写store

store到底是什么,我们再次看图。他包括state变量, getters变量, dispatch方法,mutations方法,此外,他支持modlues。我们分别来实现他。

实现modlues

modlues,其实就是多个对象,合并成一个。我们实现:

首先,我们传过来的options对象,已知又state, getters, mutations, actions, 4个对象, 而modlues内部又有一套,我们处理一下。

 class Store {
    constructor(options) {
      let {
          state = {},
          getters = {},
          mutations = {},
          actions = {},
          modules = {}
      } = options;
      for (var key in modules) {
          state = Object.assign(state, modules[key].state);
          mutations = Object.assign(mutations, modules[key].mutations);
          getters = Object.assign(getters, modules[key].getters);
          actions = Object.assign(actions, modules[key].actions);
      }
      this.state = state;
      this.actions = actions;
      this.mutations = mutations;
      this.getters = getters;
	}
}

实现getters

接下来实现,我们getters的方法。getters的实现也偏简单。就是返回state的值,调用重新获取的方法即可。

  • getters是需要双向绑定的。但我们不需要双向绑定所有的getters,只需要绑定项目中事件使用的getters。所以可以删除原来的this.getters的存储。
  • 我们新建一个getters对象,来存储,项目中已经调用的getters方法,此时他需要双向绑定。
  • 遍历对象,返回最后的结果。这样每次调用this.$store.getters时,方法即会拿到对应的值。

来看一下代码

constructor(options) {
 	...//省略
     // this.getters = getters;//删除本行
	observerGettersFunc( getters );
}

 observerGettersFunc(getters) {
    this.getters = {} // store实例上的getters
    // 定义只读的属性
    Object.keys(getters).forEach(key => {
        Object.defineProperty(this.getters, key, {
            get: () => {
                return getters[key](this.state)
            }
        })
    })
}

实现commit

再来看看怎么更改state数据。我们来看一个调用实例:

this.$store.commit('changeStatus', 'vuex数据变化,欢迎小姑凉,也欢迎小帅哥' );

其实就是两个值,调用commit后的方法,然后赋新的值。

// 触发mutations,需要实现commit
commit = (type, arg) => {
    // this只想Store实例
    const fn = this.mutations[type] // 获取状态变更函数
    fn(this.state, arg)
}

此时方法,已经走通,state的值,已经修改成功。但是出现两个致命的问题:

  • state并没有进行监听,他进入到getters的方法。
  • 视图根本不会更新。

鉴于上述原因:

state 要监听,即是赋值监听。我的第一感觉Object.defineProperty/proxy实现双向绑定。首先监听完state,如何更新getters。 第二,store里边,去强制刷新页面?貌似都不靠谱。

经过博友的提示,我来看看官方vuex的写法。

整个vuex的文件,跟state有关的就两个方法, installModule(this, state, [], options)跟 resetStoreVM(this, state)

但是跟上边两点有关的,就是resetStoreVM。我们看一下就是resetStoreVM的方法:

可以看出,他是利用了Vue的双向绑定,以及Vue的数据驱动视图的特点,解决了上边两个问题。

按照原理,我们修改一下我们的初始化state:

  constructor(options) {
       ...//省略
       // this.state = state;//删除本行
          +  this.state = new Vue({
          +    data: state
          +  });
  }

再运行案例。你会发现commit后页面已经生效。

实现dispatch

再来看一个dispatch案例:

//页面
this.$store.dispatch('changeStatusActions', 'vuex数据变化,欢迎小姑凉,也欢迎小帅哥' );

//modlues.js文件中方法
const actions = {
    changeStatusActions({commit, state}, value) {
        setTimeout(() => {
            commit('changeStatus', value);
        }, 1000);
    }
}

我们可以猜到他源码:

  • 暴露commit,state

  • 根据第一次参数类型,找到了action中对应的方法,例如上述changeStatusActions

    // 触发action,需要实现dispatch dispatch = (type, arg) => { const fn = this.actions[type] return fn({ commit: this.commit, state: this.state }, arg) }

至此,已经完成了vuex的源码

分析vuex

源码

vuex.js文件

let Vue;

function install(_Vue, storeName = '$store') {
    Vue = _Vue;
    Vue.mixin({
        beforeCreate() {
            if (this.$options.store) {
                Vue.prototype[storeName] = this.$options.store;
            }
        }
    })
}

class Store {
    constructor(options) {
        let {
            state = {}, getters = {}, mutations = {}, actions = {}, modules = {}
        } = options;
        for (var key in modules) {
            state = {
                ...state,
                ...modules[key].state
            }
            mutations = Object.assign(mutations, modules[key].mutations);
            getters = {
                ...getters,
                ...modules[key].getters
            };
            actions = Object.assign(actions, modules[key].actions);
        }
        this.state = new Vue({
            data: state
        });
        this.actions = actions;
        this.mutations = mutations;
        this.allGetters = getters;
        this.observerGettersFunc(getters);
    }

    // 触发mutations,需要实现commit
    commit = (type, arg) => {
        const fn = this.mutations[type] 
        fn(this.state, arg)
    }

    // 触发action,需要实现dispatch
    dispatch = (type, arg) => {
        const fn = this.actions[type]
        return fn({
            commit: this.commit,
            state: this.state
        }, arg)
    }

    //数据劫持getters
    observerGettersFunc(getters) {
        this.getters = {} // store实例上的getters
        Object.keys(getters).forEach(key => {
            Object.defineProperty(this.getters, key, {
                get: () => {
                    console.log(`retrunValue:` + JSON.stringify(this.state.$data));
                    return getters[key](this.state)
                }
            })
        })
    }
}

export default {
    Store,
    install
}

github完整案例地址

github.com/zhuangweizh…

文件结尾

本文参考

感谢这几篇文章带来的灵感,以及借鉴。

进度

再普及一下博客的进度:

| 序号 | 博客主题 | 相关链接 | |-----|------|------------|- | 1 | 手写vue_mini源码解析 | juejin.cn/post/684790… | | 2 | 手写react_mini源码解析 | juejin.cn/post/685457… | | 3 | 手写webpack_mini源码解析 | juejin.cn/post/685457… | | 4 | 手写jquery_mini源码解析| juejin.cn/post/685457… | | 5 | 手写vuex_mini源码解析(即本文) | juejin.cn/post/685529… | | 6 | 手写vue_router源码解析 | 预计下周 | | 7 | 手写diff算法源码解析 | 预计8月 | | 8 | 手写promis源码解析 | 预计8月 | | 9 | 手写原生js源码解析(手动实现常见api) | 预计8月 | | 10 | 手写react_redux,fiberd源码解析等 | 待定,本计划先出该文,整理有些难度 | | 11 | 手写koa2_mini | 预计9月,前端优先 |