深入剖析vue原理以及MVVM响应式原理的实现

孤人 提交于 2020-10-21 04:34:28

本文所有代码实现都收录于我的github,感兴趣的可以点击访问

一、什么是MVVM

大家都知道vue.js遵循的是mvvm的设计理念,下面简要说明什么是mvvm。
采用分而治之思想,把不同的代码放到不同的模块当中,然后通过特定的逻辑联系到一起。

  • 1、M:model、就是模型数据,普通的JS对象。
  • 2、V:view、就是Dom。
  • 3、VM:view-model、就是Vue,view和model不可以直接交互,需要通过VM联系到一起。
    M 到 V(数据驱动视图):Data Bindings:通过数据绑定联系到一起。
    V 到 M(视图影响数据):Dom Listeners:通过事件监听联系到一起。
    只要数据进行了改变,同时视图也会同时更新。


理解了基本思想之后,我们要做什么才能实现VM呢?

  • 1.首先,需要利用Object.defineProperty,将要观察的对象,转化成getter/setter,以便拦截对象赋值与取值操作,称之为Observer,也就是数据观察者;
  • 2.需要将DOM解析,提取其中的指令与占位符,并赋与不同的操作,称之为Compile,也就是指令解析器;
  • 3.需要将Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到对象数据变化时,接收通知,同时更新DOM,称之为Watcher,也就是订阅者,它是Observer和Compile之间通信的桥梁;
  • 4.最后,需要一个公共入口对象,接收配置,协调上述三者,称为vm,也就是Vue;

二、几种实现双向绑定的做法

目前几种主流的mvc(vm)框架都实现了单向数据绑定(例如react就是典型的数据单向绑定),简单的理解双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改model和 view。

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

  • 1 .发布者-订阅者模式(backbone.js)
  • 2.脏值检查(angular.js)
  • 3.数据劫持(vue.js)

一、发布者-订阅者模式

一般通过sub, pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set(‘property’, value)。
这种方式现在毕竟太low了,我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式。

二、脏值检查

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

  • 1.DOM事件,譬如用户输入文本,点击按钮等。( ng-click )
  • 2.XHR响应事件 ( $http )
  • 3.浏览器Location变更事件 ( $location )
  • 4.Timer事件( timeout ,timeout,interval )
  • 5.执行 digest() 或digest()或apply()

三、数据劫持

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

三、vue.js数据劫持实现

一、思路整理

已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的,无疑这个方法是本文中最重要、最基础的内容之一。
整理了一下,要实现mvvm的双向绑定,就必须要实现以下几点:

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

二、指令解析器Compile的实现

指令解析器的主要作用就是对指令进行解析。例如:v-text,v-html,v-on,v-bind等。解析指令之后,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示:在这里插入图片描述
在创建指令解析器之前,我们要提供入口类,也就是vm,用来接受配置,协调其它三者:

// 入口类
class Myvue{
    constructor(options){
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if(this.$el){
            // 2.实现指令的解析器
            new Compile(this.$el,this)
        }
    }
}

指令解析器的解析过程:

  • 1.首先对el属性挂在的元素进行编译,将模板中的指令(v-text等)或者插值表达式({{}})进行替换,但是频繁的编译和替换会导致页面的回流和重绘,会影响页面的性能,所以我们要利用文档碎片对象,会减少页面的回流和重绘。文档碎片的作用:将替换之后的内容放到缓存中,需要使用时会进行获取。
  • 2.将文档碎片对象作为模板进行编译。
  • 3.将文档碎片追加到根元素中。
    指定解析器的部分代码如下:
