淺析前端開發中的 MVC/MVP/MVVM 模式

扎克悟空發表於2017-06-01

本文首發於掘金專欄,釋出於廖柯宇的獨立部落格,轉載請保留原文連結。

MVC,MVP和MVVM都是常見的軟體架構設計模式(Architectural Pattern),它通過分離關注點來改進程式碼的組織方式。不同於設計模式(Design Pattern),只是為了解決一類問題而總結出的抽象方法,一種架構模式往往使用了多種設計模式。

要了解MVC、MVP和MVVM,就要知道它們的相同點和不同點。不同部分是C(Controller)、P(Presenter)、VM(View-Model),而相同的部分則是MV(Model-View)。

Model&View

這裡有一個可以對數值進行加減操作的元件:上面顯示數值,兩個按鈕可以對數值進行加減操作,操作後的數值會更新顯示。

淺析前端開發中的 MVC/MVP/MVVM 模式

我們將依照這個“栗子”,嘗試用JavaScript實現簡單的具有MVC/MVP/MVVM模式的Web應用。

Model

Model層用於封裝和應用程式的業務邏輯相關的資料以及對資料的處理方法。這裡我們把需要用到的數值變數封裝在Model中,並定義了add、sub、getVal三種運算元值方法。

var myapp = {}; // 建立這個應用物件

myapp.Model = function() {
    var val = 0; // 需要操作的資料

    /* 運算元據的方法 */
    this.add = function(v) {
        if (val < 100) val += v;
    };

    this.sub = function(v) {
        if (val > 0) val -= v;
    };

    this.getVal = function() {
        return val;
    };
};複製程式碼

View

View作為檢視層,主要負責資料的展示。

myapp.View = function() {

    /* 檢視元素 */
    var $num = $('#num'),
        $incBtn = $('#increase'),
        $decBtn = $('#decrease');

    /* 渲染資料 */
    this.render = function(model) {
        $num.text(model.getVal() + 'rmb');
    };
};複製程式碼

現在通過Model&View完成了資料從模型層到檢視層的邏輯。但對於一個應用程式,這遠遠是不夠的,我們還需要響應使用者的操作、同步更新View和Model。於是,在MVC中引入了控制器controller,讓它來定義使用者介面對使用者輸入的響應方式,它連線模型和檢視,用於控制應用程式的流程,處理使用者的行為和資料上的改變。

MVC

那時計算機世界天地混沌,渾然一體,然後出現了一個創世者,將現實世界抽象出模型形成model,將人機互動從應用邏輯中分離形成view,然後就有了空氣、水、雞啊、蛋什麼的。
——《前端MVC變形記》

上個世紀70年代,美國施樂帕克研究中心,就是那個發明圖形使用者介面(GUI)的公司,開發了Smalltalk程式語言,並開始用它編寫圖形介面的應用程式。

到了Smalltalk-80這個版本的時候,一位叫Trygve Reenskaug的工程師為Smalltalk設計了MVC(Model-View-Controller)這種架構模式,極大地降低了GUI應用程式的管理難度,而後被大量用於構建桌面和伺服器端應用程式。

淺析前端開發中的 MVC/MVP/MVVM 模式

如圖,實線代表方法呼叫,虛線代表事件通知。

MVC允許在不改變檢視的情況下改變檢視對使用者輸入的響應方式,使用者對View的操作交給了Controller處理,在Controller中響應View的事件呼叫Model的介面對資料進行操作,一旦Model發生變化便通知相關檢視進行更新。

Model

Model層用來儲存業務的資料,一旦資料發生變化,模型將通知有關的檢視。

myapp.Model = function() {
    var val = 0;

    this.add = function(v) {
        if (val < 100) val += v;
    };

    this.sub = function(v) {
        if (val > 0) val -= v;
    };

    this.getVal = function() {
        return val;
    };

    /* 觀察者模式 */
    var self = this, 
        views = [];

    this.register = function(view) {
        views.push(view);
    };

    this.notify = function() {
        for(var i = 0; i < views.length; i++) {
            views[i].render(self);
        }
    };
};複製程式碼

