Vue双向数据绑定原理及实现

7,482 阅读20分钟

对于Vue.js技术栈,我们的第一想法有可能就是容易上手,对于新手比较友好。确实如此,笔者刚刚入手的时候,觉得比较容易,而且在使用的过程中,也感觉到了它的强大。

最近在准备面试,只知道Vue.js的使用是远远不够的,所以开始剖析Vue.js的源码。下面一步一步讲解其原理以及实现。

先给出源码的github地址。

一、理解Vue.js

官方介绍:Vue.js是一套用于构建用户界面的渐进式框架。那么“渐进式”要如何理解呢?

Vue的核心的功能,是一个视图模板引擎,但这不是说Vue就不能成为一个框架。如下图所示,这里包含了Vue的所有部件,在声明式渲染(视图模板引擎)的基础上,我们可以通过添加组件系统、客户端路由、大规模状态管理来构建一个完整的框架。更重要的是,这些功能相互独立,你可以在核心功能的基础上任意选用其他的部件,不一定要全部整合在一起。可以看到,所说的“渐进式”,其实就是Vue的使用方式,同时也体现了Vue的设计的理念

渐进式代表的含义是:没有多做职责之外的事。

Vue.js只提供了 vue-cli 生态中最核心的组件系统双向数据绑定(也叫数据驱动)

二、双向数据绑定原理及实现

注意:因为该功能的实现用到了ES6的语法以及一些平常不常使用的知识,第三部分主要讲解了用到的知识点,可以先了解了基础知识知识之后再来看这一部分的实现。

我们都知道Vue.js的两个核心就是 组件系统数据驱动(双向数据绑定),所以接下来我们就 双向数据绑定进行讲解以及实现。

如上图所示:整体实现分为四步:

  1. 实现一个Compile,对指令进行解析,初始化视图,并且订阅数据的变更,绑定好更新函数
  2. 实现一个Observer,对数据进行劫持,通知数据的变化
  3. 实现一个Watcher,将其作为以上两者的一个中介点,在接受数据变更的同时,让Dep添加当前的Watcher,并及时通知视图进行update
  4. 实现MVVM,整合以上三者,作为一个入口函数

为了更好地理解实现过程,贴出一张更加详细的流程图:

下面的讲解主要分为三部分:模板编译(Compiler)数据劫持(Observer)观察者(Watcher)

我们都知道,在使用Vue.js的时候我们都需要 new Vue({}),此时的Vue即为一个类,大(花)括号里面传递的内容即为Vue的属性和方法。要想实现双向数据绑定,需要的最基本的元素即为 eldata,有了可编译的模板和数据,我们才可以进行接下来的模板编译以及数据劫持,最终通过观察者来实时监测数据的变化进而来不断的更新视图。所以 Vue类的作用可以理解为一个桥梁,将模板编译,数据劫持连接起来。

因为下面的代码以及讲解内容都为笔者自己实现的功能为例,所以Vue类改名为了MVVM,基本功能是一样的(下面的代码不完整,主要目的是为了讲解编写的主要流程和主要功能)。

1.模板编译(Compiler)

class Compile {
    //vm-->MVVM中传入的第二个参数就是MVVM的实例,即new MVVM()
    constructor(el, vm) {
        //传入的可能是 #app或者document.getElementById('app'),所以需要进行判断
        this.el = this.isElementNode(el) ? el : document.querySelector(el); 
        this.vm = vm;
        //防止用户输入的既不是“#el”字符串也不是document节点
        if (this.el) {
            //如果这个元素能够获取到,我们才开始编译
            
            //1.先把真实的DOM移入到内存中(优化性能) -->使用节点碎片 fragment
            let fragment = this.nodeToFragment(this.el);
            
            //2.编译=>提取想要的元素节点(v-model)和文本节点{{}}
            this.compile(fragment)
            
            //3.把编译好的fragment在放回到页面中
            this.el.appendChild(fragment)

        }
    }

在判断拥有可编译模板之后,接下来就要分别进行下面三步:

1.1 将真实DOM移入到内存中

直接操作DOM节点是非常损耗性能的,更何况对于一个真实的页面或者一个项目,DOM层会有很多节点以及嵌套的节点,所以,如果我们直接操作DOM,可想而知性能会变得很差。在这里我们要借助 fragment 节点碎片,来减少因为直接大量的操作DOM而造成的性能问题。(这个过程可以简单的理解为将DOM节点都移入到内存中,在内存中对DOM节点进行一系列的操作,这样就会提高性能)