// 指令解析器
class Compile{
    constructor(el,vm){
        // 当前传入的el是一个元素节点则赋值给当前类的el,否则自行获取元素节点
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        /* 需要对根节点下的每一个节点进行编译,然后将页面中的数据(例如{{person.name}})进行替换
                频繁的编译和替换会导致页面的回流和重绘,会影响页面的性能
                文档碎片的作用:将替换之后的内容放到缓存中,需要使用时会进行获取
        */
        // 1.获取文档碎片对象,会减少页面的回流和重绘
        const fragment = this.node2Fragment(this.el);

        // 2.将文档碎片对象作为模板进行编译
        this.compile(fragment);

        // 3.将文档碎片追加到根元素中
        this.el.appendChild(fragment)
        
    }
    // 创建文档碎片对象
    node2Fragment(el){
        // 创建文档碎片对象
        const f = document.createDocumentFragment();
        let firstChild;
        // 遍历传入的DOM节点
        while(firstChild = el.firstChild){
            // 追加文档碎片
            f.appendChild(firstChild);
        }
        return f;
    }
    // 编译模板:获取到的文档碎片内容
    /** 内容如下:
     * <h2>{{person.name}}--{{person.age}}</h2>
        <h3>{{person.fav}}</h3>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
        <h3>{{msg}}</h3>
        <div v-text="msg"></div>
        <div v-html="msg"></div>
        <input type="text" v-model="msg">
     */
    compile(fragment){
        // 1.获取子节点
        const childNodes = fragment.childNodes;
        [...childNodes].forEach(child => {
            // 元素节点
            if(this.isElementNode(child)){
                // 编译元素节点
                this.compileElement(child)
            // 文本节点
            }else{
                // 编译文本节点 主要处理 {{}} 形式的表达式
                this.compileText(child)
            }
            // 递归遍历
            if(child.childNodes && child.childNodes.length){
                this.compile(child)
            }
        })
    }
 }

在对文档碎片对象进行递归遍历时,我们从文档碎片对象中获取到的节点可能是元素节点,也可能是文本节点。对于元素节点和文本节点的编译过程是不一样的,所以我们在compile函数中进行了区分,对元素节点和文本节点的编译过程代码如下:

// 编译元素节点
compileElement(node){
    // console.log(node);//<div v-text='msg'></div>
    // 获取属性节点
    const attributes = node.attributes;
    // console.log(attributes);//{0: v-on:click, v-on:click: v-on:click, length: 1}
    [...attributes].forEach(attr => {
        console.log(attr.name,'name') // v-text  v-on
        console.log(attr.value,'value') // msg  handleClick
        const {name,value} = attr;
        // 如果当前属性名是否是自定义指令 name的值可能是:v-text v-html v-model v-on:click
        if(this.isDirective(name)){
            const [,dirctive] = name.split('-'); // dirctive的值可能是: text html model on:click bind
            const [dirName,eventName] = dirctive.split(':'); // dirName的值可能是:text html model on bind
            // 根据dirName调用compileUtil对象中的对应方法,用来更新数据,体现了数据驱动视图。
            compileUtil[dirName](node,value,this.vm,eventName);

            // 删除有指令的标签上的属性 将v-text等从标签中去除
            node.removeAttribute('v-' + dirctive);
        }else if(this.isEventName(name)){ // 处理以@开头的事件绑定
            let [,eventName] = name.split('@');
            compileUtil['on'](node,value,this.vm,eventName);
        }else if(this.isAttrName(name)){ // 处理以:开头的属性绑定
            let [,attrName] = name.split(':');
            compileUtil['bind'](node,value,this.vm,attrName);
        }
    });
}
// 编译文本节点:主要处理 {{}} 形式的表达式 ,{{}} 实现原理和v-text一样,都是用的node.textContent
compileText(node){
    // 取出节点中的文本内容,包括换行、空格等
    const content = node.textContent;
    // 正则匹配出含{{}}的内容
    if(/\{\{(.+?)\}\}/.test(content)){
        // 调用
        compileUtil['text'](node,content,this.vm);
    }
}
// 判断当前属性名是否是自定义指令
isDirective(attrName){
    return attrName.startsWith('v-');
}
// 判断是否是一个事件名称
isEventName(attrName){
    return attrName.startsWith('@');
}
// 判断是否是一个事件名称
isAttrName(attrName){
    return attrName.startsWith(':');
}
// 判断当前传入的是否是元素节点
isElementNode(node){
    // DOM对象的nodeType属性
    // 元素节点的nodeType为1
    // 属性节点的nodeType为2
    // 文本节点的nodeType为3,文本节点包含文字、空格、换行等。
    return node.nodeType === 1;
}

大家可以看到在元素节点和文本节点的编译过程中使用到了compileUtil编译工具类,它是整个替换过程中的主要执行类,根据接收到不同的指令,执行对应的替换逻辑:

