vue 学习笔记-数据双向绑定

vue 学习笔记-数据双向绑定

这是我学习 vue 的笔记,有错误欢迎指出。

1 MVVM

双向数据绑定指的是,将对象属性变化与视图的变化相互绑定。换句话说,如果有一个拥有name属性的user对象,与元素的内容绑定,当给user.name赋予一个新值,页面元素节点也会相应的显示新的数据。同样的,如果页面元素(通常是input)上的数据改变,输入一个新的值会导致user对象中的name属性发生变化。

MVVM最早由微软提出来,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。
把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。

总之一句话,数据与表现分离,当某一个数据改变时,页面上所有使用这个数据的元素的内容都会改变。下面是一个最简单的数据绑定的例子,来自Vue2.0源码阅读笔记–双向绑定实现原理,这个例子十分简单粗暴,就做了三件事:

  1. 创建 obj 对象,用来保存数据
  2. 监听 keyup 事件,当事件触发时,把选定的 input 标签的值赋给 obj 对象的 hello 属性。
  3. 改变 obj 对象 的 hello 属性的 set 方法,当 hell 被赋值时,将这个值同时赋值给选中的两个元素。
<!DOCTYPE html>
 <head></head>
 <body>
  <div id="app">
    <input type="text" id="a">
    <span id="b"></span>
  </div>

  <script type="text/javascript">
   var obj = {};
   Object.defineProperty(obj, 'hello', {
       get: function() {
           console.log('get val:'+ val);
           return val;
       },
       set: function(newVal) {
            val = newVal;
            console.log('set val:'+ val);
            document.getElementById('a').value = val;
            document.getElementById('b').innerHTML = val;
       }
    });
    document.addEventListener('keyup', function(e) {
      obj.hello = e.target.value;
    });
   </script>
  </body>
</html>

1.1 实现数据双向绑定的方式

双向数据绑定底层的思想非常的基本,它可以被压缩成为三个步骤:

  1. 我们需要一个方法来识别哪个UI元素被绑定了相应的属性(上面的例子里直接选中了元素,而没有提供对外的函数)
  2. 我们需要监视属性和UI元素的变化
  3. 我们需要将所有变化传播到绑定的对象和元素

常见的实现数据绑定的方法,有大致如下几种:

  • 发布者-订阅者模式
  • 脏值检查
  • 数据劫持

其中最简单也是最有效的途径,是使用发布者-订阅者模式。上面的例子就使用到了。

发布者-订阅者模式的思想很简单:使用自定义的data属性在HTML代码中指明绑定。所有绑定起来的JavaScript对象以及DOM元素都将“订阅”一个发布者对象。任何时候,如果某一个被绑定的内容(如JavaScript对象或者一个HTML输入字段)被侦测到发生了变化,我们将代理事件到发布者-订阅者模式,这会反过来将变化广播并传播到所有绑定的对象和元素。下面是一个来自谈谈JavaScript中的双向数据绑定的例子,我在注释里添加了一些我的理解。

function DataBinder(object_id){
    //创建一个简单地PubSub对象   
    var pubSub = { // 一个pubSub 对象,内部有一个 callbacks 对象,保存回调函数
        callbacks: {}, // 键名为触发回调函数的自定义事件名称,值为一个数组,每一项都是一个回调函数
        on: function(msg,callback){ // on 方法 传入参数,一个字符串(就是自定义事件的名称),一个回调函数
            this.callbacks[msg] = this.callbacks[msg] || []; // 以 msg 作为键名,创建数组(如果存在,等于原数组)
            this.callbacks[msg].push(callback); // 将新的回调函数加入数组
        },
        publish: function(msg){ // publish 方法
            this.callbacks[msg] = this.callbacks[msg] || []; // 根据 msg 传入的参数,调用 this.callbacks 对象 的 msg 属性保存的数组,如果没有,等于新建的空数组
            for(var i = 0, len = this.callbacks[msg].length; i<len;i++){ // 循环调用所有注册在了 msg 里的回调函数
                this.callbacks[msg][i].apply(this,arguments); // 调用注册的回调函数时,将 this 指向 publish 的调用者, 参数为 publish 函数调用时传入的参数
            }
        }
    },
    data_attr = "data-bind-" + object_id, // 产生一个字符串,对传入的参数进行处理,加上“data-bind”前缀加上,后面会用这个字符串作为属性名,获得需要绑定的元素
    message = object_id + ":change", // 产生一个字符串,对传入的参数进行处理,加上“:change”后缀加上,后面会用这个字符串作为事件名,将事件派发给接收的元素
    changeHandler = function(evt){ // 根据事件的触发者,判断是否是监听的数据
        var target = evt.target || evt.srcElemnt, //IE8兼容 触发事件的元素
            prop_name = target.getAttribute(data_attr); // 得到元素的 data_attr 属性

            if(prop_name && prop_name !== ""){ // 根据元素属性,判断是否是监听的元素
                pubSub.publish(message,prop_name,target.value); // 广播 message 事件,调用所以注册了 message 事件的函数,调用注册的回调函数时,将 this 指向 publish 的调用者, 参数为 publish 函数调用时传入的参数(publish 函数内部有 apply)
            }
    };
    //监听变化事件并代理到PubSub 
    if(document.addEventListener){ // 监听整个文档的变化,并调用 changeHandler 函数
        document.addEventListener("change",changeHandler,false);
    }else{
        //IE8使用attachEvent而不是addEventListener     
        document.attachEvent("onchange",changeHandler);
    }
    //PubSub将变化传播到所有绑定元素    
    pubSub.on(message,function(vet,prop_name,new_val){ // 调用 pubSub.on 方法,注册 message 事件的回调函数,本例中,message 事件也只绑定了一个回调函数,就是这个匿名函数,功能是将变化元素的值传入所有被监听的元素
        var elements = document.querySelectorAll("[" + data_attr + "=" + prop_name + "]"), // 根据传入的参数,获得所以需要接受数据的元素
                tah_name;
        for(var i = 0,len =elements.length; i < len; i++){ // 循环对元素进行处理
            tag_name = elements[i].tagName.toLowerCase();

            if(tag_name === "input" || tag_name === "textarea" || tag_name === "select"){ // 根据元素的种类,确定数据额输出方式
                elements[i].value = new_val;
            }else{
                elements[i].innerHTML = new_val;
            }
        }
    });
    return pubSub;
}