Model和View之間使用了觀察者模式,View事先在此Model上註冊,進而觀察Model,以便更新在Model上發生改變的資料。

View

view和controller之間使用了策略模式,這裡View引入了Controller的例項來實現特定的響應策略,比如這個栗子中按鈕的 click 事件:

myapp.View = function(controller) {
    var $num = $('#num'),
        $incBtn = $('#increase'),
        $decBtn = $('#decrease');

    this.render = function(model) {
        $num.text(model.getVal() + 'rmb');
    };

    /*  繫結事件  */
    $incBtn.click(controller.increase);
    $decBtn.click(controller.decrease);
};複製程式碼

如果要實現不同的響應的策略只要用不同的Controller例項替換即可。

Controller

控制器是模型和檢視之間的紐帶,MVC將響應機制封裝在controller物件中,當使用者和你的應用產生互動時,控制器中的事件觸發器就開始工作了。

myapp.Controller = function() {
    var model = null,
        view = null;

    this.init = function() {
        /* 初始化Model和View */
        model = new myapp.Model();
        view = new myapp.View(this);

        /* View向Model註冊,當Model更新就會去通知View啦 */
        model.register(view);
        model.notify();
    };

    /* 讓Model更新數值並通知View更新檢視 */
    this.increase = function() {
        model.add(1);
        model.notify();
    };

    this.decrease = function() {
        model.sub(1);
        model.notify();
    };
};複製程式碼

這裡我們例項化View並向對應的Model例項註冊,當Model發生變化時就去通知View做更新,這裡用到了觀察者模式。

當我們執行應用的時候,使用Controller做初始化:

(function() {
    var controller = new myapp.Controller();
    controller.init();
})();複製程式碼

可以明顯感覺到,MVC模式的業務邏輯主要集中在Controller,而前端的View其實已經具備了獨立處理使用者事件的能力,當每個事件都流經Controller時,這層會變得十分臃腫。而且MVC中View和Controller一般是一一對應的,捆綁起來表示一個元件,檢視與控制器間的過於緊密的連線讓Controller的複用性成了問題,如果想多個View共用一個Controller該怎麼辦呢?這裡有一個解決方案:

淺析前端開發中的 MVC/MVP/MVVM 模式

來把王者榮耀壓壓驚~其實我想說的是MVP模式...

MVP

MVP(Model-View-Presenter)是MVC模式的改良,由IBM的子公司Taligent提出。和MVC的相同之處在於:Controller/Presenter負責業務邏輯,Model管理資料,View負責顯示。

淺析前端開發中的 MVC/MVP/MVVM 模式

雖然在MVC裡,View是可以直接訪問Model的,但MVP中的View並不能直接使用Model,而是通過為Presenter提供介面,讓Presenter去更新Model,再通過觀察者模式更新View。

與MVC相比,MVP模式通過解耦View和Model,完全分離檢視和模型使職責劃分更加清晰;由於View不依賴Model,可以將View抽離出來做成元件,它只需要提供一系列介面提供給上層操作。

Model

myapp.Model = function() {
    var val = 0;

    this.add = function(v) {
        if (val < 100) val += v;
    };

    this.sub = function(v) {
        if (val > 0) val -= v;
    };

    this.getVal = function() {
        return val;
    };
};複製程式碼

Model層依然是主要與業務相關的資料和對應處理資料的方法。

View

myapp.View = function() {
    var $num = $('#num'),
        $incBtn = $('#increase'),
        $decBtn = $('#decrease');

    this.render = function(model) {
        $num.text(model.getVal() + 'rmb');
    };

    this.init = function() {
        var presenter = new myapp.Presenter(this);

        $incBtn.click(presenter.increase);
        $decBtn.click(presenter.decrease);
    };
};複製程式碼

MVP定義了Presenter和View之間的介面,使用者對View的操作都轉移到了Presenter。比如這裡可以讓View暴露setter介面以便Presenter呼叫,待Presenter通知Model更新後,Presenter呼叫View提供的介面更新檢視。

Presenter

