前端MVC變形記
背景:
MVC是一種架構(gòu)設計模式,它通過關(guān)注點分離鼓勵改進應用程序組織。在過去,MVC被大量用于構(gòu)建桌面和服務器端應用程序,如今Web應用程序的開發(fā)已經(jīng)越來越向傳統(tǒng)應用軟件開發(fā)靠攏,Web和應用之間的界限也進一步模糊。傳統(tǒng)編程語言中的設計模式也在慢慢地融入Web前端開發(fā)。由于前端開發(fā)的環(huán)境特性,在經(jīng)典MVC模式上也引申出了諸多MV*模式,被實現(xiàn)到各個Javascript框架中都有多少的衍變。在研究MV*模式和各框架的過程中,卻是“剪不斷、理還亂”:
- 為什么每個地方講的MVC都不太一樣?
- MVP、MVVM的出現(xiàn)是要解決什么問題?
- 為什么有人義正言辭的說“MVC在Web前端開發(fā)中根本無法使用”?
帶著十萬個為什么去翻閱很多資料,但是看起來像view、model、controller、解耦、監(jiān)聽、通知、主動、被動、注冊、綁定、渲染等各種術(shù)語的排列組合,像汪峰的歌詞似的。本篇希望用通俗易懂的方式闡述清楚一些關(guān)系,由于接觸時間有限,英文閱讀能力有限,可能會存在誤解,歡迎討論和糾正。
MVC變形記
MVC歷史
MVC最初是在研究Smalltalk-80(1979年)期間設計出來的,恐怕沒有一本書能夠回到計算機石器時代介紹一下Smalltalk的代碼是如何實現(xiàn)MVC的,不僅如此,連想搞清楚當時的應用場景都很難了,都要追溯到80后出生以前的事了。但是當時的圖形界面少之又少,施樂公司正在研發(fā)友好的用戶圖形界面,以取代電腦屏幕上那些拒人于千里之外的命令行和DOS提示符。那時計算機世界天地混沌,渾然一體,然后出現(xiàn)了一個創(chuàng)世者,將現(xiàn)實世界抽象出模型形成model,將人機交互從應用邏輯中分離形成view,然后就有了空氣、水、雞啊、蛋什么的。在1995年出版的《設計模式:可復用面向?qū)ο筌浖幕A》對MVC進行了深入的闡述,在推廣使用方面發(fā)揮了重要作用。
MVC包括三類對象,將他們分離以提高靈活性和復用性。
- 模型model用于封裝與應用程序的業(yè)務邏輯相關(guān)的數(shù)據(jù)以及對數(shù)據(jù)的處理方法,會有一個或多個視圖監(jiān)聽此模型。一旦模型的數(shù)據(jù)發(fā)生變化,模型將通知有關(guān)的視圖。
- 視圖view是它在屏幕上的表示,描繪的是model的當前狀態(tài)。當模型的數(shù)據(jù)發(fā)生變化,視圖相應地得到刷新自己的機會。
- 控制器controller定義用戶界面對用戶輸入的響應方式,起到不同層面間的組織作用,用于控制應用程序的流程,它處理用戶的行為和數(shù)據(jù)model上的改變。
經(jīng)典MVC模式
實線:方法調(diào)用 虛線:事件通知
其中涉及兩種設計模式:
- view和model之間的觀察者模式,view觀察model,事先在此model上注冊,以便view可以了解在數(shù)據(jù)model上發(fā)生的改變。
- view和controller之間的策略模式
一個策略是一個表述算法的對象,MVC允許在不改變視圖外觀的情況下改變視圖對用戶輸入的響應方式。例如,你可能希望改變視圖對鍵盤的響應方式,或希望使用彈出菜單而不是原來的命令鍵方式。MVC將響應機制封裝在controller對象中。存在著一個controller的類層次結(jié)構(gòu),使得可以方便地對原有的controller做適當改變而創(chuàng)建新的controller。
view使用controller子類的實例來實現(xiàn)一個特定的響應策略。要實現(xiàn)不同的響應的策略只要用不同種類的controller實例替換即可。甚至可以在運行時刻通過改變view的controller來改變用戶輸入的響應方式。例如,一個view可以被禁止接受任何輸入,只需給他一個忽略輸入事件的controller。
好吧,如果被上述言論繞昏了,請繼續(xù)研讀《設計模式:可復用面向?qū)ο筌浖幕A》。
MVC for JAVASCRIPT
我們回顧了經(jīng)典的MVC,接下來講到的MVC主要是在Javascript上的實現(xiàn)。
javascript MVC模式
源圖
如圖所示,view承接了部分controller的功能,負責處理用戶輸入,但是不必了解下一步做什么。它依賴于一個controller為她做決定或處理用戶事件。事實上,前端的view已經(jīng)具備了獨立處理用戶事件的能力,如果每個事件都要流經(jīng)controller,勢必增加復雜性。同時,view也可以委托controller處理model的更改。model數(shù)據(jù)變化后通知view進行更新,顯示給用戶。這個過程是一個圓,一個循環(huán)的過程。
這種從經(jīng)典MVC到Javascript MVC的1對1轉(zhuǎn)化,導致控制器的角色有點尷尬。MVC這樣的結(jié)構(gòu)的正確性在于,任何界面都需要面對一個用戶,而controller “是用戶和系統(tǒng)之間的鏈接”。在經(jīng)典MVC中,controller要做的事情多數(shù)是派發(fā)用戶輸入給不同的view,并且在必要的時候從view中獲取用戶輸入來更改model,而Web以及絕大多數(shù)現(xiàn)在的UI系統(tǒng)中,controller的職責已經(jīng)被系統(tǒng)實現(xiàn)了。由于某種原因,控制器和視圖的分界線越來越模糊,也有認為,view啟動了action理論上應該把view歸屬于controller。比如在Backbone中,Backbone.View和Backbone.Router一起承擔了controller的責任。這就為MVC中controller的衍變埋下了伏筆。
MVP
MVP(model-view-Presenter)是經(jīng)典MVC設計模式的一種衍生模式,是在1990年代Taligent公司創(chuàng)造的,一個用于C++ CommonPoint的模型。背景上不再考證,直接上圖看一下與MVC的不同。
MVP模式
經(jīng)典MVC中,一對controller-view捆綁起來表示一個ui組件,controller直接接受用戶輸入,并將輸入轉(zhuǎn)為相應命令來調(diào)用model的接口,對model的狀態(tài)進行修改,***通過觀察者模式對view進行重新渲染。
進化為MVP的切入點是修改controller-view的捆綁關(guān)系,為了解決controller-view的捆綁關(guān)系,將進行改造,使view不僅擁有UI組件的結(jié)構(gòu),還擁有處理用戶事件的能力,這樣就能將controller獨立出來。為了對用戶事件進行統(tǒng)一管理,view只負責將用戶產(chǎn)生的事件傳遞給controller,由controller來統(tǒng)一處理,這樣的好處是多個view可共用同一個controller。此時的controller也由組件級別上升到了應用級別,然而更新view的方式仍然與經(jīng)典MVC一樣:通過Presenter更新model,通過觀察者模式更新view。
另一個顯而易見的不同在于,MVC是一個圓,一個循環(huán)的過程,但MVP不是,依賴Presenter作為核心,負責從model中拿數(shù)據(jù),填充到view中。常見的MVP的實現(xiàn)是被動視圖(passive view),Presenter觀察model,不再是view觀察model,一旦model發(fā)生變化,就會更新view。Presenter有效地綁定了model到view。view暴露了setters接口以便Presenter可以設置數(shù)據(jù)。對于這種被動視圖的結(jié)構(gòu),沒有直接數(shù)據(jù)綁定的概念。但是他的好處是在view和model直接提供更清晰的分離。但是由于缺乏數(shù)據(jù)綁定支持,意味著不得不單獨關(guān)注某個任務。在MVP里,應用程序的邏輯主要在Presenter來實現(xiàn),其中的view是很薄的一層。
MVVM
MVVM,Model-View-ViewModel,最初是由微軟在使用Windows Presentation Foundation和SilverLight時定義的,2005年John Grossman在一篇關(guān)于Avalon(WPF 的代號)的博客文章中正式宣布了它的存在。如果你用過Visual Studio, 新建一個WPF Application,然后在“設計”中拖進去一個控件、雙擊后在“代碼”中寫事件處理函數(shù)、或者綁定數(shù)據(jù)源。就對這個MVVM有點感覺了。比如VS自動生成的如下代碼:
- <GroupBox Header="綁定對象">
- <StackPanel Orientation="Horizontal" Name="stackPanel1">
- <TextBlock Text="學號:"/>
- <TextBlock Text="{Binding Path=StudentID}"/>
- <TextBlock Text="姓名:"/>
- <TextBlock Text="{Binding Path=Name}"/>
- <TextBlock Text="入學日期:"/>
- <TextBlock Text="{Binding Path=EntryDate, StringFormat=yyyy-MM-dd}"/>
- <TextBlock Text="學分:"/>
- <TextBlock Text="{Binding Path=Credit}"/>
- </StackPanel>
- </GroupBox>
- stackPanel1.DataContext = new Student() {
- StudentID=20130501,
- Name="張三",
- EntryDate=DateTime.Parse("2013-09-01"),
- Credit=0.0
- };
其中最重要的特性之一就是數(shù)據(jù)綁定,Data-binding。沒有前后端分離,一個開發(fā)人員全搞定,一只手抓業(yè)務邏輯、一只手抓數(shù)據(jù)訪問,順帶手拖放幾個UI控件,綁定數(shù)據(jù)源到某個對象或某張表,一步到位。
背景介紹完畢,再來看一下理論圖
MVVM模式
首先,view和model是不知道彼此存在的,同MVP一樣,將view和model清晰地分離開來。 其次,view是對viewmodel的外在顯示,與viewmodel保持同步,viewmodel對象可以看作是view的上下文。view綁定到viewmodel的屬性上,如果viewmodel中的屬性值變化了,這些新值通過數(shù)據(jù)綁定會自動傳遞給view。反過來viewmodel會暴露model中的數(shù)據(jù)和特定狀態(tài)給view。 所以,view不知道m(xù)odel的存在,viewmodel和model也覺察不到view。事實上,model也完全忽略viewmodel和view的存在。這是一個非常松散耦合的設計。
流行的MV*框架:
每個框架都有自己的特性,這里主要討論MVC三個角色的責任。粗淺地過一遍每個框架的代碼結(jié)構(gòu)和風格。
BackboneJS
Backbone通過提供模型Model、集合Collection、視圖View賦予了Web應用程序分層結(jié)構(gòu),其中模型包含領(lǐng)域數(shù)據(jù)和自定義事件;集合Colection是模型的有序或無序集合,帶有豐富的可枚舉API; 視圖可以聲明事件處理函數(shù)。最終將模型、集合、視圖與服務端的RESTful JSON接口連接。
Backbone在升級的過程中,去掉了controller,由view和router代替controller,view集中處理了用戶事件(如click,keypress等)、渲染HTML模板、與模型數(shù)據(jù)的交互。Backbone的model沒有與UI視圖數(shù)據(jù)綁定,而是需要在view中自行操作DOM來更新或讀取UI數(shù)據(jù)。Router為客戶端路由提供了許多方法,并能連接到指定的動作(actions)和事件(events)。
Backbone是一個小巧靈活的庫,只是幫你實現(xiàn)一個MVC模式的框架,更多的還需要自己去實現(xiàn)。適合有一定Web基礎,喜歡原生JS去操作DOM(因為沒有數(shù)據(jù)綁定)的開發(fā)人員。為什么稱它為庫,而不是框架,不僅僅是由于僅4KB的代碼,更重要的是 使用一個庫,你有控制權(quán)。如果用一個框架,控制權(quán)就反轉(zhuǎn)了,變成框架在控制你。庫能夠給予靈活和自由,但是框架強制使用某種方式,減少重復代碼。這便是Backbone與Angular的區(qū)別之一了。
至于Backbone屬于MV*中的哪種模式,有人認為不是MVC,有人覺得更接近于MVP,事實上,它借用多個架構(gòu)模式中一些很好的概念,創(chuàng)建一個運行良好的靈活框架。不必拘泥于某種模式。
- // view:
- var Appview = Backbone.View.extend({
- // 每個view都需要一個指向DOM元素的引用,就像ER中的main屬性。
- el: '#container',
- // view中不包含html標記,有一個鏈接到模板的引用。
- template: _.template("<h3>Hello <%= who %></h3>"),
- // 初始化方法
- initialize: function(){
- this.render();
- },
- // $el是一個已經(jīng)緩存的jQuery對象
- render: function(){
- this.$el.html("Hello World");
- },
- // 事件綁定
- events: {'keypress #new-todo': 'createTodoOnEnter'}
- });
- var appview = new Appview();
- // model:
- // 每個應用程序的核心、包含了交互數(shù)據(jù)和邏輯
- // 如數(shù)據(jù)驗證、getter、setter、默認值、數(shù)據(jù)初始化、數(shù)據(jù)轉(zhuǎn)換
- var app = {};
- app.Todo = Backbone.model.extend({
- defaults: {
- title: '',
- completed: false
- }
- });
- // 創(chuàng)建一個model實例
- var todo = new app.Todo({title: 'Learn Backbone.js', completed: false});
- todo.get('title'); // "Learn Backbone.js"
- todo.get('completed'); // false
- todo.get('created_at'); // undefined
- todo.set('created_at', Date());
- todo.get('created_at'); // "Wed Sep 12 2012 12:51:17 GMT-0400 (EDT)"
- // collection:
- // model的有序集合,可以設置或獲取model
- // 監(jiān)聽集合中的數(shù)據(jù)變化,從后端獲取模型數(shù)據(jù)、持久化。
- app.TodoList = Backbone.Collection.extend({
- model: app.Todo,
- localStorage: new Store("backbone-todo")
- });
- // collection實例
- var todoList = new app.TodoList()
- todoList.create({title: 'Learn Backbone\'s Collection'});
- // model實例
- var model = new app.Todo({title: 'Learn models', completed: true});
- todoList.add(model);
- todoList.pluck('title');
- todoList.pluck('completed');
KnockoutJS
KnockoutJS是一個名正言順的MVVM框架,通過簡潔易讀的data-bind語法,將DOM元素與viewmodel關(guān)聯(lián)起來。當模型(viewmodel)狀態(tài)更新時,自動更新UI界面。 viewmodel是model和view上的操作的一個連接,是一個純粹的Javascript對象。它不是UI,沒有控件和樣式的概念,它也不是持久化的模型數(shù)據(jù),它只是hold住一些用戶正在編輯的數(shù)據(jù),然后暴露出操作這些數(shù)據(jù)(增加或刪除)的方法。
view是對viewmodel中數(shù)據(jù)的一個可視化的顯示,view觀察viewmodel,操作view時會發(fā)送命令到viewmodel,并且當viewmodel變化時更新。view和model是不了解彼此的存在的。
- <form data-bind="submit: addItem">
- New item:
- <input data-bind='value: itemToAdd, valueUpdate: "afterkeydown"' />
- <button type="submit" data-bind="enable: itemToAdd().length > 0">Add</button>
- <p>Your items:</p>
- <select multiple="multiple" width="50" data-bind="options: items"> </select>
- </form>
- // viewmodel
- var SimpleListmodel = function(items) {
- this.items = ko.observableArray(items);
- this.itemToAdd = ko.observable("");
- this.addItem = function() {
- if (this.itemToAdd() != "") {
- // 把input中的值加入到items,會自動更新select控件
- this.items.push(this.itemToAdd());
- // 清空input中的值
- this.itemToAdd("");
- }
- // 確保這里的this一直是viewmodel
- }.bind(this);
- };
- ko.applyBindings(new SimpleListmodel(["Alpha", "Beta", "Gamma"]));
AngularJS
AngularJS試圖成為Web應用中的一種端對端的解決方案。這意味著它不只是你的Web應用中的一個小部分,而是一個完整的端對端的解決方案。這會讓AngularJS在構(gòu)建一個CRUD的應用時看起來很呆板,缺乏靈活性。AngularJS是為了克服HTML在構(gòu)建應用上的不足而設計的。使用了不同的方法,它嘗試去補足HTML本身在構(gòu)建應用方面的缺陷。通過使用標識符(directives)的結(jié)構(gòu),讓瀏覽器能夠識別新的語法。例如使用雙大括號語法進行數(shù)據(jù)綁定;使用ng-controller指定每個控制器負責監(jiān)視視圖中的哪一部分;使用ng-model,把輸入數(shù)據(jù)綁定到模型中的一部分屬性上。
雙向數(shù)據(jù)綁定是AngularJS的另一個特性。UI控件的任何更改會立即反映到模型變量(一個方向),模型變量的任何更改都會立即反映到問候語文本中(另一方向)。AngularJS通過作用域來保持數(shù)據(jù)模型與視圖界面UI的雙向同步。一旦模型狀態(tài)發(fā)生改變,AngularJS會立即刷新反映在視圖界面中,反之亦然。
AngularJS原本是傾向于MVC,但是隨著項目重構(gòu)和版本升級,現(xiàn)在更接近MVVM。和Knockout view中的風格類似,都像從WPF衍變過來的,只是Knockout使用了自定義屬性data-bind作為綁定入口,而AngularJS對于HTML的變革更徹底,擴展HTML的語法,引入一系列的指令。
在AngularJS中,一個視圖是模型通過HTML模板渲染之后的映射。這意味著,不論模型什么時候發(fā)生變化,AngularJS會實時更新結(jié)合點,隨之更新視圖。比如,視圖組件被AngularJS用下面這個模板構(gòu)建出來:
- <body ng-controller="PhoneListCtrl">
- <ul>
- <li ng-repeat="phone in phones">
- {{phone.name}}
- <p>{{phone.snippet}}</p>
- </li>
- </ul>
- </body>
在li標簽里面的ng-repeat語句是一個AngularJS迭代器。包裹在phone.name和phone.snippet周圍的花括號標識著數(shù)據(jù)綁定,是對應用一個數(shù)據(jù)模型的引用。當頁面加載的時候,AngularJS會根據(jù)模版中的屬性值,將其與數(shù)據(jù)模型中相同名字的變量綁定在一起,以確保兩者的同步性。
在PhoneListCtrl控制器里面初始化了數(shù)據(jù)模型:
- // controller:
- function PhoneListCtrl($scope) {
- // 數(shù)組中存儲的對象是手機數(shù)據(jù)列表
- $scope.phones = [
- {"name": "Nexus S",
- "snippet": "Fast just got faster with Nexus S."},
- {"name": "Motorola XOOM™ with Wi-Fi",
- "snippet": "The Next, Next Generation tablet."},
- {"name": "MOTOROLA XOOM™",
- "snippet": "The Next, Next Generation tablet."}
- ];
- }
盡管控制器看起來并沒有什么控制的作用,但是它在這里的重要性在于,通過給定數(shù)據(jù)模型的作用域$scope,允許建立模型和視圖之間的數(shù)據(jù)綁定。方法名PhoneListCtrl和body標簽里面的ngcontroller指令的值相匹配。當應用啟動之后,會有一個根作用域被創(chuàng)建出來,而控制器的作用域是根作用域的一個典型后繼。這個控制器的作用域?qū)λ?/p>
標記內(nèi)部的數(shù)據(jù)綁定有效。
AngularJS的作用域理論非常重要:一個作用域可以視作模板、模型和控制器協(xié)同工作的粘接器。AngularJS使用作用域,同時還有模板中的信息,數(shù)據(jù)模型和控制器。這些可以幫助模型和視圖分離,但是他們兩者確實是同步的!任何對于模型的更改都會即時反映在視圖上;任何在視圖上的更改都會被立刻體現(xiàn)在模型中。
實踐中的思考
我們使用的MVC框架是ER,適用于并能很方便地構(gòu)建一個整站式的AJAX Web應用。提供精簡、核心的action、model和view的抽象,使得構(gòu)建RIA應用變得簡單可行。在使用的過程中近距離地體會到非常多方面的優(yōu)秀的設計理念。也讓我開始思考各個角色的轉(zhuǎn)型。
讓view上前線
我開始思考action(controller)這個角色。我覺得從純粹地解耦角度來說,view和model應該是互相不知道彼此存在的,所有的事件流和對數(shù)據(jù)、UI的處理應該都流經(jīng)action。但是這一點又極不現(xiàn)實。用戶操作了一個UI,需要更新model的一個數(shù)據(jù),就要fire到action,通過action來調(diào)用model的set方法。這樣又有點麻煩,因為view中有對model的應用,可以一句代碼搞定這一個數(shù)據(jù)的設置。所以,我自己設置了一個規(guī)則:如果是簡單的模型數(shù)據(jù)讀寫可以直接在view中操作;如果要經(jīng)過復雜的數(shù)據(jù)處理,必須流經(jīng)action。于是,我遇到了一種怎么都偷不了懶(必須經(jīng)過action)的情況: 比如有個主action main,兩個子action list、select,用戶在list中的view選擇一條數(shù)據(jù)添加到右側(cè)select中。那走過的流程是這樣的:
實踐中的思考
- 子Action中的listView接受UI事件,fire到listAction中
- listAction繼續(xù)將事件fire到mainView中,由主action來處理另外子Action的事情。
- mainView接收到事件、調(diào)用子Action selectAction的方法
- selectAction繼續(xù)調(diào)用selectView的方法來完成UI的更新。
其中涉及的model的變化暫時不考慮。我在想,view既然把經(jīng)典MVC中的controller接受用戶事件的角色承接過來的,那如果借鑒Backbone的思想,把view作為controller的一個實現(xiàn),推到戰(zhàn)場的最前線。省掉兩次action的中轉(zhuǎn)傳遞,是不是更簡單。
model驅(qū)動開發(fā)
實際開發(fā)中,常常會以view為核心,頁面上需要展示什么數(shù)據(jù),就去model中設置數(shù)據(jù)源。發(fā)生了用戶事件,我會在action中更新model,然后刷新view。有時候會遺漏更新model,直到需要數(shù)據(jù)時才發(fā)現(xiàn)沒有保存到model中。
model本身是獨立的,自控制的,不依賴于view,能夠同步支持多view的顯示。就像linux上的應用程序通常會提供圖形界面和命令行兩種操作方式一樣。那如果以model為核心,model驅(qū)動開發(fā),數(shù)據(jù)在手、天下我有,以模型驗證保證數(shù)據(jù)的完整性和正確性。實現(xiàn)數(shù)據(jù)綁定,任何對模型的更改都會在界面上反映出來。那我們只要預先寫好view和model的關(guān)系映射(類似viewmodel),然后只關(guān)注模型數(shù)據(jù),就OK了。