天天看點

前端面試題:這是我了解的MVVM,請注意查收

MVVM模式是什麼?你是怎麼了解MVVM原理的?了解它不隻是應付面試,對VUE、Backbone.js、angular、Ember、avalon架構的設計模式也會有更進步一步的了解,有可能下一個流行架構就是你的傑作~~本篇文章最後也會實作了一個屬于自己的簡易MVVM庫,裡面實作了一個mvvm庫應有基本功能~
      現在流行的前端架構也就是 vue、react、angular了,在投遞履歷時,我們都可以看到任職要求會有最少熟悉這些架構中的一種,掌握這些架構就好像時多了一個輪子或者說是多了一個車,架構可以然我們快速的使用、複用處理一些問題。當然面試中不僅會問到這些隻是的掌握情況,也會問些你的架構的了解,因為知其然也要知其是以然,我們這篇文章來了解MVVM架構模式~如果文章有問題,也請大家指正,不要打我啊~~

一、MVVM的概念

Mvvm定義MVVM是Model-View-ViewModel的簡寫。是一個軟體架構設計模式,由微軟 WPF 和 Silverlight 的架構師 Ken Cooper 和 Ted Peters 開發,是一種簡化使用者界面的事件驅動程式設計方式。由 John Gossman(同樣也是 WPF 和 Silverlight 的架構師)于2005年在他的部落格上發表。即模型-視圖-視圖模型。

二、MVVM的發展史

在了解MVVM之前,我們先回顧一下前端發展的曆史。下面一趴是來 自廖雪峰官方網站的内容,了解曆史就會知道為啥MVVM會被使用,使用背景,當然也可以跳過直接看下一趴~

在上個世紀的1989年,歐洲核子研究中心的實體學家Tim Berners-Lee發明了超文本标記語言(HyperText Markup Language),簡稱HTML,并在1993年成為網際網路草案。從此,網際網路開始迅速商業化,誕生了一大批商業網站。

最早的HTML頁面是完全靜态的網頁,它們是預先編寫好的存放在Web伺服器上的html檔案。浏覽器請求某個URL時,Web伺服器把對應的html檔案扔給浏覽器,就可以顯示html檔案的内容了。

如果要針對不同的使用者顯示不同的頁面,顯然不可能給成千上萬的使用者準備好成千上萬的不同的html檔案,是以,伺服器就需要針對不同的使用者,動态生成不同的html檔案。一個最直接的想法就是利用C、C++這些程式設計語言,直接向浏覽器輸出拼接後的字元串。這種技術被稱為CGI:Common Gateway Interface。

很顯然,像新浪首頁這樣的複雜的HTML是不可能通過拼字元串得到的。于是,人們又發現,其實拼字元串的時候,大多數字元串都是HTML片段,是不變的,變化的隻有少數和使用者相關的資料,是以,又出現了新的建立動态HTML的方式:ASP、JSP和PHP——分别由微軟、SUN和開源社群開發。

在ASP中,一個asp檔案就是一個HTML,但是,需要替換的變量用特殊的<%=var%> 标記出來了,再配合循環、條件判斷,建立動态HTML就比CGI要容易得多。

但是,一旦浏覽器顯示了一個HTML頁面,要更新頁面内容,唯一的方法就是重新向伺服器擷取一份新的HTML内容。如果浏覽器想要自己修改HTML頁面的内容,就需要等到1995年年底,JavaScript被引入到浏覽器。

有了JavaScript後,浏覽器就可以運作JavaScript,然後,對頁面進行一些修改。JavaScript還可以通過修改HTML的DOM結構和CSS來實作一些動畫效果,而這些功能沒法通過伺服器完成,必須在浏覽器實作。

用JavaScript在浏覽器中操作HTML,經曆了若幹發展階段:

第一階段,直接用JavaScript操作DOM節點,使用浏覽器提供的原生API:

var dom = document.getElementById('name');
dom.innerHTML = 'Homer';
dom.style.color = 'red';
複制代碼
           