myapp.Presenter = function(view) {
    var _model = new myapp.Model();
    var _view = view;

    _view.render(_model);

    this.increase = function() {
        _model.add(1);
        _view.render(_model);
    };

    this.decrease = function() {
        _model.sub(1);
        _view.render(_model);
    };
};複製程式碼

Presenter作為View和Model之間的“中間人”,除了基本的業務邏輯外,還有大量程式碼需要對從View到Model和從Model到View的資料進行“手動同步”,這樣Presenter顯得很,維護起來會比較困難。而且由於沒有資料繫結,如果Presenter對檢視渲染的需求增多,它不得不過多關注特定的檢視,一旦檢視需求發生改變,Presenter也需要改動。

執行程式時,以View為入口:

(function() {
    var view = new myapp.View();
    view.init();
})();複製程式碼

MVVM

MVVM(Model-View-ViewModel)最早由微軟提出。ViewModel指 "Model of View"——檢視的模型。這個概念曾在一段時間內被前端圈熱炒,以至於很多初學者拿jQuery和Vue做對比...

淺析前端開發中的 MVC/MVP/MVVM 模式

MVVM把View和Model的同步邏輯自動化了。以前Presenter負責的View和Model同步不再手動地進行操作,而是交給框架所提供的資料繫結功能進行負責,只需要告訴它View顯示的資料對應的是Model哪一部分即可。

這裡我們使用Vue來完成這個栗子。

Model

在MVVM中,我們可以把Model稱為資料層,因為它僅僅關注資料本身,不關心任何行為(格式化資料由View的負責),這裡可以把它理解為一個類似json的資料物件。

var data = {
    val: 0
};複製程式碼

View

和MVC/MVP不同的是,MVVM中的View通過使用模板語法來宣告式的將資料渲染進DOM,當ViewModel對Model進行更新的時候,會通過資料繫結更新到View。寫法如下:

<div id="myapp">
    <div>
        <span>{{ val }}rmb</span>
    </div>
    <div>
        <button v-on:click="sub(1)">-</button>
        <button v-on:click="add(1)">+</button>
    </div>
</div>複製程式碼

ViewModel

ViewModel大致上就是MVC的Controller和MVP的Presenter了,也是整個模式的重點,業務邏輯也主要集中在這裡,其中的一大核心就是資料繫結,後面將會講到。與MVP不同的是,沒有了View為Presente提供的介面,之前由Presenter負責的View和Model之間的資料同步交給了ViewModel中的資料繫結進行處理,當Model發生變化,ViewModel就會自動更新;ViewModel變化,Model也會更新。

new Vue({
    el: '#myapp',
    data: data,
    methods: {
        add(v) {
            if(this.val < 100) {
                this.val += v;
            }
        },
        sub(v) {
            if(this.val > 0) {
                this.val -= v;
            }
        }
    }
});複製程式碼

整體來看,比MVC/MVP精簡了很多,不僅僅簡化了業務與介面的依賴,還解決了資料頻繁更新(以前用jQuery操作DOM很繁瑣)的問題。因為在MVVM中,View不知道Model的存在,ViewModel和Model也察覺不到View,這種低耦合模式可以使開發過程更加容易,提高應用的可重用性。

資料繫結

雙向資料繫結,可以簡單而不恰當地理解為一個模版引擎,但是會根據資料變更實時渲染。——《介面之下:還原真實的MV*模式》

淺析前端開發中的 MVC/MVP/MVVM 模式

在Vue中,使用了雙向繫結技術(Two-Way-Data-Binding),就是View的變化能實時讓Model發生變化,而Model的變化也能實時更新到View。

“據說這玩意兒可以申請專利呢”

淺析前端開發中的 MVC/MVP/MVVM 模式

不同的MVVM框架中,實現雙向資料繫結的技術有所不同。目前一些主流的前端框架實現資料繫結的方式大致有以下幾種:

  • 資料劫持 (Vue)
  • 釋出-訂閱模式 (Knockout、Backbone)
  • 髒值檢查 (Angular)

我們這裡主要講講Vue。

