30行程式碼實現Javascript中的MVC
從09年左右開始,MVC逐漸在前端領域大放異彩,並終於在剛剛過去的2015年隨著React Native的推出而迎來大爆發:AngularJS、EmberJS、Backbone、ReactJS、RiotJS、VueJS…… 一連串的名字走馬觀花式的出現和更迭,它們中一些已經漸漸淡出了大家的視野,一些還在迅速茁壯成長,一些則已經在特定的生態環境中獨當一面捨我其誰。但不論如何,MVC已經並將持續深刻地影響前端工程師們的思維方式和工作方法。
很多講解MVC的例子都從一個具體的框架的某個概念入手,比如Backbone的collection或AngularJS中model,這當然不失為一個好辦法。但框架之所以是框架,而不是類庫(jQuery)或者工具集(Underscore),就是因為它們的背後有著眾多優秀的設計理念和最佳實踐,這些設計精髓相輔相成,環環相扣,缺一不可,要想在短時間內透過複雜的框架而看到某一種設計模式的本質並非是一件容易的事。
這便是這篇隨筆的由來——為了幫助大家理解概念而生的原型程式碼,應該越簡單越好,簡單到剛剛足以大家理解這個概念就夠了。
1. MVC的基礎是觀察者模式,這是實現model和view同步的關鍵
為了簡單起見,每個model例項中只包含一個primitive value值。
functionModel(value) { this._value =typeof value === 'undefined' ? '' : value; this._listeners = []; } Model.prototype.set =function (value) { varself = this; self._value = value; // model中的值改變時,應通知註冊過的回撥函式 // 按照Javascript事件處理的一般機制,我們非同步地呼叫回撥函式 // 如果覺得setTimeout影響效能,也可以採用requestAnimationFrame setTimeout(function() { self._listeners.forEach(function(listener) { listener.call(self, value); }); }); }; Model.prototype.watch =function (listener) { // 註冊監聽的回撥函式 this._listeners.push(listener); };
1 2 3 4 5 6 7 8 9 10 11 |
// html程式碼:
<div id=
"div1"
></div>
// 邏輯程式碼:
(
function
() {
var
model =
new
Model();
var
div1 = document.getElementById(
'div1'
);
model.watch(
function
(value) {
div1.innerHTML = value;
});
model.set(
'hello, this is a div'
);
})();
|
藉助觀察者模式,我們已經實現了在呼叫model的set方法改變其值的時候,模板也同步更新,但這樣的實現卻很彆扭,因為我們需要手動監聽model值的改變(通過watch方法)並傳入一個回撥函式,有沒有辦法讓view(一個或多個dom node)和model更簡單的繫結呢?
2. 實現bind方法,繫結model和view
1 2 3 4 5 6 |
Model.prototype.bind =
function
(node) {
// 將watch的邏輯和通用的回撥函式放到這裡
this
.watch(
function
(value) {
node.innerHTML = value;
});
};
|
1 2 3 4 5 6 7 8 9 10 |
// html程式碼:
<div id=
"div1"
></div>
<div id=
"div2"
></div>
// 邏輯程式碼:
(
function
() {
var
model =
new
Model();
model.bind(document.getElementById(
'div1'
));
model.bind(document.getElementById(
'div2'
));
model.set(
'this is a div'
);
})();
|
通過一個簡單的封裝,view和model之間的繫結已經初見雛形,即使需要在一個model上繫結多個view,實現起來也很輕鬆。注意bind是Function類prototype上的一個原生方法,不過它和MVC的關係並不緊密,筆者又實在太喜歡bind這個單詞,一語中的,言簡意賅,所以索性在這裡把原生方法覆蓋了,大家可以忽略。言歸正傳,雖然繫結的複雜度降低了,這一步依然要依賴我們手動完成,有沒有可能把繫結的邏輯從業務程式碼中徹底解耦呢?
3. 實現controller,將繫結從邏輯程式碼中解耦
細心的朋友可能已經注意到,雖然講的是MVC,但是上文中卻只出現了Model類,View類不出現可以理解,畢竟HTML就是現成的View(事實上本文中從始至終也只是利用HTML作為View,javascript程式碼中並沒有出現過View類),那Controller類為何也隱身了呢?別急,其實所謂的"邏輯程式碼"就是一個框架邏輯(姑且將本文的原型玩具稱之為框架)和業務邏輯耦合度很高的程式碼段,現在我們就來將它分解一下。
如果要將繫結的邏輯交給框架完成,那麼就需要告訴框架如何來完成繫結。由於JS中較難完成annotation(註解),我們可以在view中做這層標記——使用html的標籤屬性就是一個簡單有效的辦法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function
Controller(callback) {
var
models = {};
// 找到所有有bind屬性的元素
var
views = document.querySelectorAll(
'[bind]'
);
// 將views處理為普通陣列
views = Array.prototype.slice.call(views, 0);
views.forEach(
function
(view) {
var
modelName = view.getAttribute(
'bind'
);
// 取出或新建該元素所繫結的model
models[modelName] = models[modelName] ||
new
Model();
// 完成該元素和指定model的繫結
models[modelName].bind(view);
});
// 呼叫controller的具體邏輯,將models傳入,方便業務處理
callback.call(
this
, models);
}
|
1 2 3 4 5 6 7 8 |
// html:
<div id=
"div1"
bind=
"model1"
></div>
<div id=
"div2"
bind=
"model1"
></div>
// 邏輯程式碼:
new
Controller(
function
(models) {
var
model1 = models.model1;
model1.set(
'this is a div'
);
});
|
就這麼簡單嗎?就這麼簡單。MVC的本質就是在controller中完成業務邏輯,並對model進行修改,同時model的改變引起view的自動更新,這些邏輯在上面的程式碼中都有所體現,並且支援多個view、多個model。雖然不足以用於生產專案,但是希望對大家的MVC學習多少有些幫助。
整理後去掉註釋的"框架"程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
function
Model(value) {
this
._value =
typeof
value ===
'undefined'
?
''
: value;
this
._listeners = [];
}
Model.prototype.set =
function
(value) {
var
self =
this
;
self._value = value;
setTimeout(
function
() {
self._listeners.forEach(
function
(listener) {
listener.call(self, value);
});
});
};
Model.prototype.watch =
function
(listener) {
this
._listeners.push(listener);
};
Model.prototype.bind =
function
(node) {
this
.watch(
function
(value) {
node.innerHTML = value;
});
};
function
Controller(callback) {
var
models = {};
var
views = Array.prototype.slice.call(document.querySelectorAll(
'[bind]'
), 0);
views.forEach(
function
(view) {
var
modelName = view.getAttribute(
'bind'
);
(models[modelName] = models[modelName] ||
new
Model()).bind(view);
});
callback.call(
this
, models);
}
|
4. 一個簡單的例子
下面請大家看一個簡單例子,如何實現電子錶
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// html:
<span bind=
"hour"
></span> : <span bind=
"minute"
></span> : <span bind=
"second"
></span>
// controller:
new
Controller(
function
(models) {
function
setTime() {
var
date =
new
Date();
models.hour.set(date.getHours());
models.minute.set(date.getMinutes());
models.second.set(date.getSeconds());
}
setTime();
setInterval(setTime, 1000);
});
|
可以看出,controller中只負責更新model的邏輯,和view完全解耦;而view和model的繫結是通過view中的屬性和框架中controller的初始化程式碼完成的,也沒有出現在業務邏輯中;至於view的更新,也是通過框架中的觀察者模式實現的。
後記:
筆者在學習flux和redux的過程中,雖然掌握了工具的使用方法,但只是知其然而不知其所以然,對ReactJS官方文件中一直強調的 "Flux eschews MVC in favor of a unidirectional data flow" 不甚理解,始終覺得單向資料流和MVC並不衝突,不明白為什麼在ReactJS的文件中這二者會被對立起來,有他無我,有我無他(eschew,避開)。終於下定決心,回到MVC的定義上重新研究,雖然平日工作裡大大咧咧複製貼上,但是咱們偶爾也得任性一把,咬文嚼字一番,對吧?這樣的方式也的確幫助了我對於這句話的理解,這裡可以把自己的思考分享給大家:之所以覺得MVC和flux中的單向資料流相似,可能是因為沒有區分清楚MVC和觀察者模式的關係造成的——MVC是基於觀察者模式的,flux也是,因此這種相似性的由來是觀察者模式,而不是MVC和flux本身。這樣的理解也在四人組的設計模式原著中得到了印證:"The first and perhaps best-known example of the Observer pattern appears in Smalltalk Model/View/Controller (MVC), the user interface framework in the Smalltalk environment [KP88]. MVC's Model class plays the role of Subject, while View is the base class for observers. "。
如果讀者有興趣在這樣一個原型玩具的基礎上繼續拓展,可以參考下面的一些方向:
1. 實現對input類標籤的雙向繫結
2. 實現對controller所控制的scope的精準控制,這裡一個controller就控制了整個dom樹
3. 實現view層有關dom node隱藏/顯示、建立/銷燬的邏輯
4. 整合virtual dom,增加dom diff的功能,提高渲染效率
5. 提供依賴注入功能,實現控制反轉
6. 對innerHTML的賦值內容進行安全檢查,防止惡意注入
7. 實現model collection的邏輯,這裡每個model只有一個值
8. 利用es5中的setter改變set方法的實現,使得對model的修改更加簡單
9. 在view層中增加對屬性和css的控制
10.支援類似AngularJS中雙大括號的語法,只繫結部分html
……
一個完善的框架要經過無數的提煉和修改,這裡只是最初最初的第一步,不過別忘了,我們的征程是星辰大海,哈哈
作者:ralph_zhu
時間:2016-02-15 14:30