第二階段,由于原生API不好用,還要考慮浏覽器相容性,jQuery橫空出世,以簡潔的API迅速俘獲了前端開發者的芳心:

$('#name').text('Homer').css('color', 'red');

第三階段,MVC模式,需要伺服器端配合,JavaScript可以在前端修改伺服器渲染後的資料。

現在,随着前端頁面越來越複雜,使用者對于互動性要求也越來越高,想要寫出Gmail這樣的頁面,僅僅用jQuery是遠遠不夠的。MVVM模型應運而生。

MVVM最早由微軟提出來,它借鑒了桌面應用程式的MVC思想,在前端頁面中,把Model用純JavaScript對象表示,View負責顯示,兩者做到了最大限度的分離。

把Model和View關聯起來的就是ViewModel。ViewModel負責把Model的資料同步到View顯示出來,還負責把View的修改同步回Model。

ViewModel如何編寫?需要用JavaScript編寫一個通用的ViewModel,這樣,就可以複用整個MVVM模型了。

三、正式的MVVM了解

  • MVVM模式

MVVM 的出現促進了 GUI 前端開發與後端業務邏輯的分離,極大地提高了前端開發效率。

MVVM 的核心是 ViewModel 層

,它就像是一個中轉站(value converter),負責轉換 Model 中的資料對象來讓資料變得更容易管理和使用,該層向上與視圖層進行雙向資料綁定,向下與 Model 層通過接口請求進行資料互動,起呈上啟下作用。如下圖所示:

  • MVVM組成部分
# View 層

View 是視圖層,也就是使用者界面。前端主要由 HTML 和 CSS 來建構,為了更友善地展現 ViewModel 或者 Model 層的資料,已經産生了各種各樣的前後端模闆語言,比如FreeMarker、Marko、Pug、Jinja2等等,各大 MVVM 架構如 avalon,Vue,Angular 等也都有自己用來建構使用者界面的内置模闆語言。

# Model 層

Model 是指資料模型,泛指後端進行的各種業務邏輯處理和資料操控,主要圍繞資料庫系統展開。

後端業務處理在這就不多贅述了,其實前端人員大多都不需要管,隻要後端保證對外接口足夠簡單就行了,我請求api,你把資料返出來,咱倆就這點關系,其他都扯淡。

# ViewModel 層

ViewModel 是由前端開發人員組織生成和維護的視圖資料層。mvvm模式的核心,它是連接配接view和model的橋梁。在這一層,前端開發者對從後端擷取的 Model 資料進行轉換處理,做二次封裝,以生成符合 View 層使用預期的視圖資料模型。需要注意的是 ViewModel 所封裝出來的資料模型包括視圖的狀态和行為兩部分,而 Model 層的資料模型是隻包含狀态的,比如頁面的這一塊展示什麼,那一塊展示什麼這些都屬于視圖狀态(展示),而頁面加載進來時發生什麼,點選這一塊發生什麼,這一塊滾動時發生什麼這些都屬于視圖行為(互動),視圖狀态和行為都封裝在了 ViewModel 裡。這樣的封裝使得 ViewModel 可以完整地去描述 View 層。由于實作了雙向綁定,ViewModel 的内容會實時展現在 View 層,這是激動人心的,因為前端開發者再也不必低效又麻煩地通過操縱 DOM 去更新視圖,MVVM 架構已經把最髒最累的一塊做好了,我們開發者隻需要處理和維護 ViewModel,更新資料視圖就會自動得到相應更新,真正實作資料驅動開發。看到了吧,View 層展現的不是 Model 層的資料,而是 ViewModel 的資料,由 ViewModel 負責與 Model 層互動,這就完全解耦了 View 層和 Model 層,這個解耦是至關重要的,它是前後端分離方案實施的重要一環。

  • MVVM設計模式的優缺點:
     優點:

1、

當然是最主要的雙向綁定技術, 單向綁定與雙向綁定。 所謂

單向綁定”就是ViewModel變化時,自動更新View

