美文网首页vue前端开发那些事儿Vue
vue的双向绑定原理及实现

vue的双向绑定原理及实现

作者: 景元合 | 来源:发表于2020-03-09 12:49 被阅读0次

    前言

    虽然知道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的双向绑定原理及实现

        本文链接:https://www.haomeiwen.com/subject/ygnudhtx.html