前端技术提升面试真题
About MVVM

MVVM(Model-View-ViewModel)是基于MVC和MVP的体系结构模式,它目的在于更清楚地将用户界面(UI)的开发应用程序中业务逻辑和行为的开发区分开来。所以,MVVM模式的许多实现都使用声明性 数据绑定来允许从其他层分离视图上的工作。

直白一点就是,这个模式让Model,View,ViewModel不纠缠在一起,他们分工明确,这是一个很佛系的模式,而且还能保证项目在架构层面,稳定,干净。

这有助于在同一代码库中几乎同时进行UI和开发工作。 UI开发人员在其文档标记(HTML)中将ViewModel绑定到其中,Model和ViewModel由开发人员在应用程序的逻辑中进行维护。

为什么要使用MVVM?

1.团队层面:统一思维方式和实现方式

2.架构层面:稳定,模块之间耦合度降低

(强调的是UI中不包含业务逻辑的代码,即View和ViewModel解耦)

3.代码层:可读,可测,可替换

 

下面分别理解Model,View,ViewModel,这时候就需要一张直观的关系图

图片描述

Markdown

Model

1.现实世界中对事物的抽象结果,就是建模。

2.我们可以把Model称为数据层,因为它仅仅关注数据本身,不关心任何行为

As with other members of the MV* family, the Model in MVVM represents domain-specific data or information that our application will be working with. A typical example of domain-specific data might be a user account (e.g name, avatar, e-mail) or a music track (e.g title, year, album).

View

1.用户操作界面

2.当ViewModel对Model进行更新的时候,会通过数据绑定更新到View

MVVM’s active View contains the data-bindings, events and behaviours which require an understanding of the Model and ViewModel. The View is responsible for handling events to the ViewModel. It’s important to remember the View isn’t responsible here for handling state - it keeps this in sync with the ViewModel.

ViewModel

1.业务逻辑层,View需要什么数据,ViewModel要提供这个数据;View有某些操作,ViewModel就要响应这些操作,所以可以说它是Model for View.

2.MVVM模式的重点就在View和ViewModel的交互,View和ViewModel有两种交互方式

如上图:

双向传递数据--数据属性和data binding,

单向传递操作--命令属性。

3.由于ViewModel中的双向数据绑定,当Model发生变化,ViewModel就会自动更新;ViewModel变化,Model也会更新

The ViewModel can be considered a specialized Controller that acts as a data converter. It changes Model information into View information, passing commands from the View to the Model.The ViewModel sits behind our UI layer. It exposes data needed by a View (from a Model) and can be viewed as the source our Views go to for both data and actions.

Summary

MVVM模式简化了界面与业务的依赖,解决了数据频繁更新。MVVM 在使用当中,利用双向绑定技术,使得 Model 变化时,ViewModel 会自动更新,而 ViewModel 变化时,View 也会自动变化。

这是他的优点,但是也是引发问题的根源。

MVVM 的作者 John Gossman 对 MVVM 的批评主要有两点:

第一点:数据绑定使得 Bug 很难被调试。你看到界面异常了,有可能是你 View 的代码有 Bug,也可能是 Model 的代码有问题。数据绑定使得一个位置的 Bug 被快速传递到别的位置,要定位原始出问题的地方就变得不那么容易了。
第二点:对于过大的项目,数据绑定需要花费更多的内存。

Achieving MVVM

不同的MVVM框架中,实现双向数据绑定的技术有所不同。

目前一些主流的前端框架实现数据绑定的方式:

  • 数据劫持 (Vue)
  • 脏值检查 (Angular,React)

这里只介绍Vue

Vue采用数据劫持&发布-订阅模式的方式,通过ES5提供的 Object.defineProperty() 方法来劫持(监控)各属性的 getter 、setter ,并在数据(对象)发生变动时通知订阅者,触发相应的监听回调。并且,由于是在不同的数据上触发同步,可以精确的将变更发送给绑定的视图,而不是对所有的数据都执行一次检测。要实现Vue中的双向数据绑定,大致可以划分三个模块:Compile、Observer、Watcher

1.模板的编译Compile