所謂

雙向綁定”就是在單向綁定的基礎上View變化時,自動更新ViewModel

    我們可以先觀察下MVVM架構和jQuery操作DOM相比有什麼差別?

原來的html

<p>Hello, <span id="name">LEE</span>!</p><p>You are <span id="age">18</span>.</p>

展示

用jQuery修改name和age節點的内容:

var name = '修改';
var age =100;
 
$('#name').text(name);
$('#age').text(age);
複制代碼
           

如果我們使用MVVM架構來實作同樣的功能,我們首先并不關心DOM的結構,而是關心資料如何存儲。最簡單的資料存儲方式是使用:

var person = {
    name: 'LEEt',
    age: 18
};
複制代碼
           

我們把變量person看作Model,把HTML某些DOM節點看作View,并假定它們之間被關聯起來了。

要把顯示的name從LEE改為修改,把顯示的age從18改為100,我們并不操作DOM,而是直接修改JavaScript對象:

person.name = '修改';

person.age = 100;
複制代碼
           

這樣可以看出,我們的關注點從如何操作DOM變成了如何更新JavaScript對象的狀态,而操作JavaScript對象比DOM簡單多了!

MVVM 的設計思想:關注 Model 的變化,讓 MVVM 架構去自動更新 DOM 的狀态,進而把發者從操作 DOM 的繁瑣步驟中解脫出來!

2、由于控制器的功能大都移動到View上處理,大大的對控制器進行了瘦身。

3、可以對View或ViewController的資料處理部分抽象出來一個函數處理model。這樣它們專職頁面布局和頁面跳轉,它們必然更一步的簡化。

4、提高可維護性

5、可測試。界面素來是比較難于測試的,而現在測試可以針對ViewModel來寫。

6、低耦合可重用:視圖(View)可以獨立于Model變化和修改,一個ViewModel可以綁定不同的"View"上,當View變化的時候Model不可以不變,當Model變化的時候View也可以不變。你可以把一些視圖邏輯放在一個ViewModel裡面,讓很多view重用這段視圖邏輯。

缺點:
  1. Bug很難被調試。因為使用雙向綁定的模式,當你看到界面異常了,有可能是你View的代碼有Bug,也可能是Model的代碼有問題。資料綁定使得一個位置的Bug被快速傳遞到别的位置,要定位原始出問題的地方就變得不那麼容易了。另外,資料綁定的聲明是指令式地寫在View的模版當中的,這些内容是沒辦法去打斷點debug的。
  2. 一個大的子產品中model也會很大,雖然使用友善了也很容易保證了資料的一緻性,當時長期持有,不釋放記憶體就造成了花費更多的記憶體。
  3. 對于大型的圖形應用程式,視圖狀态較多,ViewModel的建構和維護的成本都會比較高。
  • MVVM的适用範圍

從幾個例子我們可以看到,MVVM最大的優勢是編寫前端邏輯非常複雜的頁面,尤其是需要大量DOM操作的邏輯,利用MVVM可以極大地簡化前端頁面的邏輯。

但是MVVM不是萬能的,它的目的是為了解決複雜的前端邏輯。對于以展示邏輯為主的頁面,例如,新聞,部落格、文檔等,不能使用MVVM展示資料,因為這些頁面需要被搜尋引擎索引,而搜尋引擎無法擷取使用MVVM并通過API加載的資料。

是以,需要 SEO(Search Engine Optimization)的頁面,不能使用MVVM展示資料。不需要SEO的頁面,如果前端邏輯複雜,就适合使用MVVM展示資料,例如,工具類頁面,複雜的表單頁面,使用者登入後才能操作的頁面等等。當然可能現在有了ssr。 常用的MVVM架構有:

Angular:Google出品,名氣大,但是學習難度有些大;适合PC,代碼結構會比較清晰;

Backbone.js:入門非常困難,因為自身API太多;

Ember:一個大而全的架構,想寫個Hello world都很困難。

