前端架構101(二):MVC初探

NO IMAGE

我把 MVC 框架作為我們理解架構的切入點。雖然它現在已經式弱了,但在我看來它非常重要並且起到了承上啟下的作用:作為經典的解決方案第一次系統的把應用的從複雜的混沌中解救了出來。從這套方法論中我們能學習到很多至今能受用的思路,同時我們也能瞭解到它的不足。

如果從篇幅上看我確實 MVC 裡停留較長的時間,但實際上我是深入某個具體的框架,而是需要藉助它來闡述我們需要解決的問題,引入更多的概念。這些東西在之後的討論中都會使用到。

建議從這個系列的第一篇開始閱讀:

我的第一個單頁面應用

我在剛入這行的時候加入的是一家創業公司,它們的產品是 iPad 上給兒童閱讀的電子互動圖書。而我的工作職責就是負責一款在線單頁面應用編輯工具的前端部分,以供公司內的編輯同事製作這些電子圖書。編輯同事可以在這個工具內導入素材,比如音頻、視頻、或者圖片。然後擺放這些素材的位置,給它們設置動畫效果等等。它類似於一個可以製作動畫的 Photoshop,或者平面版本的 Unity3D 編輯器。當然用這兩個比喻實在是太抬舉它了,但是我想你們大概能想象出它的功能和樣子。

我們就從這個應用開始

它的界面類似於 Photohop 的工作區

前端架構101(二):MVC初探

如上圖所示,每當你選中一個素材,右側的不同屬性欄中就會呈現這個素材不同維度的狀態,例如它的尺寸,色彩狀況,操作歷史,圖層狀態等等。我們不如先把不同的視圖區域標註一下:

前端架構101(二):MVC初探

那麼對於“每選擇一個素材是就展示它的相關這個屬性”這個需求,我想當然的第一版(偽)代碼是這麼寫的:

canvasView.onSelectElement(image => {
const {size: { width, height }, color, history, layer } = getComputedStyle(image);
infoView.updateHeight(height);
infoView.updateWidth(width);
colorView.updateColor(color);
historyView.updateHistory(history);
layerView.updateHistory(layer)
})

如果只有這一個需求不會有大問題,現在我們提出另一個需求:當刪除選中的元素時把各個屬性欄的數值清空:

canvasView.onDeleteSelectedElement(image => {
infoView.updateHeight(0);
infoView.updateWidth(0);
colorView.updateColor(null)
historyView.updateHistory(null)
layerView.updateHistory(null)
})

以及我們可以從 history 中選擇回滾到某個版本:

historyView.rollbackTo((historyInfo) => {
const {size: { width, height }, color, history, layer } = historyInfo;
infoView.updateHeight(height);
infoView.updateWidth(width);
colorView.updateColor(color);
historyView.updateHistory(history);
layerView.updateHistory(layer)
})

我的第一版代碼真的是這麼寫的,想必此時你也應該感受到我的痛苦了:我必須手動的更新每一個視圖狀態信息。這會導致更嚴重的後果:

  • 如果需要額外的添加一個視圖,比如用戶展示文件信息的 FileView。那麼在上面三段代碼中我都要手動添加一行類似的用戶更新 FileView 信息的代碼。但實際代碼中不可能只存在三段代碼,所以我有可能會遺漏這樣的修改

  • 如果我需要頁面不止有一個 InfoView 怎麼辦?例如 InfoView01 用於展示公制單位,InfoView02 用於展示英制單位。也就是說如果頁面上有多出需要展示同一份信息的時候,我的代碼需要複製 N 遍:

    infoView01.updateHeight(height);
    infoView01.updateWidth(width);
    infoView02.updateHeight(height);
    infoView02.updateWidth(width);
    

你可以想象一下五個 view 之間的調用關係,如果每一個視圖都會和其它四個視圖直接溝通的話,它們之間的關係會變的如下圖所示。再多加入一個 view 將會是一個災難

前端架構101(二):MVC初探

抽象問題

如果把上面的問題抽象一下的話其實非常簡單:把同一份數據便捷同步展示在多個消費者中。

  • 便捷

就功能而言,上面的代碼已經實現了,只不過維護起來非常的困難。所以我們解決的問題還是回到這個系列第一篇所說的,解決非功能需求。針對上面的例子,我們希望添加 View 的成本降到最低。“便捷”這個詞或許不太恰當,但我也找不到更好的詞,它能形容的是我們開發者能夠把維護代碼的成本降到最低。

  • 同步

同步不僅僅是同步的“讀取”數據,還包括“回寫”。假設一份數據被三個視圖所用,如果其中一個視圖對數據發生了修改,那麼修改應該應該也同時反饋到另外兩個視圖上。

在上面的代碼中我沒有明確的一個問題,關於這份數據我們應該存一份,還是存在 N 個視圖中都保存一份副本。這個我們會在之後討論

  • 多個消費者

數據的消費方不一定是視圖,還有可能是 selector。它不一定被展示,還有可能被用於計算。

MVC 來拯救

MVC 在不同的上下文中的架構都不一樣。從最早的 Smalltalk 裡的 MVC,到 .NET 的 MVC,再到 JavaScript 中的 MVC 框架都不盡相同。但我們通常談論 MVC 是泛指的是服務端架構中的 MVC。以 .NET 的 MVC 為例,我們可以在 ASP.NET MVC 文檔 中找到關於 MVC 中三個角色的定義。簡單來說一個服務端 MVC 的應用流程如下:用戶通過 URL 訪問應用,controller 負責響應用戶的請求,獲取數據模型;model 封裝了業務邏輯,並負責數據進行更改;最後數據渲染在頁面模板上之後將頁面返回給用戶