这个 {{ message}} 是模板,它要先取到数据data.message,然后要编译成我们想要的数据格式。

<div id="app">
    <input type="text" v-model="message">
    {{ message}}
</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script>
    let vm = new Vue({
        el:'#app',
        data:{
            message:'Hello I am Yimi'
        }
    });
</script>

2.数据劫持Observer

在Vue中主要通过ES5提供的 Object.defineProperty( ) 方法来劫持(监控)各属性变化,即给所有的对象上的某一些数据都加上get、set方法。

3.观察者Watcher

数据变化了,就要告诉视图重新编译模板,那么编译模板和数据劫持之间就需要一个关联,那就是Watcher。而Compile和Observer的具体通信靠的就是订阅者Dep,后文代码实现的时候再仔细讨论Dep。

 

我们可以通过Vue来实现双向数据绑定,但是现在不想通过Vue,而想自己来实现这个MVVM,首先简单画了一下实现MVVM框架的需要的模块和流程:

总结一下:编译的时候只需要把带“v-”前缀的指令的元素和“{{ }}”里面的文本调出来。

接下来根据上面这张图分步实现Vue的MVVM模式。

这里只实现文本节点和v-model属性节点的MVVM模式

DIY VUE--MVVM

##入口文件 index_mvvm.html

<body>
<div id="app">
    <input type="text" v-model="message">
    {{ message}}
    <ul><li></li></ul>
    <input type="text" v-model="info.a">
    <div>{{ info.a }}</div>
</div>
<script src="observer.js"></script>
<script src="compile.js"></script>
<script src="MVVM.js"></script>
<script>
    let vm = new MVVM({
        el:'#app',
        data:{
            message:'Hello I am Yimi',
            info:{
                a:'How are you'
            }
        }
    });
</script>
</body>

1.总的思路:先有3个主要的js文件,分别写模板编译Compile( )和数据劫持Observer( ),最后靠MVVM( )来整合的。这样数据比较清晰,而且可以把复用的方法全部抽离出来,方便代码维护,否则全部写在一起就会很混乱。

##MVVM.js

class MVVM {
    constructor(options) {
        this.$el = options.el;
        this.$data = options.data;

        //是否存在要编译的模板,存在则开始编译
        if (this.$el) {
            new Observer(this.$data);
            new Compile(this.$el, this);
        }
    }
}

2.有了元素后就可以开始编译了,

先拆分这个流程:

2.1)首先遍历节点,看是否存在“v-”或者“{{ }}”,所以就要频繁地操作DOM,考虑一个问题,这样性能很消耗;有个好方法,就是先把这些需要操作的真实DOM移入到内存中(文档碎片 fragement),此时移除的DOM不在页面上了,操作完后再把编译好的DOM放回页面,操作内存是不会导致页面的重绘重渲染的,肯定比直接在页面上操作快。

##compile.js

class Compile{
    constructor(el,vm){
        this.el = this.isElementNode(el)?el:document.querySelector(el);
        this.vm = vm;
        if(this.el){
            //如果这个元素能获取到 我们才开始编译
            //1.先把这些真实的DOM移入到内存中 fragement
            let fragment = this.nodeToFragment(this.el);
            //2.编译 => 提取想要的元素节点 v-model 和文本节点 {{}}
            this.compile(fragment);
            //3.把编译好的fragement再返回页面去
            this.el.appendChild(fragment);
        }
    }
……
}

 

/**
     * 将节点el里的全部内容放到内存里面
     */
    nodeToFragment(el) {
        let fragment = document.createDocumentFragment();
        let firstChild;
        while (firstChild = el.firstChild){
            fragment.appendChild(firstChild);
        }
        //内存中的节点
        return fragment;
    }

没有把编译好的文本碎片放回到页面前,页面是为空的,可以和入口文件对比一下,节点app里面的内容变为空了

2.2)节点添加到文档碎片后,就可以使用compile( )编译模板了, 在这里面需要递归所有节点,使用isElementNode( )判断是 元素类型/文本类型 的节点,分别使用不同的编译方法

isElementNode(node){
        return node.nodeType === 1;
    }

 

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);
            }
        });
    }