  nodeToFragment(el) { //需要将el中的内容全部放入到内存中
        //文档碎片,不是真正的DOM,是内存中的节点
        let fragment = document.createDocumentFragment();
        let firstChild;
        while (firstChild = el.firstChild) {
            //将el中的真实节点一个一个的移入到文档碎片中(el.firstChild指文档中的第一个节点,这一个节点里面可能嵌套很多个节点,但是都没关系,都会一次取走)
            fragment.appendChild(firstChild);
        }
        return fragment; // 内存中的节点
    }

上面的一段代码就是将DOM节点移入到内存中的过程,在执行完上面一段代码之后,打开浏览器的控制台你会发现之前的节点都已经消息了(存入到了内存中)。

1.2 编译=>(提取到需要编译的元素节点和文本节点)

    compile(fragment) {
        //需要递归
        let childNodes = fragment.childNodes; //只拿到第一层(父级),拿不到嵌套层的
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) {
                //这里的需要编译元素
                this.compileElement(node);
                //是元素节点,还需要继续深入的检查(如果是元素节点,有可能节点里面会嵌套节点,所以要使用递归)
                this.compile(node) //因为外层是箭头函数,所以this始终指向Compile实例
            } else {
                //是文本节点
                //这里需要编译文本
                this.compileText(node)
            }
        })

    }
    compileElement(node) {
        //编译带v-model、v-text等的(取节点的属性)
        let attrs = node.attributes; //取出当前节点的属性
        Array.from(attrs).forEach(attr => {
            //判断属性名字是不是包含v-
            let attrName = attr.name;
            if (this.isDirective(attrName)) {
                //取到对应的值(即从data中取到message(示例)),放到节点中
                let expr = attr.value;
                let [, type] = attrName.split('-') //解构赋值
                //node  this.vm.$data expr  //这里可能有v-model或v-text  还有可能有v-html(这里只处理前两种)
                CompileUtil[type](node, this.vm, expr)
            }
        })
    }
    compileText(node) {
        //编译带{{}}
        let expr = node.textContent; //取文本中的内容
        let reg = /\{\{([^}]+)\}\}/g;
        if (reg.test(expr)) {
            //node this.vm.$data expr
            CompileUtil['text'](node, this.vm, expr)
        }
    }

上面的三个函数最终的结果是拿到了最终需要编译的元素节点,最后就是要将传入的 data中对应的数据显示在模板上。

        //文本更新
        textUpdater(node, value) {
            node.textContent = value
        },
        //输入框更新
        modelUpdater(node, value) {
            node.value = value
        }

1.3 编译好的fragment在放回到页面中

将数据显示在节点上之后,我们发现页面上并没有显示任何数据,而且元素节点也不存在,那是因为上面的一系列操作都是我们在内存中进行的,最后我们需要将编译好的 fragment放回到页面中。

 this.el.appendChild(fragment)

至此,模板编译部分就结束了了,这时候我们就会发现我们在data中定义的数据已经完全渲染在页面上了。

接下来,我们继续实现 数据的劫持

2.数据劫持(Observer)

顾名思义,数据劫持就是对 data中的每一个属性值进行监测,只要数据变化了,就要做出相应的事情(这里就是更新视图)。话不多说,先贴代码,在说明其中的几个注意点

