阅读 3233

vue双向数据绑定原理

网上关于VUE双向数据绑定的文章多如牛毛,此文章仅用作自己总结。

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

DocumentFragment

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

Demo

<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方法,它接收三个参数:属性所在的对象、属性的名字、一个描述符对象。

数据属性:

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

  • 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缺陷:

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

proxy

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

  • 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数据改变的时候,会有一个主题对象,对列表中的观察者们发布改变的消息,观察者们再更新自己,改变节点中的显示,从而达到双向绑定的目的。

关注下面的标签,发现更多相似文章
评论