2.2.1)对于元素节点,只取属性中有'v-'的元素节点,用ES6中字符串的include( )来判断

    /**
     * 判断属性名字是不是包含'v-'
     * @param name
     * @returns {*|void}
     */
    isDirective(name){
        return name.include('v-');
    }

    /**
     * 编译带'v-'属性的元素节点,DOM元素不能用正则判断
     * @param node
     */
    compileElement(node){
        let attrs = node.attributes;
        Array.from(attrs).forEach(attr => {
            let attrName = attr.name;
            if (this.isDirective(attrName)){
                let expr = attr.value;
                let type = attrName.slice(2);
                //编译工具方法,后面详解
                CompileUtil[type](node,this.vm,expr);
            }
        });
    }

2.2.2)对于文本节点,可能是“{{a}} {{b}} {{c}}”或者 "{{abc}}"这样的形式,所以要把它当作一个整体来编译。

compileText(node){
        let text = node.textContent;
        let reg = /\{\{([^}]+)\}\}/g;
        if (reg.test(text)){
            //node this.vm.$data text
            //编译工具方法,后面详解
            CompileUtil['text'](node,this.vm,text);
        }
    }

2.2.3)最后,为了代码解耦和方法复用,我们通过一个工具方法对象CompileUtil来编译模板,以后如果还有新的指令需要编译的,就不需要修改上面的代码了,只需在对象CompileUtil添加方法就行了。

在对象CompileUtil中我们要把模板替换成它在实例上对应的值,看一下前面的入口文件,如message -> 'Hello I am Yimi' , info.a -> 'How are you' ;

有一个问题:vm.$data["message"] 是ok的,可以拿到'Hello I am Yimi',但是 info.a不能直接vm.$data[" info.a"] 这样写,因为实例vm.$data 中没有 "info.a"这个属性,必须先把字符串转换成数组,再把他们连接起来。

所以在对象CompileUtil中要添加一个方法实现 'info.a' => [info,a] vm.$data.info.a

/**
     * 获取实例上对应的数据,返回 vm.$data.xxx.yyy
     * @param vm
     * @param expr
     * @returns {T}
     */
    getVal(vm,expr){
        expr = expr.split('.');
        return expr.reduce((pre,next)=>{
            return pre[next];
        },vm.$data)
    },

还需要一个公共逻辑对象,为节点更新数据

/*公共逻辑的复用*/
    updater:{
        textUpdater(node,value){
            node.textContent = value;
        },
        modelUpdater(node,value){
            node.value = value;
        }
    }

元素节点编译:对于元素节点,可以直接使用getValue( )来取得对应的值

/**
     * 带v-model属性的元素节点编译
     * @param node
     * @param vm
     * @param expr
     */
model(node,vm,expr){
        let updateFn = this.updater['modelUpdater'];
        //这个方法存在再去调用
        updateFn && updateFn(node,this.getVal(vm,expr));
    },

文本节点编译:对于文本节点,我们传进去的是 {{xxx}},不能直接使用,要把xxx匹配出来才能取值

/**
     * 文本节点编译
     * @param node
     * @param vm
     * @param text
     */
text(node,vm,text){
        let updateFn = this.updater['textUpdater'];
        let value = text.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{
            //拿到第一个分组,并且要取得没有空格的字符串,否则会报错
            return this.getVal(vm,arguments[1].trim());
        });
        console.log(value);
        //这个方法存在再去调用
        updateFn && updateFn(node,value);
    },

2.2.4)到这里Compile就写完了,接下来我们再看一下图,然后开始写数据劫持Observer。

3.在编译之前,要做数据劫持,由于是对数据进行劫持,所以创建实例的时候只传入数据。

new Observer(this.$data);

##observer.js

class Observer {
    constructor(data){
        this.observe(data);
    }
……
}

3.1)接下来,要观察数据,然后考虑一个问题,看前面的入口文件index_mvvm.html ,

数据对象的属性有可能也是一个对象,比如info,所以我们要做深度数据劫持(递归)