前端架構101(二):MVC初探

例如 Node.js 的 MVC 框架 kraken.js 的 controller 語法如下:

'use strict';
var IndexModel = require('../models/index');
module.exports = function (router) {
var model = new IndexModel();
router.get('/', function (req, res) {
res.render('index', model);
});
};

相信你也發現了 MVC 其實是更適用於多頁面應用,所以它與前端這種單頁面應用場景並非天生契合。前端的 MVC 架構與後端有很大的不同。

但即使是在後端的 MVC 架構中,我們看到了一種思路,就是職責分離。在我們上面的代碼中,所謂的 handler,比如 canvasView.onSelectElement 幾乎包辦了所有的事情:負責響應用戶的請求,負責更新數據,還負責更新視圖。職責分離究竟給我們帶來了什麼,我們會在後面討論

Backbone.js

Backbone 解決的方法很簡單:通過事件——當數據狀態發生改變需要被同步時,它不是依次去調用消費方的接口,而是向外廣播一個事件。任何需要消費這份數據的地方只需要監聽數據的相關事件即可。

我們以一個開源在線 todo 應用為例:

前端架構101(二):MVC初探

它關於處理添加 todo 的代碼是這樣的,首先綁定輸入框的事件處理函數

events: {
'keypress .new-todo': 'createOnEnter',
},
createOnEnter: function (e) {
if (e.which === ENTER_KEY && this.$input.val().trim()) {
app.todos.create(this.newAttributes());
this.$input.val('');
}
},

當用戶敲擊回車之後往數據模型中添加一條數據,注意這裡只是修改數據,並不負責更新視圖。

接著在 app 組件內監聽數據模型的“添加”事件,並且添加回調函數addOne

initialize: function () {
this.listenTo(app.todos, 'add', this.addOne);
},

addOne 的函數實現是這樣的

// Add a single todo item to the list by creating a view for it, and
// appending its element to the `<ul>`.
addOne: function (todo) {
var view = new app.TodoView({ model: todo });
this.$list.append(view.render().el);
},

這裡才是真正更新視圖的地方。

上面代碼的流程圖如下:

前端架構101(二):MVC初探

在有的架構中 DOM 和 View 是分開被定義,有的還有稱為 template 的概念。但我們可以再進行一層抽象,相對於 Model 而言,也可以把它們都視為 View,

看似代碼變得冗餘了,一份代碼被拆分成了三份。但實際上我們不用再為添加額外的視圖後,忘記添加某個調用而感到苦惱了。當添加多個視圖之後

前端架構101(二):MVC初探

AngularJS

AngularJS 指的是 Angular 1.x 的版本,Angular 2.x 之後通常會直接稱之為 Angular,這兩個版本的架構和設計思路不同

AngularJS 不是通過事件來解決這個問題,而是通過全局變量和依賴注入解決

首先定義一個全局變量用於存儲 todos 以及相關的操作方法

angular.module('todomvc')
.factory('localStorage', function ($q) {
'use strict';
var STORAGE_ID = 'todos-angularjs';
var store = {
todos: [],
get: function () {
},
insert: function (todo) {
}
}
return store
}

這個 store 會被注入到其它的 controller。比如在 todoCtrl.js 中,我們可以直接訪問 store 對它進行修改,來實現添加 todo 的功能:

angular.module('todomvc')
.controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) {
var todos = $scope.todos = store.todos;
// ...
$scope.addTodo = function () {
var newTodo = {
title: $scope.newTodo.trim(),
completed: false
};
if (!newTodo.title) {
return;
}
$scope.saving = true;
store.insert(newTodo)
.then(function success() {
$scope.newTodo = '';
})
.finally(function () {
$scope.saving = false;
});
};
}

那麼在頁面上,我們首先完成對添加事件的綁定,給輸入框的 submit 事件綁定上面的 addTodo 處理函數

<form class="todo-form" ng-submit="addTodo()">
<input class="new-todo" placeholder="What needs to be done?" ng-model="newTodo" ng-disabled="saving" autofocus>
</form>

又同時再將(通過雙向綁定的方式) todo 列表渲染出來:

<ul class="todo-list">
<li ng-repeat="todo in todos | filter:statusFilter track by $index" >
</li>
</ul>

那麼如果有其他的視圖想使用這份 todo 的話,只需要在這個視圖對應的 controller 裡訪問全局的 store 即可

angular.module('todomvc')
.controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) {
var todos = $scope.todos = store.todos;

流程圖如下圖所示

前端架構101(二):MVC初探

總結

我們暫時告一段落。在這一篇裡我引入了一個經典的 SPA 中需要解決的問題,並且介紹了兩個 MVC 框架解決這個問題的不同方式。關於這個問題,關於這兩個解決方案更進一步的思考會在下一篇裡繼續

需要注意的,在上述的內容中我只是觸及了兩個 MVC 框架的冰山一角,千萬不要把它們當作框架的全部。例如以 Backbone.js 為例。整個框架結構應該是如下所示的:

前端架構101(二):MVC初探

而我們只是觸及了上圖中右下角的部分而已。如果你想要完全的瞭解它們還需要更徹底的學習

最後我想留給你們的問題是:到現在看來 MVC 是一個不錯的解決方案,為什麼現在我們不再使用它們了。它們是否會給我們的項目埋下隱患?

本文也同時發佈在我的知乎專欄,歡迎關注

相關文章

三分鐘從零單排js靜態檢查

前端架構101(五):從Flux進化到ModelViewPresenter

前端架構101(四):MVC的不足與Flux的崛起

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信