//在model的设置器中   
function User(uid){
    var binder = new DataBinder(uid), // 返回一个 pubSub 对象,其上保存了由传入参数 uid 确定的元素所有绑定的回调函数
    user = {
        attributes: {}, // 保存需要同步的数据
        set: function(attr_name,val){ // 调用 set 方法,将需要同步的数据通过 publish 方法传给监听的元素
            this.attributes[attr_name] = val;
            //使用“publish”方法  
            binder.publish(uid+ ":change", attr_name, val,this);
        },
        get: function(attr_name){
            return this.attributes[attr_name];
        }
    }
    return user; // 函数作为一个构造函数时,返回一个对象,作为这个构造函数的实例
}       

var user = new User(123); // 返回一个 user 对象,对象有一个 attributes 属性指向一个对象,这个对象保存这需要同步的数据
user.set("name","Wolfgang"); // 所有带有  data-bind-123="name" 属性的 html 标签都会被监听,它们的值会同步改变,保持相同

然后说脏检查,脏检查是一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。简单来说,脏检查是直接检测数据是否改变,如果某一个被监听的数据改变,就将这个值传给所有被被监听者。

而数据劫持,就是通过对属性的 set get 方法进行改造,来监测数据的改变,发布消息给订阅者,触发相应的监听回调。

2 vue 数据双向绑定

已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的。

要实现mvvm的双向绑定,主要进行了:

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

例子大体来自这篇文章的,我根据自己的理解做了些修改,添加了一些注释

为了便于理解,首先,来实现一个消息的储存中转的构造函数:

var uid = 0; // 通过全局的 uid 给 Dep 实例增加唯一 id,以区分不同实例

function Dep() {
    this.id = uid++; // 给 Dep 实例添加 id,并将全局的 uid 加1
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub) { // 增加 sub
        this.subs.push(sub);
    },

    depend: function() {
        Dep.target.addDep(this); // 将全局对象 Dep 的 target 属性指向的对象(这个函数的调用者 this)添加的 subs 里
    },

    removeSub: function(sub) { // 删处 sub
        var index = this.subs.indexOf(sub);
        if (index != -1) {
            this.subs.splice(index, 1);
        }
    },

    notify: function() { // 通知所有 subs 数据已更新
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};

通过修改对象的属性,每一个绑定的属性都会有一个 Dep 实例。每一个 Dep 实例都会有一个 subs 属性,用来存储需要通知的对象,当对象属性改变时,通过 set 方法,调用这个属性的 Dep 实例的原型的 notify 方法,根据 subs 数组保存的内容,通知绑定了这个属性值的数据修改内容。

function Observer(data) {
    this.data = data;
    this.walk(data); // 调用原型的方法,处理对象
}

Observer.prototype = {
    walk: function(data) {
        var me = this;
        Object.keys(data).forEach(function(key) { // 遍历 data 的属性,修改属性的 get / set
            me.convert(key, data[key]);
        });
    },
    convert: function(key, val) {
        this.defineReactive(this.data, key, val);
    },

    defineReactive: function(data, key, val) { // 对属性进行修改
        var dep = new Dep();
        var childObj = observe(val);

        Object.defineProperty(data, key, {
            enumerable: true, // 可枚举
            configurable: false, // 不能再define
            get: function() {
                if (Dep.target) {
                    dep.depend(); // 将全局的 Dep.target 添加到 dep 实例的 subs 数组里
                }
                return val;
            },
            set: function(newVal) {
                if (newVal === val) {
                    return;
                }
                val = newVal;
                // 新的值是object的话,进行监听
                childObj = observe(newVal);
                // 通知订阅者
                dep.notify();
            }
        });
    }
};

