Vue双向绑定的实现

1,025 阅读5分钟

一. MVVM

MVVM实际是Model - View - ViewModel的模式,这三个组件也是MVVM的核心:

  • Model —— 包含了业务和验证逻辑的数据模型
  • View —— 定义屏幕中View的结构,布局和外观
  • ViewModel —— 扮演“View”和“Model”之间的使者,帮忙处理 View 的全部业务逻辑

Model和View之间使用ViewMode进行关联,ViewModel负责将Model的数据变化显示在View上,通过将View的改变反馈到Model上。

二. Object.defineProperty()

Vue的数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的,我们可以先来看一下,如果在控制台输出一个定义在vue初始数据上的对象是个什么东西:

let vm = new Vue({
    data: {
        obj: {
            a: 1
        }
    },
    created: function () {
        console.log(this.obj);
    }
});

结果如下:

我们可以看到属性a有两个相对应的get() / set()方法,为什么会多出这两个方法呢?因为Vue是通过 Object.defineProperty()来实现数据劫持的。 那么Object.defineProperty()是用来干什么的呢?

可以从这里来看看MDN的官方定义

它说: Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

意思是Object.defineProperty()可以来控制一个对象属性的一些特有操作,比如读写权、是否可以枚举等等,具体查看文档。

而这里我们研究下它的get() / set()属性。

按照我们以往的方式,我们获取一个对象的属性可以这样:

var Book = {
    name: 'Echo的书'
};
console.log(Book.name); // 输出'Echo的书'

那么如果现在,我们想要在输出的结果中,自动给书加上书名号应该怎么做呢?这时就要用到Object.defineProperty()get() / set()方法了:

var Book = {};
var name = '';

Object.defineProperty(Book, 'name', {
    set: function(value){
        name = value;
        console.log('这本书的名字叫做:' + name);
    },
    get: function(value){
        return '《' + name + '》';
    }
})

Book.name = 'Echo的书📖';
console.log(Book.name); 

结果如下:

我们通过Object.defineProperty()设置了对象Book的name属性,对其get和set进行重写操作,顾名思义:

  • get就是在读取name属性这个值触发的函数
  • set就是在设置name属性这个值触发的函数

所以当执行 Book.name = 'Echo的书📖' 这个语句时,控制台会打印出 "这本书的名字叫做:Echo的书📖",紧接着,当读取这个属性时,就会输出 "《Echo的书📖'》",因为我们在get函数里面对该值做了加工了。

那我们现在来输出一下Book,看下结果会是什么呢?

有没有发现它的结构和我们最开始打印的vue初始数据的结构非常相似,这说明vue确实是通过这种方法来进行数据劫持的。

好了,基础知识我们已经了解完了,那么问题是,MVVM的双向绑定究竟是如何实现的呢?

三. 双向绑定的实现

实现MVVM主要包括两个方面:

  • 视图变化更新数据
  • 数据变化更新视图

第一点:视图变化更新数据 比较简单就能实现,通过事件监听就能实现:比如input中监听它的input事件,获取到更新的数据,再传递给实际数据就能实现了。所以我们现在主要来分析:当数据更新变化时如何更新视图。

数据变化更新视图的重点在于:如何知道数据更新了,其实在我们之前就已经了解过了,就是Object.defineProperty()

通过Object.defineProperty()对属性设置一个set函数,当数据改变了就会来触发这个函数,所以我们只要将一些需要更新的方法放在这里面就可以实现data更新view了。

四. 实现过程

我们已经知道了,数据的双向绑定的实现,首先就是需要对数据进行劫持监听。所以我们需要设置一个监听器Observer(), 用来监听所有属性,如果这些属性中有发生变化的了,就需要告诉订阅者 Watcher() 是否要更新视图,因为订阅者是有很多个的,所以我们需要有个集中的消息订阅者 Dep 来管理这些订阅者。然后在监听器和订阅者之间进行统一的管理。

接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher(),并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。

因此接下去我们执行以下3个步骤,实现数据的双向绑定:

  1. 实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
  2. 实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
  3. 实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。

流程图如下所示:

1. 实现一个监听器Observer()

Observer()是一个数据监听器,其核心的方法就是我们上面所说的 Object.defineProperty(), 如果要对所有的属性都进行监听的话,那么可以通过递归的方法遍历所有属性,并对其进行Object.defineProperty()处理,具体实现如下所示:

// 遍历函数
function observer(datas){
    if(!datas || typeof datas !=='object'){
        return;
    }
    Object.keys(datas).forEach((key)=>{
        defineReactive(datas, key, datas[key]);
    });
}

function defineReactive(datas, key, val){
    observer(val);
    
    Object.defineProperty(datas, key, {
        enumerable: true,
        configurable: true,
        set(newVal){
            val = newVal;
            console.log(`属性${key}被监听了,它的新值为${newVal.toString()}`);
        },
        get(){
            return val;
        }
    });
}

let datas = {
    book: {
        name: 'Echo的书',
        author: 'Echo',
        buyInfos: {
            money: '¥99'
        }
    },
    pushlishTime: '2020-01-01'
};
observer(datas);
datas.book.name = 'Echo新买的书';
datas.book.buyInfos.money = '¥109';

这样就实现了一个简单的监听器啦~

接下来,我们按照思路,需要创建一个可以容纳订阅者的消息订阅器Dep,订阅器Dep主要负责收集订阅者Watcher(),然后再属性变化的时候执行对应订阅者的更新函数。所以显然订阅器需要有一个容器,这个容器就是list,将上面的Observer()稍微改造下,植入消息订阅器: