美文网首页
Vue2.0原理与MVVM的实现

Vue2.0原理与MVVM的实现

作者: hellomyshadow | 来源:发表于2020-04-10 13:46 被阅读0次

    双向绑定 -- MVVM

    目前几种主流的MVC框架都实现了单向数据绑定,而我所理解的双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改ModelView,并没有多高深。所以无需太过介怀是实现的单向或双向绑定。

    实现数据绑定的做法有大致如下几种:

    • 发布者-订阅者模式--> backbone.js
    • 脏值检查--> angular.js
    • 数据劫持--> vue.js

    发布者-订阅者模式
    一般通过 sub、pub 的方式实现数据和视图的绑定监听,更新数据的通常做法是vm.set('property', value)。但这种方式放在现在实在太Low了,我们更希望通过 vm.property = value 更新数据,同时自动更新视图。于是便有了 脏值建测数据劫持

    脏值检查
    angular.js是通过脏值检测的方式比对数据是否变更,来决定是否更新视图。最简单的方式是通过 setInterval() 定时轮询检测数据的变动。当然了,Google可不会这么Low,angular只有在指定事件触发时才会进入脏值检测,大致如下:

    • DOM事件,诸如用户输入文本,点击按钮等等,ng-click
    • XHR响应事件,$http
    • 浏览器Location变更事件,$location
    • Timer事件,timeout, interval
    • 执行 digest() / apply()

    数据劫持
    Vue.js则是采用数据劫持并结合订阅-发布模式的方式,通过 Object.defineProperty() 来劫持各个属性的setter、getter,在数据变动时发布消息给订阅者,触发响应的监听回调。

    Vue1.xVue2.x 的核心区别就在于,2.0引入了Virtual DomPitch Diff

    实现过程

    Vue通过数据劫持来实现数据绑定,其中最核心的方法便是通过 Object.defineProperty() 来实现对属性的劫持,达到监听数据变动的目的。
    要实现MVVM,就必须实现以下几点:

    1. Observer 数据劫持,实现一个数据监听器,能够对数据对象的所有属性进行监听,如有变动,则获取到最新的值并通知订阅者。
    2. Compile 模板解析,实现一个指令解析器,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
    3. Watcher,作为连接ObserverCompile的桥梁,能够订阅并接收每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。
    4. MVVM入口函数,整合Observer、Compile、Watcher
    MVVM.png

    Observer

    利用 Obeject.defineProperty() 来监听属性变动,那么就需要将Observe的数据对象进行递归遍历,包括子属性对象的属性,通通加上settergetter。由此一来,给对象的某个属性赋值时,便会触发setter,那么就能监听到数据变化了。

    var data = { name: 'kindeng' };
    observe(data);
    data.name = 'dmq'; // 哈哈哈,监听到值变化了 kindeng --> dmq
    
    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) {
        // 递归监听子属性
        observe(val);
        Object.defineProperty(data, key, {
            enumerable: true, // 可枚举
            configurable: false, // 不能再define
            get: function() {
                return val;
            },
            set: function(newVal) {
                if(val === newVal) return;  // 相等就不更新了
                console.log('监听到值变化了: ', val, ' --> ', newVal);
                observe(val);  // 如果是对象,则递归监听其属性
                val = newVal;
            }
        });
    }
    

    这样就监听到了每个数据的变化了。
    接下来还需要实现一个消息订阅器,也就是维护一个数组,用于收集订阅者,数据变动则触发notify,再调用订阅者update方法

    // ... 省略
    function defineReactive(data, key, val) {
        var dep = new Dep();
    
        observe(val);
    
        Object.defineProperty(data, key, {
            // ... 省略
            set: function(newVal) {
                if (val === newVal) return;
                console.log('监听到值变化了: ', val, ' --> ', newVal);
                val = newVal;
                // 通知所有订阅者
                dep.notify();
            }
        });
    }
    
    function Dep() {
        this.subs = [];
    }
    Dep.prototype = {
        addSub: function(sub) {
            this.subs.push(sub);
        },
        notify: function() {
            this.subs.forEach(sub => sub.update());
        }
    };
    

    所谓的订阅者 也就是 Watcher,由于 var dep = new Dep(); 定义在 defineReactive() 中,所以想通过 dep 添加订阅者,就必须要在闭包内操作,也就可以在 getter 中:

    // Observer.js
    // ...省略
    Object.defineProperty(data, key, {
        get: function() {
            // 由于需要在闭包内添加Watcher,所以通过Dep定义一个全局target属性,暂存Watcher, 添加完移除
            Dep.target && dep.addSub(Dep.target);
            return val;
        }
        // ... 省略
    });
    
    // Watcher.js
    Watcher.prototype = {
        get: function(key) {
            Dep.target = this;
            // 这里会触发属性的getter,从而添加订阅者
            this.value = data[key];
            // dep.addSub() 添加订阅者之后,则移除target属性
            Dep.target = null;
        }
    }
    

    实现了一个Observer,已经具备监听数据和数据变动通知订阅者的功能。
    完整代码 - observer.js

    function Observer(data) {
        this.data = data;
        this.walk(data);
    }
    
    Observer.prototype = {
        constructor: Observer,
        walk: function(data) {
            var me = this;
            Object.keys(data).forEach(function(key) {
                me.convert(key, data[key]);
            });
        },
        convert: function(key, val) {
            this.defineReactive(this.data, key, val);
        },
    
        defineReactive: function(data, key, val) {
            var dep = new Dep();
            observe(val);  // 递归监听
            Object.defineProperty(data, key, {
                enumerable: true, // 可枚举
                configurable: false, // 不能再define
                get: function() {
                    Dep.target && dep.depend();
                    return val;
                },
                set: function(newVal) {
                    if (newVal === val) return;
                    val = newVal;
                    // 新的值是object的话,进行监听
                    observe(newVal);
                    // 通知订阅者
                    dep.notify();
                }
            });
        }
    };
    
    function observe(value, vm) {
        if (!value || typeof value !== 'object') {
            return;
        }
        return new Observer(value);
    };
    
    var uid = 0;
    function Dep() {
        this.id = uid++;
        this.subs = [];
    }
    Dep.prototype = {
        addSub: function(sub) {
            this.subs.push(sub);
        },
        depend: function() {
            Dep.target.addDep(this);
        },
        removeSub: function(sub) {
            var index = this.subs.indexOf(sub);
            if (index != -1) {
                this.subs.splice(index, 1);
            }
        },
        notify: function() {
            this.subs.forEach(sub  => sub.update());
        }
    };
    Dep.target = null;
    

    Compile

    Compile的主要任务就是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据变动,手动通知,更新视图

    compile.png

    遍历解析的过程会多次操作DOM节点,为了提高性能和效率,会先将根节点el 转换成文档碎片Fragment进行解析编译操作,解析完成后再将Fragment添加会原来的真实DOM节点中。
    Fragment 的优点:优化DOM操作的常见方式,其操作与原生DOM基本一致,但对它的修改不会立即触发浏览器的渲染(重绘和回流),最后再通过appendChild()/insertBefore()一次性插入到真实DOM中。

    function Compile(el) {
        // 如果 el 不是一个DOM节点,则作为选择器去查找DOM
        this.$el = this.isElementNode(el) ? el : document.querySelector(el);
        if (this.$el) {
            // 1. 生成 Fragment,同时删除掉了 el 中的所有子节点
            this.$fragment = this.node2Fragment(this.$el);
            this.init();   // 2. 通过 Fragment 编译模板
            // 3. 把编译后的模板再插入到 el 中
            this.$el.appendChild(this.$fragment);
        }
    }
    Compile.prototype = {
        init: function() { this.compileElement(this.$fragment); },
        node2Fragment: function(el) {
            // DOM节点转为Fragment
            var fragment = document.createDocumentFragment(), child;
            // 将原生节点拷贝到fragment
            while (child = el.firstChild) {
                // appendChild() 会同时删除掉 el 中的子元素
                fragment.appendChild(child);
            }
            return fragment;
        }
    };
    

    compileElement() 函数将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定

    Compile.prototype = {
        // ... 省略
        compileElement: function(el) {
            // 获取 fragment 中的所有子节点,开始编译、解析
            var childNodes = el.childNodes, me = this;
            [].slice.call(childNodes).forEach(function(node) {  // 类数组 转 数组
                var text = node.textContent;
                var reg = /\{\{(.*)\}\}/;    // 解析 表达式文本 {{ xxx }}
                // 按元素节点方式编译
                if (me.isElementNode(node)) {
                    me.compile(node);
                } else if (me.isTextNode(node) && reg.test(text)) {
                    me.compileText(node, RegExp.$1);  // 处理文本节点
                }
                // 递归遍历编译子节点
                if (node.childNodes && node.childNodes.length) {
                    me.compileElement(node);
                }
            });
        },
        compile: function(node) {
            var nodeAttrs = node.attributes, me = this;
            [].slice.call(nodeAttrs).forEach(function(attr) {
                // 规定:指令以 v-xxx 命名
                // 如 <span v-text="content"></span> 中指令为 v-text
                var attrName = attr.name;    // v-text
                if (me.isDirective(attrName)) {
                    var exp = attr.value; // content
                    var dir = attrName.substring(2);    // text
                    if (me.isEventDirective(dir)) {
                        // 事件指令, 如 v-on:click
                        compileUtil.eventHandler(node, me.$vm, exp, dir);
                    } else {
                        // 普通指令
                        compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
                    }
                }
            });
        }
    };
    
    // 指令处理集合
    var compileUtil = {
        text: function(node, vm, exp) {
            this.bind(node, vm, exp, 'text');
        },
        // ...省略
        bind: function(node, vm, exp, dir) {
            var updaterFn = updater[dir + 'Updater'];
            // 第一次初始化视图
            updaterFn && updaterFn(node, vm[exp]);
            // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
            new Watcher(vm, exp, function(value, oldValue) {
                // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
                updaterFn && updaterFn(node, value, oldValue);
            });
        }
    };
    
    // 更新函数
    var updater = {
        textUpdater: function(node, value) {
            node.textContent = typeof value == 'undefined' ? '' : value;
        }
        // ...省略
    };
    

    通过递归遍历保证了每个节点及其子节点都会解析编译,包括{{ xxx }}表达式生命的文本节点。
    指令的声明规定是通过特定前缀的节点属性来标记,如<span v-text="content" other-attr> 中的 v-text 是指令,而other-attr只是普通的属性。
    监听数据、绑定更新函数的处理在 compileUtil.bind() 方法中,通过 new Watcher() 添加回调来接收数据变化的通知。
    完整代码 - compile.js

    function Compile(el, vm) {
        this.$vm = vm;
        this.$el = this.isElementNode(el) ? el : document.querySelector(el);
        if (this.$el) {
            this.$fragment = this.node2Fragment(this.$el);
            this.init();
            this.$el.appendChild(this.$fragment);
        }
    }
    Compile.prototype = {
        constructor: Compile,
        node2Fragment: function(el) {
            var fragment = document.createDocumentFragment(), child;
            // 将原生节点拷贝到fragment
            while (child = el.firstChild) {
                fragment.appendChild(child);
            }
            return fragment;
        },
        init: function() {
            this.compileElement(this.$fragment);
        },
        compileElement: function(el) {
            var childNodes = el.childNodes, me = this;
            [].slice.call(childNodes).forEach(function(node) {
                var text = node.textContent;
                var reg = /\{\{(.*)\}\}/;
    
                if (me.isElementNode(node)) {
                    me.compile(node);
    
                } else if (me.isTextNode(node) && reg.test(text)) {
                    me.compileText(node, RegExp.$1.trim());
                }
    
                if (node.childNodes && node.childNodes.length) {
                    me.compileElement(node);
                }
            });
        },
        compile: function(node) {
            var nodeAttrs = node.attributes, me = this;
            [].slice.call(nodeAttrs).forEach(function(attr) {
                var attrName = attr.name;
                if (me.isDirective(attrName)) {
                    var exp = attr.value;
                    var dir = attrName.substring(2);
                    // 事件指令
                    if (me.isEventDirective(dir)) {
                        compileUtil.eventHandler(node, me.$vm, exp, dir);
                        // 普通指令
                    } else {
                        compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
                    }
                    node.removeAttribute(attrName);
                }
            });
        },
        compileText: function(node, exp) {
            compileUtil.text(node, this.$vm, exp);
        },
        isDirective: function(attr) {
            return attr.indexOf('v-') == 0;
        },
        isEventDirective: function(dir) {
            return dir.indexOf('on') === 0;
        },
        isElementNode: function(node) {
            return node.nodeType == 1;
        },
        isTextNode: function(node) {
            return node.nodeType == 3;
        }
    };
    
    // 指令处理集合
    var compileUtil = {
        text: function(node, vm, exp) {
            this.bind(node, vm, exp, 'text');
        },
        html: function(node, vm, exp) {
            this.bind(node, vm, exp, 'html');
        },
        model: function(node, vm, exp) {
            this.bind(node, vm, exp, 'model');
    
            var me = this, val = this._getVMVal(vm, exp);
            node.addEventListener('input', function(e) {
                var newValue = e.target.value;
                if (val === newValue) {
                    return;
                }
                me._setVMVal(vm, exp, newValue);
                val = newValue;
            });
        },
        class: function(node, vm, exp) {
            this.bind(node, vm, exp, 'class');
        },
        bind: function(node, vm, exp, dir) {
            var updaterFn = updater[dir + 'Updater'];
            updaterFn && updaterFn(node, this._getVMVal(vm, exp));
            new Watcher(vm, exp, function(value, oldValue) {
                updaterFn && updaterFn(node, value, oldValue);
            });
        },
        // 事件处理
        eventHandler: function(node, vm, exp, dir) {
            var eventType = dir.split(':')[1],
                fn = vm.$options.methods && vm.$options.methods[exp];
    
            if (eventType && fn) {
                node.addEventListener(eventType, fn.bind(vm), false);
            }
        },
        _getVMVal: function(vm, exp) {
            var val = vm;
            exp = exp.split('.');
            exp.forEach(function(k) {
                val = val[k];
            });
            return val;
        },
        _setVMVal: function(vm, exp, value) {
            var val = vm;
            exp = exp.split('.');
            exp.forEach(function(k, i) {
                // 非最后一个key,更新val的值
                if (i < exp.length - 1) {
                    val = val[k];
                } else {
                    val[k] = value;
                }
            });
        }
    };
    
    var updater = {
        textUpdater: function(node, value) {
            node.textContent = typeof value == 'undefined' ? '' : value;
        },
        htmlUpdater: function(node, value) {
            node.innerHTML = typeof value == 'undefined' ? '' : value;
        },
        classUpdater: function(node, value, oldValue) {
            var className = node.className;
            className = className.replace(oldValue, '').replace(/\s$/, '');
            var space = className && String(value) ? ' ' : '';
            node.className = className + space + value;
        },
        modelUpdater: function(node, value, oldValue) {
            node.value = typeof value == 'undefined' ? '' : value;
        }
    };
    

    实现Watcher

    订阅者Watcher作为ObserverCompile之间的桥梁,主要职责是:

    • 在自身实例化时往属性订阅器dep 里添加自己
    • 自身必须有一个 update() 方法
    • 待属性变动 dep.notify() 通知时,能够调用自身的 update() 方法,并触发 Compile 中绑定的回调,则功成身退!

    回顾核心代码

    function Watcher(vm, exp, cb) {
        this.cb = cb;
        this.vm = vm;
        this.exp = exp;
        // 为了触发属性的getter,从而在 dep 中添加自己
        this.value = this.get(); 
    }
    Watcher.prototype = {
        update: function() {
            this.run();    // 属性值变化收到通知
        },
        run: function() {
            var value = this.get(); // 取到最新值
            var oldVal = this.value;
            if (value !== oldVal) {
                this.value = value;
                // 执行Compile中绑定的回调,更新视图
                this.cb.call(this.vm, value, oldVal);
            }
        },
        get: function() {
            // 将当前订阅者指向自己
            Dep.target = this;
            // 触发属性的getter,添加自己到属性订阅器中
            var value = this.vm[exp];
            // 添加完毕,重置为null
            Dep.target = null;
            return value;
        }
    };
    // Observer 和 Dep
    Object.defineProperty(data, key, {
        get: function() {
            // 添加watcher,在Dep上定义一个全局target属性,暂存watcher, 添加完移除
            Dep.target && dep.addDep(Dep.target);
            return val;
        }
        // ... 省略
    });
    Dep.prototype = {
        notify: function() {
            this.subs.forEach(function(sub) {
                sub.update(); // 调用订阅者的update方法,通知变化
            });
        }
    };
    

    实例化Watcher时,调用自己的 get() 方法,通过 Dep.target = this; 标记订阅者是当前Watcher实例,又通过 var value = this.vm[exp]; 强行触发属性定义的getter方法,getter方法执行并在订阅器dep 中添加当前的Watcher实例,从而在属性值变化时让Watcher收到更新通知。
    完整代码 - watcher.js

    function Watcher(vm, expOrFn, cb) {
        this.cb = cb;
        this.vm = vm;
        this.expOrFn = expOrFn;
        this.depIds = {};
        if (typeof expOrFn === 'function') {
            this.getter = expOrFn;
        } else {
            this.getter = this.parseGetter(expOrFn.trim());
        }
        this.value = this.get();
    }
    
    Watcher.prototype = {
        constructor: Watcher,
        update: function() {
            this.run();
        },
        run: function() {
            var value = this.get();
            var oldVal = this.value;
            if (value !== oldVal) {
                this.value = value;
                this.cb.call(this.vm, value, oldVal);
            }
        },
        addDep: function(dep) {
            /** 
             1. 每次调用run()的时候会触发相应属性的getter
             getter里面会触发dep.depend(),继而触发这里的addDep
             2. 假如相应属性的dep.id已经在当前watcher的depIds里,
             说明不是一个新的属性,仅仅是改变了其值而已
             则不需要将当前watcher添加到该属性的dep里
             3. 假如相应属性是新的属性,则将当前watcher添加到新属性的dep里
             如通过 vm.child = {name: 'a'} 改变了 child.name 的值,child.name 就是个新属性
             则需要将当前watcher(child.name)加入到新的 child.name 的dep里
             因为此时 child.name 是个新值,之前的 setter、dep 都已经失效,
             如果不把 watcher 加入到新的 child.name 的dep中
             通过 child.name = xxx 赋值的时候,对应的 watcher 就收不到通知,等于失效了
             4. 每个子属性的watcher在添加到子属性的dep的同时,也会添加到父属性的dep
             监听子属性的同时监听父属性的变更,这样,父属性改变时,
             子属性的watcher也能收到通知进行update
             这一步是在 this.get() --> this.getVMVal() 里面完成,forEach时会从父级开始取值,
             间接调用了它的getter
             触发了addDep(), 在整个forEach过程,当前wacher都会加入到每个父级过程属性的dep
             例如:当前watcher的是'child.child.name', 那么child, child.child, 
             child.child.name这三个属性的dep都会加入当前watcher
            **/
            if (!this.depIds.hasOwnProperty(dep.id)) {
                dep.addSub(this);
                this.depIds[dep.id] = dep;
            }
        },
        get: function() {
            Dep.target = this;
            var value = this.getter.call(this.vm, this.vm);
            Dep.target = null;
            return value;
        },
        parseGetter: function(exp) {
            if (/[^\w.$]/.test(exp)) return; 
    
            var exps = exp.split('.');
            return function(obj) {
                for (var i = 0, len = exps.length; i < len; i++) {
                    if (!obj) return;
                    obj = obj[exps[i]];
                }
                return obj;
            }
        }
    };
    

    实现MVVM

    MVVM作为数据绑定的入口,整合Observer、Compile、Watcher三者,通过Observer来监听自己的Model变化,通过Compile来解析编译模板指令,最终利用Watcher搭起ObserverCompile之间的通信桥梁,达到数据变化 --> 视图更新;视图交互变化 --> 数据Model变更的双向绑定效果。

    一个简单的MVVM构造器:

    function MVVM(options) {
        this.$options = options;
        var data = this._data = this.$options.data;
        observe(data, this);
        this.$compile = new Compile(options.el || document.body, this)
    }
    

    但这样有个问题,监听的对象数据是options.data,每次需要更新视图就必须通过 var vm = new MVVM({data:{name: 'kindeng'}}); vm._data.name = 'dmq'; 的方式来改变数据。
    显然不符合期望:var vm = new MVVM({data: {name: 'kindeng'}}); vm.name = 'dmq';

    所以,需要给MVVM实例增加一个属性代理的方法,使访问 vm 的属性代理为访问 vm._data 的属性。

    function MVVM(options) {
        this.$options = options;
        var data = this._data = this.$options.data, me = this;
        // 属性代理,实现 vm.xxx -> vm._data.xxx
        Object.keys(data).forEach(function(key) {
            me._proxy(key);
        });
        observe(data, this);
        this.$compile = new Compile(options.el || document.body, this)
    }
    MVVM.prototype = {
        _proxy: function(key) {
            var me = this;
            Object.defineProperty(me, key, {
                configurable: false,
                enumerable: true,
                get: function proxyGetter() {
                    return me._data[key];
                },
                set: function proxySetter(newVal) {
                    me._data[key] = newVal;
                }
            });
        }
    };
    

    主要还是利用了 Object.defineProperty() 方法来劫持对vm实例对象的属性读写权,使读写vm的属性转成读写 vm._data 的属性值,达到鱼目混珠的效果!

    完整代码 - mvvm.js

    function MVVM(options) {
        this.$options = options || {};
        var data = this._data = this.$options.data;
        var me = this;
        // 数据代理
        // 实现 vm.xxx -> vm._data.xxx 的变化
        Object.keys(data).forEach(function(key) {
            me._proxyData(key);
        });
    
        this._initComputed();
    
        observe(data, this);
    
        this.$compile = new Compile(options.el || document.body, this)
    }
    
    MVVM.prototype = {
        constructor: MVVM,
        $watch: function(key, cb, options) {
            new Watcher(this, key, cb);
        },
        _proxyData: function(key, setter, getter) {
            var me = this;
            setter = setter || 
            Object.defineProperty(me, key, {
                configurable: false,
                enumerable: true,
                get: function proxyGetter() {
                    return me._data[key];
                },
                set: function proxySetter(newVal) {
                    me._data[key] = newVal;
                }
            });
        },
        _initComputed: function() {
            var me = this;
            var computed = this.$options.computed;
            if (typeof computed === 'object') {
                Object.keys(computed).forEach(function(key) {
                    Object.defineProperty(me, key, {
                        get: typeof computed[key] === 'function' 
                                ? computed[key] 
                                : computed[key].get,
                        set: function() {}
                    });
                });
            }
        }
    };
    

    index.html

    <head>
        <meta charset="UTF-8">
        <title>MVVM</title>
        <style type="text/css">
            .test-btn {
                font-size: 20px;
                color: blue;
            }
        </style>
    </head>
    <body>
        <div id="mvvm-app">
            <input type="text" v-model="testName">
            <input type="text" v-model="person.info">
            <p v-class="clzName" class="abc">
                <span>{{testName}}</span>
                <span v-text="person.info"></span>
            </p>
            <p>{{ getAll }}</p>
            <p v-html="htmlStr"></p>
            <button v-on:click="onBtnClick">change model</button>
        </div>
    </body>
    <script src="./js/observer.js"></script>
    <script src="./js/watcher.js"></script>
    <script src="./js/compile.js"></script>
    <script src="./js/mvvm.js"></script>
    <script>
        var vm = new MVVM({
            el: '#mvvm-app',
            data: {
                testName: 'hello',
                clzName: 'test-btn',
                htmlStr: '<span style="color: #f00;">red v-html</span>',
                person: {
                    info: 'World !'
                }
            },
            computed: {
                getAll: function() {  // 计算属性
                    return this.testName + this.person.info;
                }
            },
            methods: {
                onBtnClick: function(e) {  // 点击事件
                    this.person.info = +new Date();
                }
            }
        });
    
        vm.$watch('person.info', function() {
            // 监听 person.info 的变化
            console.log(arguments);
        });
    </script>
    

    相关文章

      网友评论

          本文标题:Vue2.0原理与MVVM的实现

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