Vue採用資料劫持&釋出-訂閱模式的方式,通過ES5提供的 Object.defineProperty() 方法來劫持(監控)各屬性的 gettersetter ,並在資料(物件)發生變動時通知訂閱者,觸發相應的監聽回撥。並且,由於是在不同的資料上觸發同步,可以精確的將變更傳送給繫結的檢視,而不是對所有的資料都執行一次檢測。要實現Vue中的雙向資料繫結,大致可以劃分三個模組:Observer、Compile、Watcher,如圖:

淺析前端開發中的 MVC/MVP/MVVM 模式

  • Observer 資料監聽器
    負責對資料物件的所有屬性進行監聽(資料劫持),監聽到資料發生變化後通知訂閱者。

  • Compiler 指令解析器
    掃描模板,並對指令進行解析,然後繫結指定事件。

  • Watcher 訂閱者
    關聯Observer和Compile,能夠訂閱並收到屬性變動的通知,執行指令繫結的相應操作,更新檢視。Update()是它自身的一個方法,用於執行Compile中繫結的回撥,更新檢視。

資料劫持

一般對資料的劫持都是通過Object.defineProperty方法進行的,Vue中對應的函式為 defineReactive ,其普通物件的劫持的精簡版程式碼如下:

var foo = {
  name: 'vue',
  version: '2.0'
}

function observe(data) {
    if (!data || typeof data !== 'object') {
        return
    }
    // 使用遞迴劫持物件屬性
    Object.keys(data).forEach(function(key) {
        defineReactive(data, key, data[key]);
    })
}

function defineReactive(obj, key, value) {
     // 監聽子屬性 比如這裡data物件裡的 'name' 或者 'version'
     observe(value)

    Object.defineProperty(obj, key, {
        get: function reactiveGetter() {
            return value
        },
        set: function reactiveSetter(newVal) {
            if (value === newVal) {
                return
            } else {
                value = newVal
                console.log(`監聽成功:${value} --> ${newVal}`)
            }
        }
    })
}

observe(foo)
foo.name = 'angular' // “監聽成功:vue --> angular”複製程式碼

上面完成了對資料物件的監聽,接下來還需要在監聽到變化後去通知訂閱者,這需要實現一個訊息訂閱器 Dep ,Watcher通過 Dep 新增訂閱者,當資料改變便觸發 Dep.notify() ,Watcher呼叫自己的 update() 方法完成檢視更新。

寫著寫著發現離主題越來越遠了。。。資料劫持就先講這麼多吧~對於想深入vue.js的同學可以參考勾三股四的Vue.js 原始碼學習筆記

總結

MV*的目的是把應用程式的資料、業務邏輯和介面這三塊解耦,分離關注點,不僅利於團隊協作和測試,更有利於甩鍋維護和管理。業務邏輯不再關心底層資料的讀寫,而這些資料又以物件的形式呈現給業務邏輯層。從 MVC --> MVP --> MVVM,就像一個打怪升級的過程,它們都是在MVC的基礎上隨著時代和應用環境的發展衍變而來的。

在我們糾結於使用什麼架構模式或框架的時候,不如先了解它們。靜下來思考業務場景和開發需求,不同需求下會有最適合的解決方案。我們使用這個框架就代表認同它的思想,相信它能夠提升開發效率解決當前的問題,而不僅僅是因為大家都在學。

有人對新技術樂此不疲,有人對新技術不屑一顧。正如狄更斯在《雙城記》中寫的:

這是最好的時代,這是最壞的時代,這是智慧的時代,這是愚蠢的時代;這是信仰的時期,這是懷疑的時期;這是光明的季節,這是黑暗的季節;這是希望之春,這是失望之冬;人們面前應有盡有,人們面前一無所有;人們正在直登天堂;人們正在直下地獄。

請保持一顆擁抱變化的心,在新技術面前不盲目,不守舊。

寫了兩天,也查閱了很多資料,對於我而言也是一次學習的過程。希望對看完本文後的同學有所幫助。不足之處請多指教。

一些參考資源:
GUI Architectures
介面之下:還原真實的MV*模式
前端MVC變形記
深入理解JavaScript系列
250行實現一個簡單的MVVM

相關文章