Avalon:屬于輕量級的,并且對老的浏覽器支援程度較高,最低支援到IE6,是以适合相容老劉浏覽器的項目;

Vue:主打輕量級,僅作為MV*中的視圖部分使用,優點輕量級,易學易用,缺點是大項目的時候還要配合其他架構或者庫來使用,比較麻煩

四、實作MVVM的js庫

目前實作資料雙向綁定主要有一下幾種方式:

  1. 髒值檢測(angular):

以典型的mvvm架構angularjs為代表,angular通過檢查髒資料來進行UI層的操作更新。關于angular的髒檢測,有幾點需要了解些:

  • l髒檢測機制并不是使用定時檢測。
  • l髒檢測的時機是在資料發生變化時進行。
  • l angular對常用的dom事件,xhr事件等做了封裝, 在裡面觸發進入angular的digest流程。
  • l在digest流程裡面, 會從rootscope開始周遊, 檢查所有的watcher。 (關于angular的具體設計可以看其他文檔,這裡隻讨論資料綁定),那我們看下髒檢測該如何去做:主要是通過設定的資料來需找與該資料相關的所有元素,然後再比較資料變化,如果變化則進行指令操作。

  2.前端資料劫持(Hijacking)(vue):基本思路:通過Object.defineProperty() 去劫持資料每個屬性對應的getter和setter。當有資料讀取和指派操作時則調用節點的指令,這樣使用最通用的=等号指派就可以了。

  3.釋出-訂閱模式(backbone):通過釋出消息,訂閱消息進行資料和視圖的綁定監聽。

比較老的實作方式,使用觀察者程式設計模式,主要思路是通過在資料對象上定義get和set方法等,調用時手動調用get或set資料,改變資料後觸發UI層的渲染操作;以視圖驅動資料變化的場景主要應用與input、select、textarea等元素,當UI層變化時,通過監聽dom的change,keypress,keyup等事件來觸發事件改變資料層的資料。整個過程均通過函數調用完成。

代碼實作思路:(類似實作 VUE

的一個實作MVVM的庫)

模拟的是VUE的MVVM庫使用資料劫持思路實作,MVVM,上圖為基本思路圖。如上圖所示,我們可以看到,整體實作分為已下步驟

1、實作一個Observer,對資料進行劫持,通知資料的變化(将使用的要點為:Object.defineProperty()方法)

2、實作一個Compile,對指令進行解析,初始化視圖,并且訂閱資料的變更,綁定好更新函數

3、實作一個Watcher,将其作為以上兩者的一個中介點,在接收資料變更的同時,讓Dep添加目前Watcher,并及時通知視圖進行update

4、實作一些VUE的其他功能(Computed、menthods)

5、實作MVVM,整合以上幾點,作為一個入口函數

以下為代碼部分:

Html:

<!DOCTYPE html>
<html hljs-string">"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>
實作MVVM的js庫(模拟vue實作功能)</title>

<script src="./MVVM.js"></script>
</head>
 
<body>
 
<div id="app">
<input type="text" v-model="person.name">
<p>
hello,{{person.name}}
</p>
<p>You are:{{person.age}}</p>
<!-- computed屬性如果資料不變化 視圖不更新 -->
<p>{{getNewName}}</p>
<button type="button" name="button" v-on:click="testToggle">
修改名字</button>

</div>
 
<script>
let vm = new Vue({
el: '#app',
data: {
person: {
name: 'lee',
age: 18
}
},
methods: {
testToggle(){
this.person.name = '修改後的名字:哈哈';
}
},
computed: {
getNewName(){
return this.person.name+' 是要成為海賊王的人'
}
},
})
</script>
</body>
</html>
複制代碼
           

js:

// 2019-4-4
// lee 
// 草履蟲的思考
// 簡單模拟vue實作MVVM
/**
 * 實作一個Vue的類
 * 1、實作一個Observer,對資料進行劫持,通知資料的變化(将使用的要點為:Object.defineProperty()方法)
2、實作一個Compile,對指令進行解析,初始化視圖,并且訂閱資料的變更,綁定好更新函數ComplieUtil解析指令的公共方法
3、實作一個Watcher,将其作為以上兩者的一個中介點,在接收資料變更的同時,讓Dep添加目前Watcher,并及時通知視圖進行update
4、實作一些VUE的其他功能(Computed、menthods)
 */