/**
     * 将所有data数据改成set和get的形式
     * @param data
     */
    observe(data){
        //数据不存在或者数据不是对象
        if (!data || typeof data !== 'object'){
            return;
        }
        //将数据一一劫持 先获取到data的key和value
        Object.keys(data).forEach(key => {
            //劫持,若data[key]是个对象,则时需要递归劫持
            //响应式 为属性添加get set 在下文定义
            this.defineReactive(data,key,data[key]);
            this.observe(data[key]);
        });
    }

3.2)通过Object.defineProperty( ) , 把data对象的所有属性,改成访问器类型属性,添加get和set方法;

比如现在想取data对象的message值,而message被下面的方法定义了,他就会走get( )方法

那么,新对象会劫持吗?和上面一样,新的数据对象的属性有可能也是一个对象,所以我们也要做深度数据劫持(递归)

/**
     * 定义响应式,在赋新值的时候加点中间过程
     * @param obj 数据对象
     * @param key 数据对象属性
     * @param value 属性值
     */
    defineReactive(obj,key,value){
        let that = this;
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            /*取值时调用的方法*/
            get(){
                return value;
            },
            /*给data属性中设置值时,更改获取的属性的值*/
            set(newValue){
                if(newValue !== value){
                    //这里的this不是实例
                    //如果是新值是对象则继续劫持
                    that.observe(newValue);
                    value = newValue;
                }
            }
        });
    }

3.3)现在值改了,但是模板没有重新编译,我们希望的是,数据变了,会让模板重新编译,所以我们这里需要一个观察者Watcher,使得数据和模板之间有关联

4.Watcher观察者的目的:

在于给需要变化的那个元素增加一个观察者,当数据变化后执行对应的方法,用新值和旧值进行对比,如果发生变化,就调用更新方法

所以实例化Watcher的时候,传入3个值vm, expr, cb( )

##watcher.js

class Watcher{
    constructor(vm,expr,cb){
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        //先获取老的值
        this.value = this.get();
    }
……
}

4.1)先获取旧值

get(){
        let value = this.getVal(this.vm,this.expr);
        return value;
    }
//这个方法和compile.js里的一样
 getVal(vm,expr){
        expr = expr.split('.');
        return expr.reduce((pre,next)=>{
            return pre[next];
        },vm.$data);
    }

4.2)然后什么时候更新新值?
对外暴露的更新方法update( ),拿旧值和新值作比较,如果不一样就执行cb( )传入新值

update(){
        let newValue = this.getVal(this.vm,this.expr);
        let oldValue = this.value;
        if (newValue !== oldValue){
            this.cb(newValue);
        }
    }

4.3)在哪里调用watcher?

模板编译的时候,还记得compile.js里面的CompileUtil.text( )和Compile.model( )吗?

它们分别是文本节点编译和带v-model属性的元素节点编译

文本节点编译添加Watcher,传入新值编译:

 text(node,vm,text){
        let updateFn = this.updater['textUpdater'];
        let value = this.getTextVal(vm,text);

        //为每一个文本添加观察者,{{a}},{{b}}
        text.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{
            new Watcher(vm,arguments[1].trim(),(newValue) => {
                updateFn && updateFn(node,this.getTextVal(vm,newValue));
            });
        });

        //这个方法存在再去调用
        updateFn && updateFn(node,value);
    },

元素节点编译:这里应该加一个监控,数据变化了,就调用watcher的回调函数cb(),将新的值传递过来,强调一下,他默认不会调用cb( ),要调用Watcher.update()时,才会调用.

model(node,vm,expr){
        let updateFn = this.updater['modelUpdater'];
        //编译传入的新值,不会主动编译,直到调用Watcher.update(),才会调用cb()
        new Watcher(vm,expr,(newValue)=>{
            updateFn && updateFn(node,this.getVal(vm,expr));
        });

        //这个方法存在再去调用
        updateFn && updateFn(node,this.getVal(vm,expr));
    },

4.4)那什么时候调用update( )?

这里涉及到了一个新内容--发布订阅Dep

5.发布订阅者Dep

5.1)两个功能:

a.用数组存放watcher

b.通知全体watcher添加成功,调用watcher.update( )

