适合人群
本文适合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完整案例地址
文件结尾
本文参考
感谢这几篇文章带来的灵感,以及借鉴。
- eventBus源码借鉴: juejin.cn/post/684490…
- kvue源码借鉴: www.cnblogs.com/haishen/p/1…
- kMini源码: juejin.cn/post/684490…
进度
再普及一下博客的进度:
| 序号 | 博客主题 | 相关链接 | |-----|------|------------|- | 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月,前端优先 |