class Observer {
    constructor(data) {
        this.observer(data)
    }
    observer(data) {
        //要对这个data数据原有的属性改成set和get的形式
        if (!data || typeof data !== 'object') { //如果数据不存在或者不是对象
            return;
        }
        //要将数据一一劫持,先获取到data的key和value
        Object.keys(data).forEach(key => { //该方法是将对象先转换成数组,再循环
            //劫持(定义一个函数,数据响应式)
            this.defineReactive(data, key, data[key]);
            //深度递归劫持,这里的递归只会为初始的data中的数据进行劫持(添加set和get方法),如果在defineReactive函数中使用set新增加则不会进行劫持
            this.observer(data[key]);
        })
    }
    //定义响应式
    defineReactive(obj, key, value) {
        //在获取某个值的时候,可以在获取或更改值的时候,做一些处理
        let that = this;
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() { //当取值时,调用的方法
                return value;
            },
            set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
                if (newValue !== value) {
                    console.log(this, 'this'); //这个this指向的是被修改的值
                    //但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
                    that.observer(newValue); //如果是对象继续劫持
                    value = newValue;
                }
            }
        })
    }

    /**
     * 以上就实现了数据劫持
     */
}

数据劫持部分比较简单,主要使用了 Object.defineProperty(),下面列出一个需要注意的地方:

  • 在对data数据中的原有属性改为 getset之前,需要对data进行判断,排除不是对象和数据不存在的情况
  • 因为 data中的数据可能是多层嵌套的对象,所以要进行深层递归,但是这里的递归只会为data中初始的数据进行劫持,对于新添加的则不会。
  • 基于上面一条的缺陷,所以我们需要在为数据添加set方法时,对数据也进行劫持(因为此时的this指向的是被修改的值,所以需要在方法最初保存一下当前的this值)

核心的模板编译和数据劫持已经完成,两个部分也都可以实现自己的职能,但是如何将两者关联起来,达到最终双向绑定的效果呢?

下面就是结合两者的 Watcher的主场了!!!

3.观察者 (Watcher)

创建Watcher观察者,用新值和老值进行比对,如果发生变化了,就调用更新方法,进行视图的更新。

class Watcher {
    constructor(vm, expr, cb) {
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        //先获取一下老的值
        this.value = this.get();
    }
    getVal(vm, expr) { //获取实例上对应的数据
        expr = expr.split('.');
        return expr.reduce((prev, next) => { //vm.$data.a....
            return prev[next];
        }, vm.$data)
    }
    get() {
        Dep.target = this; //将当前watcher实例放入到tartget中
        let value = this.getVal(this.vm, this.expr);
        Dep.target = null;
        return value;
    }
    //对外暴露的方法
    update() {
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value;
        if (newValue !== oldValue) {
            this.cb(newValue); //对应watch的callback
        }
    }
}

在这里还要插入一个知识,发布-订阅模式:

//observer.js
/**
 * 发布订阅
 */
class Dep {
    constructor() {
        //订阅的数组
        this.subs = [];
    }
    //添加订阅者
    addSub(watcher) {
        this.subs.push(watcher);
    }
    //通知
    notify() {
        this.subs.forEach(watcher => {
            watcher.update()
        })
    }
}

发布订阅在这里的作用:因为 Watcher是来观察数据变化的,即订阅者。因为一个数据可能在模板的多处使用,所以一个数据会有多个监测者。即可以理解为对于一个数据有多个订阅者,那么当一个数据变化时,就可以一次通知便可实现所有订阅者都知道这个消息的结果。(也就是一个数据变化,模板中使用这个数据的值都发生了改变)

上面的一段话是个人现阶段的理解,如果有误,希望可以提出来,共同改进和努力~~~

结合上面的两个功能,就可以将整个数据双向绑定联络起来:

当在模板编译中 创建 Watcher实例时,这行代码 Dep.target = this; //将当前watcher实例放入到tartget中 就会将监听这个数据变化的订阅者防盗订阅者数组中,注意,因为Dep中没有target这个属性,所以在使用完之后,记得释放该没有必要的内存空间 Dep.target = null;,通过这一步,我们就先将所有订阅者都放入到了订阅者的数组中。