// 觀察者模式(釋出訂閱)
class Dep {
    constructor() {
        this.subs = []; //存放所有watcher
    }
    // 訂閱 添加watcher
    addSub(watcher) {
        this.subs.push(watcher);
    }
    // 釋出
    notify() {
        this.subs.forEach(watcher => watcher.update());
    }
}
// 觀察者 vm.$watch(vm,'person.name',(newVal)=>{ })
class Watcher {
    constructor(vm, expr, cb) {
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        // 預設存儲一個老值
        this.oldValue = this.get();
    }
 
    get() {
        Dep.target = this;
        // 取值 把這個觀察者和資料關聯起來
        let val = ComplieUtil.getVal(this.vm,this.expr);
        Dep.target = null;
        return val;
    }
    // 更新操作 資料變化後 會調用觀察者中的update方法
    update() {
        let newVal = ComplieUtil.getVal(this.vm,this.expr);
        if (newVal !== this.oldValue) {
            this.cb(newVal);
        }
    }
}
 
// 實作資料劫持作用
class Observer {
    constructor(data) {
        this.observer(data);
    }
    observer(data) {
        // 如果是對象才觀察
        if (data && typeof data === 'object') {
            for (let key in data) {
                this.defineReactive(data, key, data[key])
            }
        }
    }
 
    defineReactive(obj, key, value) {
        this.observer(value);
        // 給每個屬性 都加上具有釋出訂閱的功能
        let dep = new Dep();
        Object.defineProperty(obj, key, {
            enumerable: true,   // 可枚舉
            configurable: true, // 可重新定義
            get() {
                // 建立watcher時 會取到對應的内容,并且把watcher放到全局上
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set: (newVal) => { // {person:{name:'lee'}
                // 資料沒有變不需要更新
                if (newVal != value) {
                    // 需要遞歸
                    this.observer(newVal);
                    value = newVal;
                    dep.notify();
                }
            }
        })
    }
}
// 編譯器
class Complier {
    constructor(el, vm) {
        // 判斷el屬性是不是一個元素 如果不是元素 那就擷取他 (因為在vue的el中可能是el:'#app'
        // 或者document.getElementById('app')
 
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 把目前節點中的元素 擷取到 放到記憶體中
        let fragment = this.nodeFragMent(this.el);
 
        // 把節點中的内容進行替換
 
        // 編譯模闆 用資料編譯
        this.complie(fragment);
        // 把内容在塞到頁面中
        this.el.appendChild(fragment);
    }
 