// 编译工具类
const compileUtil = {
    /**
     * 处理 v-text 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式 msg vue.js中MVVM的实现原理
     * @param {*} vm 当前vm实例
     */
    text(node,expr,vm){
        let value;
        // 当前传入的表达式expr可能是$data中的一个属性,也可能是$data中对象.属性的形式,也可能是{{}}插值表达式
        // 如果当前传入的是插值表达式
        if(expr.indexOf('{{') !== -1){
            value = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{
                // console.log(args);//["{{msg}}", "msg", 0, "{{msg}}"]
                return this.getVal(args[1],vm)
            });
        }else{
            value = this.getVal(expr,vm);
        }
        this.updater.textUpdater(node,value);
    },
    /**
     * 处理 v-html 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式
     * @param {*} vm 当前vm实例
     */
    html(node,expr,vm){
        const value = this.getVal(expr,vm);
        this.updater.htmlUpdater(node,value);
    },
    /**
     * 处理 v-model 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式
     * @param {*} vm 当前vm实例
     */
    model(node,expr,vm){
        const value = this.getVal(expr,vm);
        this.updater.modelUpdater(node,value);
    },
    /**
     * 处理 v-on 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式
     * @param {*} vm 当前vm实例
     * @param {*} eventName 当前事件名称
     */
    on(node,expr,vm,eventName){
        // 取出methods中的函数
        let fn = vm.$options.methods && vm.$options.methods[expr];
        // 调用函数时,同时改变this指向当前的vm实例
        node.addEventListener(eventName,fn.bind(vm),false);
    },
    /**
     * 处理 v-bind 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式
     * @param {*} vm 当前vm实例
     * @param {*} eventName 当前事件名称
     */
    bind(node,expr,vm,attrName){
        console.log(node,expr,attrName)
        const value = this.getVal(expr,vm);
        this.updater.bindUpdater(node,value,attrName);
    },
    // 更新的函数
    updater:{
        textUpdater(node,value){
            node.textContent = value;
        },
        htmlUpdater(node,value){
            node.innerHTML = value;
        },
        modelUpdater(node,value){
            node.value = value;
        },
        bindUpdater(node,value,attrName){
            node.setAttribute(attrName,value)
        },
    },
    // 根据表达式expr从data中获取值
    getVal(expr,vm){
        // 当前传入的表达式expr可能是$data中的一个属性,也可能是$data中对象.属性的形式
        // 可以获取data中属性值,也可以获取data中对象的属性值
        return expr.split('.').reduce((data,currentVal)=>{
            return data[currentVal];
        },vm.$data);
    },
}

小结:

  • 1.v-text:使用的是node.nodeContent进行实现
  • 2.v-html:使用的是node.innerHTML进行实现
  • 3.v-model:用于实现表单对象的数据绑定,node.value
  • 4.v-on/@:node.addEventListener();在addEventListener对this指向进行了改变,指向当前的vm实例,所以我们在使用vue时,this一直指向的是当前的vue实例。
  • 5.v-bind(或者:):node.setAttribute()

二、实现数据监听器Observer

在前面已经说到,vue的双向绑定原理是通过数据劫持实现的,数据劫持的底层又是基于Object.defineProperty中的getter和setter进行实现的。

  • 1.在getter中我们要做的主要操作就是使用数据模型中的数据进行视图的初始化,同时向订阅器Dep中添加对应属性的订阅者,用于收集属性的依赖,实现一个属性对应一个订阅者。
  • 2.在setter中我们要做的主要操作:数据劫持是需要对每一个属性值进行劫持的,当我们获取到新值的时候,初始状态是不会对该值进行劫持的,所以要对新值做劫持操作。然后对传入的新值进行赋值。在赋值之后,我们的模型中的数据已经进行了更新,那么我们要通知订阅器Dep去通知变化,进行视图的更新,从而达到数据驱动视图的目的。
  • 3.流程图
    在这里插入图片描述
  • 4.数据监听器Observer以及订阅器Dep的代码实现