// compile.js
 //这里应该加一个监控,数据变化了,应该调用这个watch的callback
        new Watcher(vm, expr, (newValue) => {
            //当值变化后,会调用cb将新值传递过来()
            updateFn && updateFn(node, this.getVal(vm, expr))
        })
//observer.js
    //定义响应式
    defineReactive(obj, key, value) {
        //在获取某个值的时候,可以在获取或更改值的时候,做一些处理
        let that = this;
        console.log(that, this);
        
        let dep = new Dep(); //每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
        
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() { //当取值时,调用的方法
            
                Dep.target && dep.addSub(Dep.target);
                
                return value;
            },
            set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
                if (newValue !== value) {
                    console.log(this, 'this'); //这个this指向的是被修改的值
                    //但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
                    that.observer(newValue); //如果是对象继续劫持
                    value = newValue;
                    dep.notify(); //通知所有人数据更新了
                }
            }
        })
    }

在数据劫持的部分定义一个数组Dep.target && dep.addSub(Dep.target);,存放需要更新的订阅者。

在获取值的时候,将这些订阅者都放到上面定义的数组中,Dep.target && dep.addSub(Dep.target);

在改变值的时候,就会调用 dep.notify(); //通知所有人数据更新了,间接调用 watcher.update()来更新数据。

到此为止,双向数据绑定已经基本实现,下面还有两点简单的内容。

为输入框添加点击事件

        //为节点添加点击事件
        node.addEventListener('input', e => {
            let newValue = e.target.value;
            this.setVal(vm, expr, newValue);
        })

添加代理

当我们访问实例上的数据时,我们都要通过 this.$data.message才能访问到,因为我们的数据是 $data里面的,如果我们想要实现 this.message就能访问到数据,这时候就需要使用一层代理。

    proxyData(data) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    return data[key]
                },
                set(newValue) {
                    data[key] = newValue
                }
            })
        })
    }

最终效果

三、准备知识

剖析Vue.js的源码过程中发现,Vue.js的底层使用了很多我们平时代码实现不怎么用到的知识,这里作为一个入门简单的罗列一下。

1. ES6中类及其定义

问题一:传统JS类的定义

JS定义类的的传统方法:是通过构造函数,定义并生成新对象,prototype属性使您有能力向对象添加属性和方法。

案例:

//Person.js
function Person(x,y){    
    this.x = x;   
    this.y = y;
}

Person.prototype.toString = function (){    
    return (this.x + "的年龄是" +this.y+"岁");
}

export {Person};

//index.js
import {Person} from './Person';
let person = new Person('张三',12);
console.log(person.toString()); /张三的年龄是12岁

问题二:ES6中类的定义

ES6引入了Class(类)这个概念,作为对象的模板,通过class关键字,可以定义类。 基本上,ES6的Class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的Class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。 上面的代码用ES6的“类”改写,就是下面这样:

//Person.js
class Person{    
    // 构造    
    constructor(x,y){        
        this.x = x;            
        this.y = y;    
    }
    toString(){        
        return (this.x + "的年龄是" +this.y+"岁");    
    }
}
export {Person};

//index.js
import {Person} from './Person';
let person = new Person('张三',12);
console.log(person.toString()); /张三的年龄是12岁

面代码定义了一个“Class类”,可以看到里面有一个constructor方法,这就是构造方法,而this关键字则代表实例对象。 也就是说,ES5的构造函数Person,对应ES6的Person类的构造方法。 Person类除了构造方法,还定义了一个toString方法。

注意,定义“类”的方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。 另外,方法之间不需要逗号分隔,加了会报错。

一个类必须有constructor方法,如果没有显式定义,一个默认的constructor方法会被添加。所以即使你没有添加构造函数,也是有默认的构造函数的。

2. JS中的文档碎片

在浏览器中,我们通常用innerHTML()或者appendChild()向页面中插入DOM节点,例如:

