vue双向数据绑定原理

网上关于VUE双向数据绑定的文章多如牛毛,此文章仅用做本身总结。javascript

VUE双向数据绑定用到了文档碎片documentFragmentObject.definePropertyproxy发布订阅模式,下面来分别介绍一下这几个知识点,而后运用它们写一个JS原生的双向数据绑定案例。html

DocumentFragment

建立一个新的空白的文档片断。DocumentFragments是DOM节点。它们不是主DOM树的一部分。一般的用例是建立文档片断,将元素附加到文档片断,而后将文档片断附加到DOM树。在DOM树中,文档片断被其全部的子元素所代替。由于文档片断存在于内存中,并不在DOM树中,因此将子元素插入到文档片断时不会引发页面回流(reflow)(对元素位置和几何上的计算)。所以,使用文档片断document fragments一般会起到优化性能的做用。vue

Demojava

<body>
    <ul data-uid="ul"></ul>
</body>

<script>
    let ul = document.querySelector(`[data-uid="ul"]`),
        docfrag = document.createDocumentFragment();
    
    const browserList = [
        "Internet Explorer", 
        "Mozilla Firefox", 
        "Safari", 
        "Chrome", 
        "Opera"
    ];
    
    browserList.forEach((e) => {
        let li = document.createElement("li");
        li.textContent = e;
        docfrag.appendChild(li);
    });
    
    ul.appendChild(docfrag);
</script>
复制代码

defineProperty

对象的属性分为:数据属性和访问器属性。若是要修改对象的默认特性,必须使用Object.defineProperty方法,它接收三个参数:属性所在的对象、属性的名字、一个描述符对象。node

数据属性:

数据属性包含一个数据值的位置,在这个位置能够读取和写入值,数据属性有4个描述其行为的特性。es6

  • Configurable:表示可否经过delete删除属性从而从新定义属性,可否修改属性的特性,或者可否把属性修改成访问器属性。默认值为true。
  • Enumberable:表示可否经过for-in循环返回属性。默认值为true
  • Writable:表示可否修改属性的值。默认值为true
  • Value:包含这个属性的数据值。读取属性值的时候,从这个位置读;定稿属性值的时候,把新值保存在这个位置。默认值为true
访问器属性:

访问器属性不包含数据值;它们包含一对getter、setter函数(两个函数不是必须的)。在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。访问器属性有以下4个特性。设计模式

  • Configurable:表示可否经过delete删除属性从而从新定义属性,可否修改属性的特性,或者可否把属性修改成数据属性。默认值为true
  • Enumerable:表示可否经过for-in循环返回属性。默认值为true
  • Get:在读取属性时调用的函数。默认值为undefined
  • Set:在定稿属性时调用的函数。默认值为undefined

Demo数组

var book = {
    _year: 2018,
    edition: 1
};
Object.defineProperty(book, "year", {
    get: function(){
        return this._year;
    },
    set: function(newVal){
        if(newVal > 2008){
            this._year = newVal;
            this.edition += newVal - 2008;
        }
    }
});

book.year = 2019;
console.log(book._year);//2019
console.log(book.edition);//12
复制代码

Object.defineProperty缺陷:bash

  1. 只能对属性进行数据劫持,对于JS对象劫持须要深度遍历;
  2. 对于数组不能监听到数据的变化,而是经过一些hack办法来实现,如pushpopshiftunshiftsplicesortreverse详见文档

proxy

ES6新方法,它能够理解成,在目标对象以前架设一层“拦截”,外界对该对象的访问,都必须先经过这层拦截,所以提供了一种机制,能够对外界的访问进行过滤和改写。Proxy这个词的原意是代理,用在这里表示由它来“代理”某些操做,能够译为“代理器”。proxy支持的方法有:app

  • get():拦截对象属性的读取。
  • set():拦截对象属性的设置。
  • apply():拦截函数的调用、callapply操做。
  • has():即判断对象是否具备某个属性时,这个方法会生效,返回一个布尔值。它有两个参数:目标对象、需查询的属性名。
  • construct():用于拦截new命令。参数:target(目标对象)、args(构造函数的参数对象)、newTarget(建立实例对象时,new命令做用的构造函数)。
  • deleteProperty():拦截delete proxy[propKey]的操做,返回一个布尔值。
  • defineProperty():拦截object.defineProperty操做。
  • getOwnPropertyDescriptor():拦截object.getownPropertyDescriptor(),返回一个属性描述对象或者undefined
  • getPrototypeOf():用来拦截获取对象原型。能够拦截Object.prototype.__proto__Object.prototype.isPrototypeOf()Object.getPrototypeOf()Reflect.getPrototypeOf()instanceof
  • isExtensible():拦截Object.isExtensible操做,返回布尔值。
  • ownKeys():拦截对象自身属性的读取操做。可拦截Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.keys()for...in循环。
  • preventExtensions():拦截Object.preventExtensions(),返回一个布尔值。
  • setPrototypeOf():拦截Object.setPrototypeOf方法。
  • revocable():返回一个可取消的proxy实例。

