vue的双向绑定原理及实现

前言

虽然知道vue双向绑定是通过Object.defineProperty方法属性拦截的方式,把 data 对象里每个数据的读写转化成 getter/setter,当数据变化时通知视图更新。但是关于其中具体实现逻辑还是很懵逼的,今天就特意跟着大神了解了一下其中具体实现方法。

一、什么是 MVVM 数据双向绑定

MVVM 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据,如下图所示:


image.png

如何知道数据变了,其实上文我们已经给出答案了,就是通过Object.defineProperty( )对属性设置一个set函数,当数据改变了就会来触发这个函数,所以我们只要将一些需要更新的方法放在这里面就可以实现data更新view了。


image.png

二、实现过程

首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。因此接下去我们执行以下3个步骤,实现数据的双向绑定:
1.实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
2.实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
3.实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
流程图如下:


image.png

1、实现一个Observer

昨天学习了vue2.0的更新机制主要靠Object.defineProperty( )对所有属性进行递归,并绑定get()与set()。

let oldArrayPrototype=Array.prototype;
let proto=Object.create(oldArrayPrototype);
['push','pop','shift','unshift','unshift','splice','sort','reverse'].forEach(method=>{
    proto[method]=function(){
        updateView();
        oldArrayPrototype[method].call(this,...arguments);
    }
})
function updateView(){
    console.log('视图更新');
}
function defineReactive(target,key,value){
    observe(value);
    Object.defineProperty(target,key,{
        get(){
            return value
        },
        set(newValue){
            if(newValue!==value){
                observe(newValue)
                updateView();
                value=newValue
            }
        }
    })
}
function observe(target){
    if(typeof target!=='object'||target===null){
        return
    };
    if(Array.isArray(target)){
        Object.setPrototypeOf(target,proto);
    }
    for(let key in target){
        defineReactive(target,key,target[key])
    }
}

思路分析种,需要创建一个可以容纳订阅者的消息订阅器Dep,消息订阅器Dep负责手机订阅者,当执行get()方法时候执行对应订阅者的更新函数,因此将上面代码进行一下改动

let oldArrayPrototype=Array.prototype;
let proto=Object.create(oldArrayPrototype);
['push','pop','shift','unshift','unshift','splice','sort','reverse'].forEach(method=>{
    proto[method]=function(){
        updateView();
        oldArrayPrototype[method].call(this,...arguments);
    }
})
function defineReactive(target,key,value){
    observe(value);
    var dep = new Dep(); 
    Object.defineProperty(target,key,{
        get(){
            if (Dep.target) {
                dep.addSub(Dep.target);
            }
            return value
        },
        set(newValue){
            if(newValue!==value){
                observe(newValue)
                value=newValue;
                dep.notify();
            }
        }
    })
}
function observe(target){
    if(typeof target!=='object'||target===null){
        return
    };
    if(Array.isArray(target)){
        Object.setPrototypeOf(target,proto);
    }
    for(let key in target){
        defineReactive(target,key,target[key])
    }
}
function Dep () {
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};
Dep.target = null;

从代码上看,我们将订阅器Dep添加一个订阅者设计在getter里面,这是为了让Watcher初始化进行触发,因此需要判断是否要添加订阅者。在setter函数里面,如果数据变化,就会去通知所有订阅者,订阅者们就会去执行对应的更新的函数。到此为止,一个比较完整Observer已经实现了,接下来我们开始设计Watcher。

2、订阅者 Watcher 实现

订阅者Watcher在初始化的时候需要将自己添加进订阅器Dep中,那该如何添加呢?我们已经知道监听器Observer是在get函数执行了添加订阅者Wather的操作的,所以我们只要在订阅者Watcher初始化的时候触发对应的get函数去执行添加订阅者操作即可,那要如何触发get的函数,再简单不过了,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了Object.defineProperty( )进行数据监听。这里还有一个细节点需要处理,我们只要在订阅者Watcher初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在Dep.target上缓存下订阅者,添加成功后再将其去掉就可以了。订阅者Watcher的实现如下:

function Watcher(vm, exp, cb) {
    this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    this.value = this.get();  // 将自己添加到订阅器的操作
}

Watcher.prototype = {
    update: function() {
        this.run();
    },
    run: function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        }
    },
    get: function() {
        Dep.target = this;  // 缓存自己
        var value = this.vm.data[this.exp]  // 强制执行监听器里的get函数
        Dep.target = null;  // 释放自己
        return value;
    }
};

订阅者 Watcher 分析如下:
订阅者 Watcher 是一个 类,在它的构造函数中,定义了一些属性:

vm:一个 Vue 的实例对象;
exp:是 node 节点的 v-model 等指令的属性值 或者插值符号中的属性。如 v-model="name",exp 就是name;
cb:是 Watcher 绑定的更新函数;