function observe(value, vm) {
    if (!value || typeof value !== 'object') {
        return;
    }

    return new Observer(value);
};

然后对 html 模板进行编译,根据每个节点及其的属性,判断是否包含 ‘{{}}’,’v-‘,’on’ 等特殊字符串,判断是否进行了绑定,将绑定了的属性个 get set 进行处理,

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 = {
    node2Fragment: function(el) {
        var fragment = document.createDocumentFragment(),
            child;

        // 将原生节点拷贝到fragment
        while (child = el.firstChild) { // 如果 el 有资源素,就将其赋值给 child,返回 true
            fragment.appendChild(child); // 将 child 从 el 转移到 fragment 下,el 会少一个资源素,进行下一轮循环
        }

        return fragment; // 返回 fragment
    },

    init: function() {
        this.compileElement(this.$fragment); // 对 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);
            }

            if (node.childNodes && node.childNodes.length) {
                me.compileElement(node); // 递归调用,处理子元素
            }
        });
    },

    compile: function(node) {
        var nodeAttrs = node.attributes, // 获得 dom 节点在 html 代码里设置的属性
            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;
    }
}

最后,实现 watch,监视属性的变化。watch 的每个实例,会添加到希望监听的属性的 dep.subs 数组中,当监听的数据发生变化,调用 notify 函数,然后函数内部调用 subs 中所以 watch 实例的 updata 方法,通知监听这个数据的对象。受到通知后,对象判断值是否改变,如果改变,调用回调函数,更改视图

function Watcher(vm, exp, cb) {
    this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    // 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解
    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;
            this.cb.call(this.vm, value, oldVal); // 执行Compile中绑定的回调,更新视图
        }
    },
    get: function() {
        Dep.target = this;  // 将当前订阅者指向自己
        var value = this.vm[exp];   // 触发getter,添加自己到属性订阅器中
        Dep.target = null;  // 添加完毕,重置
        return value;
    }
};

最后,通过 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)
}

上面的内容只是实现数据绑定的大概思路,其他内容我再慢慢完善。

3 vue 数据双向绑定的缺陷

3.1 vue 实例创建后,再向其上添加属性,不能监听

当创建一个Vue实例时,将遍历所有 DOM 对象,并为每个数据属性添加了 get 和 set。 get 和 set 允许 Vue 观察数据的更改并触发更新。但是,如果你在 Vue 实例化后添加(或删除)一个属性,这个属性不会被 vue 处理,改变 get 和 set。

如果你不想创建一个新的对象,你可以使用Vue.set设置一个新的对象属性。该方法确保将属性创建为一个响应式属性,并触发视图更新:

function addToCart (id) {
    var item = this.cart.findById(id);
    if (item) {
        item.qty++
    } else {
        // 不要直接添加一个属性,比如 item.qty = 1
        // 使用Vue.set 创建一个响应式属性
        Vue.set(item, 'qty', 1)
        this.cart.push(item)
    }
}
addToCart(myProduct.id);

3.2 数组

Object.defineProperty 的一个缺陷是无法监听数组变化。

当直接使用索引(index)设置数组项时,不会被 vue 检测到:

app.myArray[index] = newVal;

然而Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下八种方法, vm.items[indexOfItem] = newValue 这种是无法检测的。

push();
pop();
shift();
unshift();
splice();
sort();
reverse();

同样可以使用Vue.set来设置数组项:

Vue.set(app.myArray, index, newVal);

3.3 proxy 与 defineproperty

Proxy 对象在ES2015规范中被正式发布,用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。

它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

我们可以这样认为,Proxy是Object.defineProperty的全方位加强版,具体的文档可以查看此处;

Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等,是Object.defineProperty不具备的。
Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改。
Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy的劣势就是兼容性问题,而且无法用polyfill磨平,因此Vue的作者才声明需要等到下个大版本(3.0)才能用Proxy重写。

参考资料:

MVVM(廖雪峰的官方网站)
谈谈JavaScript中的双向数据绑定
脏检查及其优点
Vue2.0源码阅读笔记–双向绑定实现原理
剖析Vue原理&实现双向绑定MVVM
mdn Object.defineProperty()
Vue响应式及其缺陷
面试官: 实现双向绑定Proxy比defineproperty优劣如何?
MDN Proxy

猜你喜欢

转载自blog.csdn.net/agyyl0/article/details/80299326
今日推荐