// 订阅器/依赖收集器
class Dep{
    constructor(){
        // 定义依赖容器
        this.subs = [];
    }
    // 收集订阅者
    addSub(watcher){
        this.subs.push(watcher);
    }
    // 通知订阅者去更新视图
    notify(){
        // 遍历容器,找到对应订阅者,调用更新方法去更新视图
        this.subs.forEach(watcher => watcher.update())  
    }
}
// 数据观察者类,使用Object.defineProperty实现数据劫持
class Observer{
    constructor(data){
        this.observe(data)
    }
    // 劫持函数
    observe(data){
        // 此处仅对对象做数据观测
        if(data && typeof data === 'object'){
            // 遍历获取到到所有的key
            Object.keys(data).forEach(key => {
                // 使用监听函数进行数据监听
                this.defineReactive(data,key,data[key])
            })
        }
    }
    // 监听函数
    defineReactive(obj,key,value){
        // 传入的value是对象的一个属性值,属性值也可能是一个对象,所以需要递归遍历
        this.observe(value);
        // 创建依赖收集器
        const dep = new Dep();
        // 劫持所有的属性
        Object.defineProperty(obj,key,{
            // 是否可枚举
            enumerable:true,
            // 是否可更改
            configurable:false,
            // 获取数据进行初始化
            get(){
                // 订阅数据变化时,向订阅器Dep中添加订阅者,用于收集属性的依赖,实现一个属性对应一个订阅者
                Dep.target && dep.addSub(Dep.target)
                return value;
            },
            // 使用箭头函数是为了将函数内部的this指向外部的Observer类
            set:(newVal) => {
                // 获取到新的值时,初始状态是不会对该值进行劫持的,所以要对新值做劫持操作
                this.observe(newVal)
                // 当前传入的新值不等于旧值
                if(newVal !== value){
                    value = newVal;
                }
                // 更新数据之后,告诉Dep去通知变化
                dep.notify();
            }
        })
    }
}

三、实现数据订阅者Watcher

  • 1.为每一个属性添加订阅者:需要注意的是,每一个模型数据中的每一个属性都要对应一个订阅者进行观测。所以属性与订阅者是一一对应的关系。既然是一一对应的关系,那么我们在进行初始化视图的时候就应该进行订阅者的添加。初始化视图操作是在Compile指令解析器种进行的,所以修改指令解析器中的代码如下(此处仅对v-text和v-html进行演示,完整代码请看开头提到的github):
/**
 * 处理 v-text 指令
 * @param {*} node 当前元素节点
 * @param {*} expr 表达式 msg vue.js中MVVM的实现原理
 * @param {*} vm 当前vm实例
 */
text(node,expr,vm){
    let value;
    // 当前传入的表达式expr可能是$data中的一个属性,也可能是$data中对象.属性的形式,也可能是{{}}插值表达式
    // 如果当前传入的是插值表达式
    if(expr.indexOf('{{') !== -1){
        value = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{
            // console.log(args);//["{{msg}}", "msg", 0, "{{msg}}"]
            // 添加订阅者,对数据进行监听,数据如果发生了变化,调用updater进行更新视图
            new Watcher(vm,args[1],() => {
                // 获取到新值之后,更新视图
                this.updater.textUpdater(node,this.getContentVal(expr,vm));
            })
            return this.getVal(args[1],vm)
        })
    }else{
        value = this.getVal(expr,vm);
    }
    // 初始化视图
    this.updater.textUpdater(node,value);
},
/**
 * 处理 v-html 指令
 * @param {*} node 当前元素节点
 * @param {*} expr 表达式
 * @param {*} vm 当前vm实例
 */
html(node,expr,vm){
    const value = this.getVal(expr,vm);
    // 添加订阅者,对数据进行监听,数据如果发生了变化,调用updater进行更新视图
    new Watcher(vm,expr,(newVal) => {
        // 获取到新值之后,更新视图
        this.updater.htmlUpdater(node,newVal);
    })
    // 初始化视图
    this.updater.htmlUpdater(node,value);
},
  • 2.订阅者的定义:在初始化视图时添加了订阅者之后,那么订阅者拿到新值之后就可以利用新值进行视图的更新,订阅者代码实现:
