本文主要对Vue.js中双向数据绑定的原理进行分析,以及模拟实现双向数据绑定。

Vue双向数据绑定原理分析

本文参考公众号:小时光茶社中的《剖析Vue原理&实现双向绑定MVVM》

结合掘金小册的《剖析 Vue.js 内部运行机制》的2、3章响应式系统来理解更佳。

实现数据双向绑定的方法

  • Angular:脏值检查
  • Vue.js:数据劫持和发布者-订阅者模式结合(通过Object.defineProperty()来劫持各个属性的settergetter,在数据变动时发布消息给订阅者,触发响应的监听回调)
    (关于Object.defineProperty()的用法(特别要理解“属性描述符”的概念))

什么是【数据双向绑定】?

在回答这个问题之前,首先要搞清楚Vue.js的【响应式系统】。

Vue.js的【响应式系统】就是:当一个对象的属性发生变化时,依赖到这个属性的其他对象能够知道这个属性发生了变化,并且能对视图进行相应的更新。而这其实也可以叫做【数据单向绑定】。(官方文档

而【数据双向绑定】实际上就是在【单向绑定】基础上,给可输入元素(input、textarea等),添加了change(input)事件,来修改对象的属性值,从而改变视图。

更直白的说法:【数据双向绑定】是指将对象属性的变化绑定到UI,或者反之。换句话说,如果我们有一个name属性的user对象,当我们给user.name赋予一个新值时,UI也会相应显示新的名字。同样的,如果UI包括了一个输入字段用来输入用户名,输入一个新的值会导致user对象中的name属性的值发生变化。

  • 单向绑定:通过赋值语句修改对象的属性值 ->通知依赖这个属性值的其他对象 -> 其他对象对视图进行相应更新
  • 双向绑定:通过表单输入值修改对象的属性值 ->通知依赖这个属性值的其他对象 -> 其他对象对视图进行相应更新

模拟实现Vue.js【数据双向绑定】:

通过【数据劫持】和【发布者-订阅者模式】实现。

发布者-订阅者模式:

将对象的属性看作是“发布者”,将依赖这个属性的其他对象看作是“订阅者”。一个对象的属性发生了变化时,发布者就发布消息给订阅者,通知订阅者【“我的值发生了变化”】,然后订阅者知道这个消息后,再对视图进行更新。

数据劫持:

通过Object.defineProperty,来修改对象的属性的get和set,具体点来说就是,在做真正的get(获取数据)和set(设置数据)操作之前,再额外添加一些操作。
在进行get操作中,进行【依赖收集】,意思就是,给将当前获取这个属性值的对象看作的订阅者,并把这个订阅者加入到【订阅器(发布者)Dep】的一个“订阅者数组”中。
在进行set操作中,就要把“属性值发生改变”的这一消息通知所有订阅者们,让他们相应地更新视图。

具体代码模块:

1. 入口:Vue对象
2. 给每个属性添加【响应式系统】:observe
3. 定义【响应式系统】:defineReactive
4. 发布者(依赖收集器):Dep对象(subs、addSub、notify)
5. 订阅者:Watcher对象(Dep.target = this、update)
6. 解析元素中的v-model指令和双大括号{{}}:Complie

Show me the code:

// observe函数用来监听对象的属性是否有变化。
function observe(data) {
    if (!data || typeof data !== 'object') {
        return;
    }
    // 取出所有属性遍历,给每个属性加上【响应式系统】
    Object.keys(data).forEach(function(key) {
        defineReactive(data, key, data[key]);
    });
}

// 定义【响应式系统】
function defineReactive(data, key, val) {
    var dep = new Dep();  //将数据变成发布者
    observe(val); // 监听子属性
    Object.defineProperty(data, key, {
        enumerable: true,  // 定义该属性可以枚举
        configurable: true, // 定义该属性可以修改
        get: function() {
            // 添加订阅者
            // 需要知道是谁订阅了我, 所以在订阅的时候就提前保存下订阅者(Dep.target)
            console.log("有一个新的订阅者!");

            Dep.target && dep.addSub(Dep.target);
            return val;
        },
        set: function(newVal) {
            // 通知所有的订阅者我更新了
            if (val === newVal) return;
            console.log("“哈哈哈,监听到对象的属性变化了:", val, " -> ", newVal);
            dep.notify();
            val = newVal;
        }
    });
}

// 依赖收集器(发布者):每一个数据(对象的属性),都可以是发布者
function Dep() {
    this.subs = [];  // 订阅者数组
}
Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        console.log("这里要通知订阅者们调用自身的update()来更新视图。");
        this.subs.forEach(sub => {
            sub.update();
        })
    }
}

// 订阅者:每一个依赖(引用)属性的,都是订阅者
function Watcher() {
    Dep.target = this;  // 每次订阅都告诉发布者“我是谁”
}
Watcher.prototype.update = function() {
    console.log("我是订阅者,现在发现依赖值发生了改变,该更新视图啦~");
}

// 入口
function Vue(options) {
    this.data = options.data;
    observe(this.data);  // 给对象的每个属性都加上响应式系统
    new Watcher(); // 新建订阅者。这一步仅仅只是告诉发布者我是谁,真正的订阅发生在数据被引用的时候
}

// 测试
var vm = new Vue({
    data: {
        name: 'tom'
    }
});

console.log('模拟数据被使用', vm.data.name) // 此时订阅了
vm.data.name = 'jerry' // 模拟数据更新

PS:其实这里有一点“标题党”了,按照上面的分析,上面代码中是通过“赋值语句”修改对象的属性值,因此来说其实更像是数据的单向绑定而已。由于Compile模块的代码实现起来更为复杂,所以上面代码就没有实现。但是没有关系,因为Vue的双向数据绑定的最核心的部分:数据劫持和发布者-订阅者模式已经基本实现了。

更完整的Vue数据双向绑定看这里

番外:使用Object.defineProperty和Proxy实现数据劫持的区别

Object.defineProperty的缺点:

  1. 只能对属性进行数据劫持,所以需要深度遍历整个对象
  2. 对于数组不能监听到数据的变化。虽然Vue中确实可以检测到数组数据的变化,但是其实用用了hack的方法,而且也是有缺陷的。

Proxy是ES6的新语法,用于修改某些操作的默认行为。Proxy可以理解成,在目标对象之前假设一层“拦截”,当外界对该对象进行访问时,都必须要先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

Proxy就没有以上Object.defineProperty的那些问题,它支持监听数组变化,并且可以直接对整个对象进行拦截,所以Vue也将在3.0的时候使用Proxy来替换Object.defineProperty。

var p = new Proxy(targetObj, handler);
// targetObj参数是一个对象,表示所要拦截的目标对象
// handler参数也是一个对象,表示定制拦截行为

Proxy的使用方法