MVVM 是 Web 前端一種很是流行的開發模式,利用 MVVM 可使咱們的代碼更專一於處理業務邏輯而不是去關心 DOM 操做。目前著名的 MVVM 框架有 vue, avalon , angular 等,這些框架各有千秋,可是實現的思想大體上是相同的:數據綁定 + 視圖刷新。出於好奇和一顆願意折騰的心,我本身也沿着這個方向寫了一個最簡單的 MVVM 庫 ( mvvm.js ),總共 2000 多行代碼,指令的命名和用法與 vue 類似,在這裏分享一下實現的原理以及個人代碼組織思路。javascript
MVVM 在概念上是真正將視圖與數據邏輯分離的模式,ViewModel 是整個模式的重點。要實現 ViewModel 就須要將數據模型(Model)和視圖(View)關聯起來,整個實現思路能夠簡單的總結成 5 點:html
實現一個 Compiler 對元素的每一個節點進行指令的掃描和提取;前端
實現一個 Parser 去解析元素上的指令,可以把指令的意圖經過某個刷新函數更新到 dom 上(中間可能須要一個專門負責視圖刷新的模塊)好比解析節點 <p v-show="isShow"></p>
時先取得 Model 中 isShow 的值,再根據 isShow 更改 node.style.display
從而控制元素的顯示和隱藏;vue
實現一個 Watcher 能將 Parser 中每條指令的刷新函數和對應 Model 的字段聯繫起來;java
實現一個 Observer 使得可以對對象的全部字段進行值的變化監測,一旦發生變化時能夠拿到最新的值並觸發通知回調;node
利用 Observer 在 Watcher 中創建一個對 Model 的監聽 ,當 Model 中的一個值發生變化時,監聽被觸發,Watcher 拿到新值後調用在步驟 2 中關聯的那個刷新函數,就能夠實現數據變化的同時刷新視圖的目的。git
首先粗看下最終的使用示例,與其餘 MVVM 框架的實例化大同小異:github
<div id="mobile-list"> <h1 v-text="title"></h1> <ul> <li v-for="item in brands"> <b v-text="item.name"></b> <span v-show="showRank">Rank: {{item.rank}}</span> </li> </ul> </div>
var element = document.querySelector('#mobile-list'); var vm = new MVVM(element, { 'title' : 'Mobile List', 'showRank': true, 'brands' : [ {'name': 'Apple', 'rank': 1}, {'name': 'Galaxy', 'rank': 2}, {'name': 'OPPO', 'rank': 3} ] }); vm.set('title', 'Top 3 Mobile Rank List'); // => <h1>Top 3 Mobile Rank List</h1>
我把 MVVM 分紅了五個模塊去實現: 編譯模塊 Compiler 、解析模塊 Parser 、視圖刷新模塊 Updater 、數據訂閱模塊 Watcher 和 數據監聽模塊 Observer 。流程能夠簡述爲:Compiler 編譯好指令後將指令信息交給解析器 Parser 解析,Parser 更新初始值並向 Watcher 訂閱數據的變化,Observer 監測到數據的變化而後反饋給 Watcher ,Watcher 再將變化結果通知 Updater 找到對應的刷新函數進行視圖的刷新。數組
上述流程如圖所示:瀏覽器
下文就介紹下這五個模塊實現的基本原理(代碼只貼重點部分,完整的實現請到個人 Github 翻閱)
Compiler 的職責主要是對元素的每一個節點進行指令的掃描和提取。由於編譯和解析的過程會屢次遍歷整個節點樹,因此爲了提升編譯效率在 MVVM 構造函數內部先將 element
轉成一個文檔碎片形式的副本 fragment
編譯對象是這個文檔碎片而不該該是目標元素,待所有節點編譯完成後再將文檔碎片添加回到原來的真實節點中。
vm.complieElement
實現了對元素全部節點的掃描和指令提取:
vm.complieElement = function(fragment, root) { var node, childNodes = fragment.childNodes; // 掃描子節點 for (var i = 0; i < childNodes.length; i++) { node = childNodes[i]; if (this.hasDirective(node)) { this.$unCompileNodes.push(node); } // 遞歸掃描子節點的子節點 if (node.childNodes.length) { this.complieElement(node, false); } } // 掃描完成,編譯全部含有指令的節點 if (root) { this.compileAllNodes(); } }
vm.compileAllNodes
方法將會對 this.$unCompileNodes
中的每一個節點進行編譯(將指令信息交給 Parser ),編譯完一個節點後就從緩存隊列中移除它,同時檢查 this.$unCompileNodes.length
當 length === 0 時說明所有編譯完成,能夠將文檔碎片追加到真實節點上了。
當編譯器 Compiler 把每一個節點的指令提取出來後就能夠給到解析器解析了。每個指令都有不一樣的解析方法,全部指令的解析方法只要作好兩件事:一是將數據值更新到視圖上(初始狀態),二是將刷新函數訂閱到 Model 的變化監測中。這裏以解析 v-text
爲例描述一個指令的大體解析方法:
parser.parseVText = function(node, model) { // 取得 Model 中定義的初始值 var text = this.$model[model]; // 更新節點的文本 node.textContent = text; // 對應的刷新函數: // updater.updateNodeTextContent(node, text); // 在 watcher 中訂閱 model 的變化 watcher.watch(model, function(last, old) { node.textContent = last; // updater.updateNodeTextContent(node, text); }); }
上個例子,Watcher 提供了一個 watch
方法來對數據變化進行訂閱,一個參數是模型字段 model 另外一個是回調函數,回調函數是要經過 Observer 來觸發的,參數傳入新值 last 和 舊值 old , Watcher 拿到新值後就能夠找到 model 對應的回調(刷新函數)進行更新視圖了。model 和 刷新函數是一對多的關係,即一個 model 能夠有任意多個處理它的回調函數(刷新函數),好比:v-text="title"
和 v-html="title"
兩個指令共用一個數據模型字段。
添加數據訂閱 watcher.watch
實現方式爲:
watcher.watch = function(field, callback, context) { var callbacks = this.$watchCallbacks; if (!Object.hasOwnProperty.call(this.$model, field)) { console.warn('The field: ' + field + ' does not exist in model!'); return; } // 創建緩存回調函數的數組 if (!callbacks[field]) { callbacks[field] = []; } // 緩存回調函數 callbacks[field].push([callback, context]); }
當數據模型的 field 字段發生改變時,Watcher 就會觸發緩存數組中訂閱了 field 的全部回調。
Observer 是整個 mvvm 實現的核心基礎,看過有一篇文章說 O.o (Object.observe) 將會引爆數據綁定革命,給前端帶來巨大影響力,不過很惋惜,ES7 草案已經將 O.o 給廢棄了!目前也沒有瀏覽器支持!所幸的是還有 Object.defineProperty
經過攔截對象屬性的存取描述符(get 和 set) 能夠模擬一個簡單的 Observer :
// 攔截 object 的 prop 屬性的 get 和 set 方法 Object.defineProperty(object, prop, { get: function() { return this.getValue(object, prop); }, set: function(newValue) { var oldValue = this.getValue(object, prop); if (newValue !== oldValue) { this.setValue(object, newValue, prop); // 觸發變化回調 this.triggerChange(prop, newValue, oldValue); } } });
而後還有個問題就是數組操做 ( push, shift 等) 該如何監測?全部的 MVVM 框架都是經過重寫該數組的原型來實現的:
observer.rewriteArrayMethods = function(array) { var self = this; var arrayProto = Array.prototype; var arrayMethods = Object.create(arrayProto); var methods = 'push|pop|shift|unshift|splice|sort|reverse'.split('|'); methods.forEach(function(method) { Object.defineProperty(arrayMethods, method, function() { var i = arguments.length; var original = arrayProto[method]; var args = new Array(i); while (i--) { args[i] = arguments[i]; } var result = original.apply(this, args); // 觸發回調 self.triggerChange(this, method); return result; }); }); array.__proto__ = arrayMethods; }
這個實現方式是從 vue 中參考來的,以爲用的很妙,不過數組的 length 屬性是不可以被監聽到的,因此在 MVVM 中應避免操做 array.length
Updater 在五個模塊中是最簡單的,只須要負責每一個指令對應的刷新函數便可。其餘四個模塊通過一系列的折騰,把最後的成果交給到 Updater 進行視圖或者事件的更新,好比 v-text
的刷新函數爲:
updater.updateNodeTextContent = function(node, text) { node.textContent = text; }
v-bind:style
的刷新函數:
updater.updateNodeStyle = function(node, propperty, value) { node.style[propperty] = value; }
表單元素的雙向數據綁定是 MVVM 的一個最大特色之一:
其實這個神奇的功能實現原理也很簡單,要作的只有兩件事:一是數據變化的時候更新表單值,二是反過來表單值變化的時候更新數據,這樣數據的值就和表單的值綁在了一塊兒。
數據變化更新表單值 利用前面說的 Watcher 模塊很容易就能夠作到:
watcher.watch(model, function(last, old) { input.value = last; });
表單變化更新數據 只須要實時監聽表單的值得變化事件並更新數據模型對應字段便可:
var model = this.$model; input.addEventListenr('change', function() { model[field] = this.value; });
其餘表單 radio, checkbox 和 select 都是同樣的原理。
以上,整個流程以及每一個模塊的基本實現思路都講完了,語言表達能力不太好,若有說的不對寫的很差的地方,但願你們可以批評指正!
折騰這個簡單的 mvvm.js 是由於原來本身的框架項目中用的是 vue.js 可是隻是用到了它的指令系統,一大堆功能只用到四分之一左右,就想着只是實現 data-binding 和 view-refresh 就夠了,結果沒找這樣的 javascript 庫,因此我本身就造了這麼一個輪子。
雖然說功能和穩定性遠不如 vue 等流行 MVVM 框架,代碼實現可能也比較粗糙,可是經過造這個輪子仍是增加了不少知識的 ~ 進步在於折騰嘛!
目前個人 mvvm.js 只是實現了最本的功能,之後我會繼續完善、健壯它,若有興趣歡迎一塊兒探討和改進~
源代碼傳送門: https://github.com/tangbc/sugar