for(var i=0;i<5;i++){

    var op = document.createElement("span"); 

    var oText = document.createTextNode(i); 

    op.appendChild(oText); 

    document.body.appendChild(op); 
}

但是,如果当我们要向document中添加大量数据时(比如1w条),如果像上面的代码一样,逐条添加节点,这个过程就可能会十分缓慢。 当然,你也可以建个新的节点,比如说div,先将oP添加到div上,然后再将div添加到body中,但这样要在body中多添加一个<div></div>.但文档碎片不会产生这种节点。

var oDiv = document.createElement("div"); 

for(var i=0;i<10000;i++){ 

    var op = document.createElement("span"); 

    var oText = document.createTextNode(i); 

    op.appendChild(oText); 

    oDiv.appendChild(op);  
} 
document.body.appendChild(oDiv);

为了解决这个问题,JS引入了createDocumentFragment()方法,它的作用是创建一个文档碎片,把要插入的新节点先附加在它上面,然后再一次性添加到document中。 代码如下:

//先创建文档碎片

var oFragmeng = document.createDocumentFragment(); 

for(var i=0;i<10000;i++){ 

    var op = document.createElement("span"); 

    var oText = document.createTextNode(i); 

    op.appendChild(oText); 

    //先附加在文档碎片中

    oFragmeng.appendChild(op);  

} 
//最后一次性添加到document中
document.body.appendChild(oFragmeng);

3. 解构赋值

按照一定的模式从数组或者对象中取值,对变量进行赋值的过程称为解构。

上面的代码表示,可以从数组中取值,按照位置的对应关系对变量赋值。

4. Array.from与Array.reduce

1、ES6中的 Array.from()方法 Array.from方法

用于将两类对象转为真正的数组: 类似数组的对象(array-like object) 和 可遍历(iterable)的对象(包括 ES6 新增的数据结构SetMap)。

下面是一个类似数组的对象,Array.from将它转为真正的数组:

let arrayLike = {
    '0': 'a',
    '1': 'b',
    '2': 'c',
    length: 3
};
// ES5的写法
var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']
// ES6的写法
let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']

实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments对象。Array.from都可以将它们转为真正的数组。

/ NodeList对象
let ps = document.querySelectorAll('p');
Array.from(ps).filter(p => {
  return p.textContent.length > 100;
});

// arguments对象
function foo() {
  var args = Array.from(arguments);
  // ...
}

上面代码中,querySelectorAll方法返回的是一个类似数组的对象,可以将这个对象转为真正的数组,再使用filter方法。

更多参考

2、ES5中的 Array.reduce() 方法

reduce() 方法接收一个函数作为累加器(accumulator),数组中的每个值(从左到右)开始合并,最终为一个值。

参数 描述
callback 执行数组中每个值的函数,包含四个参数
previousValue 上一次调用回调返回的值,或者是提供的初始值(initialValue)
currentValue 数组中当前被处理的元素
index 当前元素在数组中的索引
array 调用 reduce 的数组
initialValue 作为第一次调用 callback 的第一个参数。

详细描述

  • reduce 为数组中的每一个元素依次执行回调函数,不包括数组中被删除或从未被赋值的元素,接受四个参数:初始值(或者上一次回调函数的返回值),当前元素值,当前索引,调用 reduce 的数组。

  • 回调函数第一次执行时,previousValuecurrentValue 可以是一个值,如果 initialValue在调用 reduce 时被提供,那么第一个 previousValue等于initialValue,并且currentValue等于数组中的第一个值;如果initialValue 未被提供,那么previousValue 等于数组中的第一个值,currentValue等于数组中的第二个值。

  • 如果数组为空并且没有提供initialValue, 会抛出TypeError 。如果数组仅有一个元素(无论位置如何)并且没有提供initialValue, 或者有提供initialValue但是数组为空,那么此唯一值将被返回并且callback不会被执行。

使用示例:

var total = [0, 1, 2, 3].reduce(function(a, b) {
    return a + b;
});
console.log(total);//6