Demo

<body>
    <input type="text" id="input">
    <p id="p"></p>
</body>
<script>
    const input = document.getElementById('input');
    const p = document.getElementById('p');
    const obj = {};
    
    const newObj = new Proxy(obj, {
      get: function(target, key, receiver) {
        console.log(`getting ${key}!`);
        return Reflect.get(target, key, receiver);
      },
      set: function(target, key, value, receiver) {
        console.log(target, key, value, receiver);
        if (key === 'text') {
          input.value = value;
          p.innerHTML = value;
        }
        return Reflect.set(target, key, value, receiver);
      },
    });
    
    input.addEventListener('keyup', function(e) {
      newObj.text = e.target.value;
    });
</script>
复制代码

设计模式-发布订阅模式

观察者模式与发布订阅模式容易混,这里顺带区别一下。

  • 观察者模式:一个对象(称为subject)维持一系列依赖于它的对象(称为observer),将有关状态的任何变动自动通知给它们(观察者)。
  • 发布订阅模式:基于一个主题/事件通道,但愿接收通知的对象(称为subscriber)经过自定义事件订阅主题,被激活事件的对象(称为publisher)经过发布主题事件的方式被通知。

差别:

  • Observer模式要求观察者必须订阅内容改变的事件,定义了一个一对多的依赖关系;
  • Publish/Subscribe模式使用了一个主题/事件通道,这个通道介于订阅着与发布者之间;
  • 观察者模式里面观察者「被迫」执行内容改变事件(subject内容事件);发布/订阅模式中,订阅着能够自定义事件处理程序;
  • 观察者模式两个对象之间有很强的依赖关系;发布/订阅模式两个对象之间的耦合度底。

Demo