    isElementNode(node) { //是不是元素節點
        return node.nodeType === 1;
    }
    //  把節點移動到記憶體中
    nodeFragMent(node) {
        let frag = document.createDocumentFragment();
        let firstChild;
        while (firstChild = node.firstChild) {
            // appendChild 具有移動性 
            frag.appendChild(firstChild);
        }
        return frag;
    }
    // 是不是指令 
    isDirective(attrName) {
        return attrName.startsWith('v-');
    }
    // 編譯元素
    complieElement(node) {
        let attr = node.attributes;
        [...attr].forEach(item => {
            // item 有key = value ,type="text" v-model="person.name"
            let {
                name,
                value: expr
            } = item;
            if (this.isDirective(name)) {
                // v-mode v-html v-bind...
                let [, directive] = name.split('-');
               let [directiveName,eventName] = directive.split(':');
                console.log(node, expr, this.vm, eventName);
                // ComplieUtil[directive](node, expr, this.vm);
                ComplieUtil[directiveName](node, expr, this.vm, eventName);
            }
        })
    }
    // 編譯文本
    // 判斷目前文本節點中内容是否包括{{}}
    complieText(node) {
        let content = node.textContent;
        var reg = /\{\{(.+?)\}\}/;
        if (reg.test(content)) {
            ComplieUtil['text'](node, content,this.vm); //{{}}
        }
    }
    // 用來編譯記憶體中的dom節點
    complie(node) {
        let childNode = node.childNodes;
        // childNode 是類數組 轉換為數組
        [...childNode].forEach(item => {
            // 元素 查找v-開頭
            if (this.isElementNode(item)) {
                this.complieElement(item);
                //如果是元素的話  需要把自己傳進去
                // 在去周遊子節點
                this.complie(item);
                //    文本 查找{{}}内容
            } else {
                this.complieText(item);
            }
        })
 
    }
}
// 編譯工具
ComplieUtil = {
    // 解析v-model指令
    // node是節點 expr是表達式 vm是執行個體 person.name vm.$data 解析v-model
    model(node, expr, vm) {
        // 給輸入框賦予value屬性 node.value = xxx
        let fn = this.updater['modelUpdater'];
        let val = this.getVal(vm, expr);
        // 給輸入框加一個觀察者 如果稍後資料更i性能了會觸發此方法,資料會更新
        new Watcher(vm, expr, (newVal) => {
            fn(node, newVal);
        });
        // 輸入事件
        node.addEventListener('input',(e)=>{
            let val = e.target.value; //擷取使用者輸入的内容
            this.setVal(vm, expr, val);
        });
        fn(node, val);
    },
    html() {
 
    },
    // 傳回了一個全的字元串
    getContentVal(vm, expr) {
        return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(vm, args[1]);
        });
    },
    text(node, expr, vm) { //expr {{a}} {{b}} {{person.name}}
        let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
        //給表達式{{}}都加上觀察者    
            new Watcher(vm, args[1], () => {
                fn(node, this.getContentVal(vm, expr));
            });
            return this.getVal(vm, args[1]);
        });
        let fn = this.updater['textUpdater'];
        fn(node, content);
    },
    on(node, expr, vm,eventName){ //v-on:click
        console.log(node, expr, vm, eventName);
        node.addEventListener(eventName,(e)=>{
            vm[expr].call(vm,e );
        });
       
    },
    updater: {
        modelUpdater(node, value) {
            node.value = value;
        },
        htmlUpdater() {},
        // 處理文本節點
        textUpdater(node, value) {
            node.textContent = value;
        }
    },
    //根據表達式取到的對應的資料  vm.$data expr是如 'person.name'
    getVal(vm, expr) {
      return  expr.split('.').reduce((data, cur) => {
            return data[cur];
        }, vm.$data);
    },
    setVal(vm, expr,value){
        expr.split('.').reduce((data, cur,index,arr) => {
           if(index == arr.length-1){ //索引是最後一項 
               return data[cur] = value;
           }
            return data[cur];
        }, vm.$data);
    }
}
class Vue {
    constructor(options) {
        this.$el = options.el;
        this.$data = options.data;
        let computed = options.computed;
        let methods = options.methods;
        // 根元素存在在編譯模闆
        if (this.$el) {
            // 把資料 全部轉化成用Object.defineProperty來定義
            new Observer(this.$data);
 
 
            // 實作methods中的方法
            for (let key in methods) { 
                Object.defineProperty(this, key, {
                    get() {
                        return methods[key]; //進行了轉化操作
                    }
                });
            }
            // 實作computed中的方法
            for (let key in computed) { //有依賴關系
                Object.defineProperty(this.$data, key, {
                    get() {
                        return computed[key].call(this); //進行了轉化操作
                    }
                });
            }
               // 把資料擷取操作 都代理到vm.$data
            this.proxy(this.$data);
            new Complier(this.$el, this);
        }
 
    }
    // 代理 去掉$data
    proxy(data){
        for(let key in data){
            Object.defineProperty(this,key,{
                get(){
                    return data[key]; //進行了轉化操作
                }
            });
        }
    }
}


複制代碼
           

繼續閱讀