当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,就会执行它的 this.get() 方法,进入 get 函数,首先会执行:
Dep.target = this; // 将自己赋值为全局的订阅者
复制代码实际上就是把 Dep.target 赋值为当前的渲染 watcher ,接着又执行了:
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数
复制代码在这个过程中会对 vm 上的数据访问,其实就是为了触发数据对象的 getter。
每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行this.addSub(Dep.target),即把当前的 watcher 订阅到这个数据持有的 dep 的 watchers 中,这个目的是为后续数据变化时候能通知到哪些 watchers 做准备。
这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了吗?其实并没有,完成依赖收集后,还需要把 Dep.target 恢复成上一个状态,即:
Dep.target = null; // 释放自己
复制代码而 update() 函数是用来当数据发生变化时调用 Watcher 自身的更新函数进行更新的操作。先通过 let value = this.vm.data[this.exp]; 获取到最新的数据,然后将其与之前 get() 获得的旧数据进行比较,如果不一样,则调用更新函数 cb 进行更新。
至此,简单的订阅者 Watcher 设计完毕。

3、实现Compile

虽然上面已经实现了一个双向数据绑定的例子,但是整个过程都没有去解析dom节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器Compile来做解析和绑定工作。解析器Compile实现步骤:
1.解析模板指令,并替换模板数据,初始化视图
2.将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器
为了解析模板,首先需要获取到dom元素,然后对含有dom元素上含有指令的节点进行处理,因此这个环节需要对dom操作比较频繁,所有可以先建一个fragment片段,将需要解析的dom节点存入fragment片段里再进行处理:

 nodeToFragment: function (el) {
        var fragment = document.createDocumentFragment();
        var child = el.firstChild;
        while (child) {
            // 将Dom元素移入fragment中
            fragment.appendChild(child);
            child = el.firstChild
        }
        return fragment;
    }

接下来需要遍历各个节点,对含有相关指定的节点进行特殊处理,这里咱们先处理最简单的情况,只对带有 '{{变量}}' 这种形式的指令进行处理

 compileElement: function (el) {
        var childNodes = el.childNodes;
        var self = this;
        [].slice.call(childNodes).forEach(function(node) {
            var reg = /\{\{\s*(.*?)\s*\}\}/;
            var text = node.textContent;
            if (self.isTextNode(node) && reg.test(text)) {  // 判断是否是符合这种形式{{}}的指令
                self.compileText(node, reg.exec(text)[1]);
            }

            if (node.childNodes && node.childNodes.length) {
                self.compileElement(node);  // 继续递归遍历子节点
            }
        });
    },
    compileText: function(node, exp) {
        var self = this;
        var initText = this.vm[exp];
        this.updateText(node, initText);  // 将初始化的数据初始化到视图中
        new Watcher(this.vm, exp, function (value) { // 生成订阅器并绑定更新函数
            self.updateText(node, value);
        });
    },
    updateText: function (node, value) {
        node.textContent = typeof value == 'undefined' ? '' : value;
    },
    isTextNode: function(node) {
        return node.nodeType == 3;
    }

获取到最外层节点后,调用compileElement函数,对所有子节点进行判断,如果节点是文本节点且匹配{{}}这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤
1、接下去需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤
2、这样就完成指令的解析、初始化、编译三个过程,一个解析器Compile也就可以正常的工作了。

4、关联Observer和Watcher

function SelfVue (options) {
    var self = this;
    this.vm = this;
    this.data = options.data;

    Object.keys(this.data).forEach(function(key) {
        self.proxyKeys(key);
    });

    observe(this.data);
    new Compile(options.el, this.vm);
    return this;
}

SelfVue.prototype = {
    proxyKeys: function (key) {
        var self = this;
        Object.defineProperty(this, key, {
            enumerable: false,
            configurable: true,
            get: function proxyGetter() {
                return self.data[key];
            },
            set: function proxySetter(newVal) {
                self.data[key] = newVal;
            }
        });
    }
}

如上代码,在页面上可观察到,刚开始titile和name分别被初始化为 'hello world' 和空,2s后title被替换成 '你好' 3s后name被替换成 'canfoo' 了

最后

本文通过监听器 Observer 、订阅器 Dep 、订阅者 Watcher 和解析器 ·的实现,模拟初始化一个 Vue 实例,帮助大家了解数据双向绑定的基本原理,代码已经上传到github上,github地址为:
https://github.com/jingyuanhe/mvvm

参考文献

https://www.cnblogs.com/canfoo/p/6891868.html
https://juejin.im/post/5d421bcf6fb9a06af23853f1#heading-13

你可能感兴趣的:(vue的双向绑定原理及实现)