var total = [0, 1, 2, 3].reduce(function(a, b) {
    return a + b;
},10);
console.log(total);//16

5.递归的使用

6.Obj.keys()与Obj.defineProperty()

问题一:Obj.keys()的使用

Object.keys()方法返回一个由一个给定对象的自身可枚举属性组成的数组。

使用示例:

var person = {
       firstName: "aaaaaa",
       lastName: "bbbbbb",
       others: "ccccc"
};
Object.keys(person).forEach(function(data) {
       console.log('person', data, ':', person[data]);
 });
//console.log:
//person firstName : aaaaaa
//person lastName : bbbbbb
//person others : ccccc

问题二:Obj.defineProperty()的使用

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

语法:

Object.defineProperty(obj, prop, descriptor)

参数说明:

obj:必需。目标对象 
prop:必需。需定义或修改的属性的名字
descriptor:必需。目标属性所拥有的特性

返回值:

传入函数的对象。即第一个参数obj

给对象的属性添加特性描述,目前提供两种形式:数据描述和存取器描述。

一、数据描述

当修改或定义对象的某个属性的时候,给这个属性添加一些特性:

var obj = {
    test:"hello"
}
//对象已有的属性添加特性描述
Object.defineProperty(obj,"test",{
    configurable:true | false,
    enumerable:true | false,
    value:任意类型的值,
    writable:true | false
});
//对象新添加的属性的特性描述
Object.defineProperty(obj,"newKey",{
    configurable:true | false,
    enumerable:true | false,
    value:任意类型的值,
    writable:true | false
});

设置的特性总结:

value: 设置属性的值
writable: 值是否可以重写。true | false
enumerable: 目标属性是否可以被枚举。true | false
configurable: 目标属性是否可以被删除或是否可以再次修改特性 true | false

注意:

  • 除了可以给新定义的属性设置特性,也可以给已有的属性设置特性

  • 一旦使用Object.defineProperty给对象添加属性,那么如果不设置属性的特性,那么configurable、enumerable、writable这些值都为默认的false。

二、存取器描述

当使用存取器描述属性的特性的时候,允许设置以下特性属性:

var obj = {};
Object.defineProperty(obj,"newKey",{
    get:function (){} | undefined,
    set:function (value){} | undefined
    configurable: true | false
    enumerable: true | false
});

注意:当使用了getter或setter方法,不允许使用writable和value这两个属性

getter/setter 当设置或获取对象的某个属性的值的时候,可以提供getter/setter方法。

  • getter 是一种获得属性值的方法

  • setter是一种设置属性值的方法。 在特性中使用get/set属性来定义对应的方法。

var obj = {};
var initValue = 'hello';
Object.defineProperty(obj,"newKey",{
    get:function (){
        //当获取值的时候触发的函数
        return initValue;    
    },
    set:function (value){
        //当设置值的时候触发的函数,设置的新值通过参数value拿到
        initValue = value;
    }
});
//获取值
console.log( obj.newKey );  //hello
//设置值
obj.newKey = 'change value';
console.log( obj.newKey ); //change value

注意:get或set不是必须成对出现,任写其一就可以。如果不设置方法,则get和set的默认值为undefined。configurable和enumerable同上面的用法。

7.发布-订阅模式

问题一:发布-订阅模式,又称为 观察者模式

观察者模式概念解读

观察者模式又叫发布订阅模式(Publish/Subscribe),它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。

观察者模式作用和注意事项

模式作用:

  • 1、支持简单的广播通信,自动通知所有已经订阅过的对象。

  • 2、页面载入后目标对象很容易与观察者存在一种动态关联,增加了灵活性

  • 3、目标对象与观察者之间的抽象耦合关系能够单独扩展以及重用。

注意事项:

监听要在触发之前。

到此为止,双向数据绑定的原理以及实现的思路已经基本完成,同时也讲解了功能实现中需要的基本知识,希望读者可以从中收获知识,也欢迎提出不同的意见,虚心采纳~~~