class Dep{
    constructor(){
        //订阅的数组
        this.subs = [];

    }
    /**
     * 添加订阅
     * @param watcher
     */
    addSub(watcher){
        this.subs.push(watcher);
    }
    /**
     * 通知全体完成添加订阅,循环每一个watcher,调用watcher的update(),文本节点和表单全部重新赋值
     */
    notify(){
        this.subs.forEach(watcher => watcher.update());
    }
}

5.2)在哪里调用Dep?

a.创建Watcher实例,它就要去获取旧值this.get( ),在哪里获取呢?

##watcer.js

let value = this.getVal(this.vm,this.expr); 

b.去实例vm上获取expr这个值,在哪里获取expr?

在属性的get( )方法获取,所以在这里将在compile.js里面实例化的Watcher赋给Dep.target

##watcher.js

get(){
        Dep.target = this;//只要一创建Watcher实例,就把实例赋给Dep.target
        let value = this.getVal(this.vm,this.expr);//这里一取属性就会调用属性的get()方法,在observer.js
        //更新完后后,要取消掉
        Dep.target = null;
        return value;
    }

c.然后它要去取属性数据了,就会去调用observer.js中定义的该属性的get( ),这时候就在定义响应式defineReactive( )这里实例化Dep,重点是在调用get( )时将Watcher实例加入到Dep的数组中

##observer.js

defineReactive(obj,key,value){
        let that = this;
        let dep = new Dep();//每个变化的数据都会对应一个数组,这个数组存放所有更新的操作
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            /*取值时调用的方法*/
            get(){
                //Dep.target是Watcher实例,实例化Watcher后,才有Dep.target,只有Dep.target存在才执行这条语句
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            /*给data属性中设置值时,更改获取的属性的值*/
            set(newValue){
                if(newValue !== value){
                    //这里的this不是实例
                    //如果是对象继续劫持
                    // that.observe(newValue);
                    value = newValue;
                    dep.notify();//通知全体,数据更新了
                }
            }
        });
为什么要在get( )中添加这条语句 Dep.target && dep.addSub(Dep.target);?

第一次从vm中取属性值调用get( )时,Watcher没有实例化, 所以Dep.target不存在

##compile.js

text(node,vm,text){
……
   let value = this.getTextVal(vm,text);
……
}

第二次调用get( )时,Watcher才实例化,Dep.target存在 ,将Watcher实例加入到订阅者Dep的数组中

text(node,vm,text){
      ……
            new Watcher(vm,arguments[1].trim(),(newValue) => {
                //若数据变化,文本节点要重新获取依赖的属性,更新文本中的内容
                updateFn && updateFn(node,this.getTextVal(vm,newValue));
            });
      ……
    },

d.那么当Watcher实例加进去后,Dep.target继续执行就一直都是这个值了,这样是不行的,不是每次更新都是这个值,所以用完后要还回去,把值干掉

##watcher.js

get(){
        Dep.target = this;//只要一创建Watcher实例,就把实例赋给Dep.target
        let value = this.getVal(this.vm,this.expr);//这里一取属性就会调用属性的get()方法,在observer.js
        //更新完后后,要取消掉
        Dep.target = null;
        return value;
    }

e.到这里后,当值改变就可以更新所有数据变化,文本节点和表单节点更新数据

##observer.js

set(newValue){
                if(newValue !== value){
                    //这里的this不是实例
                    //如果是对象继续劫持
                    that.observe(newValue);
                    value = newValue;
                    dep.notify();//通知全体,数据更新了
                }
            }

到这里后,页面效果: 从控制台改变数据,页面数据改变

6.最后要实现,从表单输入新值,文本节点跟着表单更新

给表单节点添加一个事件处理,实现输入新值并赋值

##compile.js

 model(node,vm,expr){  
  ……
  node.addEventListener('input',(e)=>{
            let newValue = e.target.value;
            this.setVal(vm,expr,newValue);
        });
  ……
}
setVal(vm,expr,value){ //expr => [info,a]
        expr = expr.split('.');
        return expr.reduce((pre,next,currentIndex)=>{
            if (currentIndex === expr.length-1){
                return pre[next] = value;
            }
            return pre[next];
        },vm.$data);
    },

到这里后,页面效果: 在输入框输入新值,文本节点跟着改变

---------------------------------