// vm.$on
export function eventsMixin(Vue: Class<Component>) {
    const hookRE = /^hook:/
    //参数类型为字符串或者字符串组成的数组
    Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
        const vm: Component = this
        // 传入类型为数组
        if (Array.isArray(event)) {
            for (let i = 0, l = event.length; i < l; i++) {
                this.$on(event[i], fn)
                //递归并传入相应的回调
            }
        } else {
            (vm._events[event] || (vm._events[event] = [])).push(fn)
            // optimize hook:event cost by using a boolean flag marked at registration
            // instead of a hash lookup
            if (hookRE.test(event)) {
                vm._hasHookEvent = true
            }
        }
        return vm
    }

    // vm.$emit
    Vue.prototype.$emit = function (event: string): Component {
        const vm: Component = this
        if (process.env.NODE_ENV !== 'production') {
            const lowerCaseEvent = event.toLowerCase()
            if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
                tip(
                    `Event "${lowerCaseEvent}" is emitted in component ` +
                    `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
                    `Note that HTML attributes are case-insensitive and you cannot use ` +
                    `v-on to listen to camelCase events when using in-DOM templates. ` +
                    `You should probably use "${hyphenate(event)}" instead of "${event}".`
                )
            }
        }
        let cbs = vm._events[event]
        if (cbs) {
            cbs = cbs.length > 1 ? toArray(cbs) : cbs
            const args = toArray(arguments, 1)
            for (let i = 0, l = cbs.length; i < l; i++) {
                try {
                    cbs[i].apply(vm, args)// 执行以前传入的回调
                } catch (e) {
                    handleError(e, vm, `event handler for "${event}"`)
                }
            }
        }
        return vm
    }
}
复制代码

MVVM的流程分析

下面原生的MVVM小框架主要针对Compile(模板编译)、Observer(数据劫持)、Watcher(数据监听)和Dep(发布订阅)几个部分来实现。流程可参照下图:

mvvm.html页面,实例化一个VUE对象

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <input type="text" v-model="message.a">
        <ul>
            <li>{{message.a}}</li>
        </ul>
        {{name}}
    </div>
    <script src="mvvm.js"></script>
    <script src="compile.js"></script>
    <script src="observer.js"></script>
    <script src="watcher.js"></script>
    <script> let vm = new MVVM({ el:'#app', data: { message: { a: 'hello' }, name: 'haoxl' } }) </script>
</body>
</html>
复制代码

mvvm.js主要用来劫持数据,及将节点挂载到$el上,数据挂载到$data上。

class MVVM{
    constructor(options) {
        //将参数挂载到MVVM实例上
        this.$el = options.el;
        this.$data = options.data;
        //若是有要编译的模板就开始编译
        if(this.$el){
            //数据劫持-就是把对象的全部属性改为get和set方法
            new Observer(this.$data);
            //将this.$data上的数据代理到this上
            this.proxyData(this.$data);
            //用数据和元素进行编译
            new Compile(this.$el, this);
        }
    }
    proxyData(data){
        Object.keys(data).forEach(key =>{
            Object.defineProperty(this, key, {
                get(){
                    return data[key]
                },
                set(newValue){
                    data[key] = newValue
                }
            })
        })
    }
}
复制代码

observer.js利用Object.defineProerty来劫持数据,结合发布订阅模式来响应数据变化。

class Observer{
    constructor(data){
        this.observe(data);
    }
    observe(data){
        //将data数据原有属性改为set和get的形式,若是data不为对象,则直接返回
        if(!data || typeof data !== 'object'){
            return;
        }
        //要将数据一一劫持,先获取data中的key和value
        Object.keys(data).forEach(key => {
            //劫持
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]);//递归劫持,data中的对象
        });
    }
    defineReactive(obj, key, value) {
        let 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){
                    //若是是对象继续劫持
                    that.observe(newValue);
                    value = newValue;
                    dep.notify();//通知全部人数据更新了
                }
            }
        })
    }
}

//
class Dep{
    constructor(){
        //订阅的数组
        this.subs = []
    }
    //添加订阅
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        //调用watcher的更新方法
        this.subs.forEach(watcher => watcher.update());
    }
}
复制代码

watcher.js

//观察者的目的就是给须要变化的元素加一个观察者,当数据变化后执行对应的方法
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.b
            return prev[next];
        }, vm.$data)
    }
    get(){
        Dep.target = this;//将实例赋给target
        let value = this.getVal(this.vm, this.expr);
        Dep.target = null;//
        return value;//将旧值返回
    }
    // 对外暴露的方法
    update(){
        //值变化时将会触发update,获取新值,旧值已保存在value中
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value;
        if(newValue !== oldValue){
            this.cb(newValue);//调用watch的回调函数
        }
    }
}
复制代码

compile.js 利用DocumentFragment文档碎片建立DOM节点,而后利用正则解析{{}},将数据渲染到此区域。

class Compile{
    constructor(el, vm){
        //el为MVVM实例做用的根节点
        this.el = this.isElementNode(el) ? el:document.querySelector(el);
        this.vm = vm;
        //若是元素能取到才开始编译
        if(this.el) {
            //1.先把这些真实DOM移入到内存中fragment
            let fragment = this.node2fragment(this.el);
            //2.编译=>提取想要的元素节点 v-model或文本节点{{}}
            this.compile(fragment);
            //3.把编译好的fragment塞到页面中
            this.el.appendChild(fragment);
        }
    }
    /*辅助方法*/
    //判断是不是元素
    isElementNode(node){
        return node.nodeType === 1;
    }
    //是不是指令
    isDirective(name){
        return name.includes('v-');
    }
    /*核心方法*/
    //将el中的内容所有放到内存中
    node2fragment(el){
        //文档碎片-内存中的文档碎片
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;//内存中的节点
    }
    //编译元素
    compileElement(node){
        //获取节点全部属性
        let attrs = node.attributes;
        Array.from(attrs).forEach(attr => {
            //判断属性名是否是包含v-
            let attrName = attr.name;
            if(this.isDirective(attrName)){
                //取到对应的值放到节点中
                let expr = attr.value;
                //指令可能有多个,如v-model、v-text、v-html,因此要取相应的方法进行编译
                let [,type] = attrName.split('-');//解构赋值[v,model]
                CompileUtil[type](node, this.vm, expr)
            }
        })
    }
    compileText(node){
        //带{{}}
        let expr = node.textContent;
        let reg = /\{\{([^}]+)\}\}/g;
        if(reg.test(expr)){
            CompileUtil['text'](node, this.vm, expr);
        }
    }
    compile(fragment){
        //当前父节点节点的子节点,包含文本节点,类数组对象
        let childNodes = fragment.childNodes;
        // 转换成数组并循环判断每个节点的类型
        Array.from(childNodes).forEach(node => {
            if(this.isElementNode(node)) {//是元素节点
                //编译元素
                this.compileElement(node);
                //若是是元素节点,须要再递归
                this.compile(node)
            }else{//是文本节点
                //编译文本
                this.compileText(node);
            }
        })
    }
}

//编译方法,暂时只实现v-model及{{}}对应的方法
CompileUtil = {
    getVal(vm, expr){
        expr = expr.split('.');
        return expr.reduce((prev,next) => {//vm.$data.a.b
            return prev[next];
        }, vm.$data)
    },
    getTextVal(vm, expr){//获取编译后的文本内容
        return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            return this.getVal(vm, arguments[1])
        })
    },
    text(node, vm, expr){//文本处理
        let updateFn = this.updater['textUpdater'];
        //将{{message.a}}转为里面的值
        let value = this.getTextVal(vm, expr);
        //用正则匹配{{}},而后将其里面的值替换掉
        expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            //解析时遇到模板中须要替换为数据值的变量时,应添加一个观察者
            //当变量从新赋值时,调用更新值节点到Dom的方法
            //new(实例化)后将调用observe.js中get方法
            new Watcher(vm, arguments[1],(newValue)=>{
                //若是数据变化了文本节点须要从新获取依赖的属性更新文本中的内容
                updateFn && updateFn(node,this.getTextVal(vm, expr));
            })
        })
        //若是有文本处理方法,则执行
        updateFn && updateFn(node,value)
    },
    setVal(vm, expr, value){//[message,a]给文本赋值
        expr = expr.split('.');//将对象先拆开成数组
        //收敛
        return expr.reduce((prev, next, currentIndex) => {
            //若是到对象最后一项时则开始赋值,如message:{a:1}将拆开成message.a = 1
            if(currentIndex === expr.length-1){
                return prev[next] = value;
            }
            return prev[next]// TODO
        },vm.$data);
    },
    model(node, vm, expr){//输入框处理
        let updateFn = this.updater['modelUpdater'];
        //加一个监控,当数据发生变化,应该调用这个watch的callback
        new Watcher(vm, expr, (newValue)=>{
            //当值变化后会调用cb,将新值传递回来
            updateFn && updateFn(node,this.getVal(vm, expr))
        });
        //给输入添加input事件,输入值时将触发
        node.addEventListener('input', (e) => {
            let newValue = e.target.value;
            this.setVal(vm, expr, newValue);
        });
        //若是有文本处理方法,则执行
        updateFn && updateFn(node,this.getVal(vm, expr))
    },
    updater: {
        //更新文本
        textUpdater(node, value){
            node.textContent = value
        },
        //更新输入框的值
        modelUpdater(node, value){
            node.value = value;
        }
    }

}
复制代码

总结:首先Vue会使用documentfragment劫持根元素里包含的全部节点,这些节点不只包括标签元素,还包括文本,甚至换行的回车。 而后Vue会把data中全部的数据,用defindProperty()变成Vue的访问器属性,这样每次修改这些数据的时候,就会触发相应属性的get,set方法。 接下来编译处理劫持到的dom节点,遍历全部节点,根据nodeType来判断节点类型,根据节点自己的属性(是否有v-model等属性)或者文本节点的内容(是否符合{{文本插值}}的格式)来判断节点是否须要编译。对v-model,绑定事件当输入的时候,改变Vue中的数据。对文本节点,将他做为一个观察者watcher放入观察者列表,当Vue数据改变的时候,会有一个主题对象,对列表中的观察者们发布改变的消息,观察者们再更新本身,改变节点中的显示,从而达到双向绑定的目的。

相关文章
相关标签/搜索