// 订阅者
class Watcher{
    /**
     * 
     * @param {*} vm 当前vm对象
     * @param {*} expr 取值表达式
     * @param {*} cb 回调函数,用于更新视图
     */
    constructor(vm,expr,cb){
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        // 获取旧值,并保存
        this.oldVal = this.getOldVal();
    }
    // 更新视图的函数
    update(){
        // 获取新值
       const newVal =  compileUtil.getVal(this.expr,this.vm);
        // 如果新值不等于旧值,利用回调将新值返回
        if(newVal !== this.oldVal){
            this.cb(newVal);
        }
    }
    // 获取旧值
    getOldVal(){
        // 将当前的订阅者挂载到订阅器中
        Dep.target = this;
       const oldVal =  compileUtil.getVal(this.expr,this.vm);
        // 销毁当前订阅者
        Dep.target = null;   
        return oldVal;
    }
}
  • 3.订阅者更新视图:在订阅者的代码中可以看到,在构造函数时需要传入回调,数据更新之后,订阅者获取到新值,利用传入的回调将新值进行返回,在订阅者的外部进行视图的更新操作。也就是在指令解析器中添加订阅者时,传入回调,获取新值,进行视图的更新。
  • 4.思路整理:没错,这条线的实现就是这么简单
    在这里插入图片描述

四、双向数据绑定

双向数据绑定是针对于表单控件的,给元素节点绑定类似于input、change事件,通过事件对象来获取新值,获取到新值之后来改变模型数据data中的数据。由于是给表单对象绑定对应的事件,所以我们在处理v-model指令时进行事件绑定,修改compileUtil类的model函数如下:

/**
     * 处理 v-model 指令
     * @param {*} node 当前元素节点
     * @param {*} expr 表达式
     * @param {*} vm 当前vm实例
     */
    model(node,expr,vm){
        const value = this.getVal(expr,vm);
        // 添加订阅者,对数据进行监听,数据如果发生了变化,调用updater进行更新视图,数据驱动视图
        new Watcher(vm,expr,(newVal) => {
            // 获取到新值之后,更新视图
            this.updater.modelUpdater(node,newVal);
        })
        // 视图影响数据
        node.addEventListener('input',(e) => {
            // 拿到输入框的新值之后,去影响视图
            this.setVal(expr,vm,e.target.value)
        })
        // 初始化视图
        this.updater.modelUpdater(node,value);
    },
    /**
     * 获取到新值之后,将新值保存到vm的模型数据中
     * @param {*} expr 取值表达式
     * @param {*} vm 当前vm实例
     * @param {*} inputNewVal 通过input事件获取到的新的值
     */
    setVal(expr,vm,inputNewVal){
        return expr.split('.').reduce((data,currentVal)=>{
            // 旧值替换成新值
            data[currentVal] = inputNewVal;
        },vm.$data);
    },

五、实现Proxy代理

大家在使用vue.js的时候,对数据进行操作时,都是以this.person.name = '张三’这样的形式进行操作的,而不是this.$data.person.name = '张三’的形式。想要使用我们常用的方式进行数据的操作,就要实现Proxy代理,使this代理成this.$data。修改入口类的代码如下:

// 入口类
class Myvue{
    constructor(options){
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if(this.$el){
            // 1.实现数据观察者
            new Observer(this.$data)
            // 2.实现指令的解析器
            new Compile(this.$el,this)
            // 使用this代理this.$data
            this.proxyData(this.$data)
        }
    }
    // 使用this代理this.$data
    proxyData(data){
        for(const key in data){
            Object.defineProperty(this,key,{
                get(){
                    return data[key];
                },
                set(newVal){
                    data[key] = newVal;
                }
            })
        }
    }
}

这里主要还是利用了Object.defineProperty()这个方法来劫持了vm实例对象的属性的读写权,使读写vm实例的属性转成读写了vm.$data的属性值。

四、总结

一、效果

到此我们已经实现了vue中响应式,先看看其中一部分的效果吧:
在这里插入图片描述

二、总结

在这里重新对 Watcher, Observer , Dep 的关系进行一下梳理,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据驱动视图;视图影响数据的双向绑定效果。
至此文章结束,文中肯定有不足的地方,欢迎大家指正,有兴趣欢迎一起探讨和改进,感谢阅读。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!