230行實現一個簡單的MVVM


作者:mirone
鏈接:https://zhuanlan.zhihu.com/p/24451202
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

MVVM這兩年在前端屆掀起了一股熱潮,火熱的Vue和Angular帶給了開發者無數的便利,本文將實現一個簡單的MVVM,用200多行代碼探索MVVM的秘密。您可以先點擊本文的JS Bin查看效果,代碼使用ES6,所以你可能需要轉碼。

什么是MVVM?

MVVM是一種程序架構設計。把它拆開來看應該是Model-View-ViewModel

Model

Model指的是數據層,是純凈的數據。對於前端來說,它往往是一個簡單的對象。例如:

{ name: 'mirone', age: 20, friends: ['singleDogA', 'singleDogB'], details: { type: 'notSingleDog', tags: ['fff', 'sox'] } } 

數據層是我們需要渲染后呈現給用戶的數據,數據層本身是可變的。數據層不應該承擔邏輯操作和計算的功能。

View

View指視圖層,是直接呈現給用戶的部分,簡單的來說,對於前端就是HTML。例如上面的數據層,它對應的視圖層可能是:

<div> <p> <b>name: </b> <span>mirone</span> </p> <p> <b>age: </b> <span>20</span> </p> <ul> <li>singleDogA</li> <li>singleDogB</li> </ul> <div> <p>notSingleDog</p> <ul> <li>fff</li> <li>sox</li> </ul> </div> </div> 

當然視圖層是可變的,你完全可以在其中隨意添加元素。這不會改變數據層,只會改變視圖層呈現數據的方式。視圖層應該和數據層完全分離。

ViewModel

既然視圖層應該和數據層分離,那么我們就需要設計一種結構,讓它們建立起某種聯系。當我們對Model進行修改的時候,ViewModel就會把修改自動同步到View層去。同樣當我們修改View,Model同樣被ViewModel自動修改。

可以看出,如何設計能夠高效自動同步View與Model的ViewModel是整個MVVM框架的核心和難點。

MVVM的原理

差異

不同的框架對於MVVM的實現是不同的。

數據劫持

Vue的實現方式,對數據(Model)進行劫持,當數據發生變動時,數據會觸發劫持時綁定的方法,對視圖進行更新。

臟檢查機制

Angular的實現方式,當發生了某種事件(例如輸入),Angular會檢查新的數據結構和之前的數據結構是否發生了變動,來決定是否更新視圖。

發布訂閱模式

Knockout的實現方式,實現了一個發布訂閱器,解析時會在對應視圖節點綁定訂閱器,而在數據上綁定發布器,當修改數據時,就出發了發布器,視圖收到后進行對應更新。

相同點

但是還是有很多相同點的,它們都有三個步驟:

  • 解析模版

  • 解析數據

  • 綁定模版與數據

解析模版

何謂模版?我們可以看一下主流MVVM的模版:

<!-- Vue --> <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> <!-- Angular --> <ul> <li ng-repeat="phone in phones"> {{phone.name}} <p>{{phone.snippet}}</p> </li> </ul> <!-- Knockout --> <tbody data-bind="foreach: seats"> <tr> <td data-bind="text: name"></td> <td data-bind="text: meal().mealName"></td> <td data-bind="text: meal().price"></td> </tr> </tbody> 

可以看到它們都定義了自己的模版關鍵字,這一模塊的作用就是根據這些關鍵字解析模版,將模版對應到期望的數據結構。

解析數據

Model中的數據經過劫持或綁定發布器來解析。數據解析器的編寫要考慮VM的實現方式,但是無論如何解析數據只要做好一件事:定義數據變動時要通知的對象。解析數據時應保證數據解析后的一致性,對於每種數據解析后暴露的接口應該保持一致。

綁定模版與數據

這一部分定義了數據結構以何種方式和模版進行綁定,就是傳說中的“雙向綁定”。綁定之后我們直接對數據進行操作時,應用就能自動更新視圖了。數據和模版往往是多對多的關系,而且不同的模版更新數據的方式往往不同。例如有的是改變標簽的文本節點,有的是改變標簽的className。

動手實現MVVM

經過一番分析,來動手實現MVVM吧。

期望效果

對於我的MVVM,我希望對應一個數據結構:

let data = { title: 'todo list', user: 'mirone', todos: [ { creator: 'mirone', content: 'write mvvm' done: 'undone', date: '2016-11-17', members: [ { name: 'kaito' } ] } ] } 

我可以對應的編寫模版:

<div id="root"> <h1 data-model="title"></h1> <div> <div data-model="user"></div> <ul data-list="todos"> <li data-list-item="todos"> <p data-class="todos:done" data-model="todos:creator"></p> <p data-model="todos:date"></p> <p data-model="todos:content"></p> <ul data-list="todos:members"> <li data-list-item="todos:members"> <span data-model="todos:members:name"></span> </li> </ul> </li> </ul> </div> </div> 

然后通過調用:

new Parser('#root', data) 

就可以完成mvvm的綁定,之后可以直接操作data對象來對View進行更改。

解析模版

模版的解析其實是一個樹的遍歷過程。

遍歷

眾所周知,DOM是一個樹狀結構,這也是為什么它被稱為“DOM樹”。對於樹的遍歷,只要遞歸,便能很輕松的完成一個深度優先遍歷,請看代碼:

function scan(node) { console.log(node) for(let i = 0; i < node.children.length; i++) { const _thisNode = node.children[i] console.log(_thisNode) if(_thisNode.children.length) { scan(_thisNode) } } } 

這個函數遍歷了一個DOM節點,依次打印遍歷得到的節點。

遍歷不同結構

知道了如何遍歷一個DOM樹,那么我們如何獲取需要分析的DOM樹?根據之前的構想,我們需要這么幾種標識:

  • data-model——用於將DOM的文本節點替換為制定內容

  • data-class——用於將 DOM的className替換為制定內容

  • data-list——用於標識接下來將出現一個列表,列表為制定結構

  • data-list-item——用於標識列表項的內部結構

  • data-event——用於為DOM節點綁定指定事件

簡單的歸類一下:data-model、data-class和data-event應該是一類,它們都只影響當前節點;而data-list和data-item作為列表應該要單獨考慮。那么我們可以這樣遍歷:

 function scan(node) { if(!node.getAttribute('data-list')) { for(let i = 0; i < node.children.length; i++) { const _thisNode = node.children[i] parseModel(node) parseClass(node) parseEvent(node) if(_thisNode.children.length) { scan(_thisNode) } } } else { parseList(node) } } function parseModel(node) { //TODO:解析Model節點 } function parseClass(node) { //TODO:解析className } function parseEvent(node) { //TODO:解析事件 } function parseList(node) { //TODO: 解析列表 } 

這樣我們就搭好了遍歷器的大概框架

不同結構的處理方法

parseModel,parseClass和parseEvent的處理方式比較相似,唯一值得注意的就是對於嵌套元素的處理,回憶一下我們的模版設計:

<!--遇到嵌套部分--> <div data-model="todos:date"></div> 

這里的todos:date其實大大方便了我們解析模版,因為它展示了當前數據在Model結構中的位置。

//event要有一個eventList,大概結構為: const eventList = { typeWriter: { type: 'input', //事件的種類 fn: function() { //事件的處理函數,函數的this代表函數綁定的DOM節點 } } } function parseEvent(node) { if(node.getAttribute('data-event')) { const eventName = node.getAttribute('data-event') node.addEventListener(eventList[eventName].type, eventList[eventName].fn.bind(node)) } } //根據在模版中的位置解析模版,這里的Path是一個數組,代表了當前數據在Model中的位置 function parseData(str, node) { const _list = str.split(':') let _data, _path let p = [] _list.forEach((key, index) => { if(index === 0) { _data = data[key] p.push(key) } else { _path = node.path[index-1] p.push(_path) _data = _data[_path][key] p.push(key) } }) return { path: p, data: _data } } function parseModel(node) { if(node.getAttribute('data-model')) { const modelName = node.getAttribute('data-model') const _data = parseData(modelName, node) if(node.tagName === 'INPUT') { node.value = _data.data } else { node.innerText = _data.data } } } function parseClass(node) { if(node.getAttribute('data-class')) { const className = node.getAttribute('data-class') const _data = parseData(className, node) if(!node.classList.contains(_data.data)) { node.classList.add(_data.data) } } } 

接下來解析列表,我們遇到列表時,應該先遞歸找出列表項的結構

 parseListItem(node) { let target !function getItem(node) { for(let i = 0; i < node.children.length; i++) { const _thisNode = node.children[i] if(node.path) { _thisNode.path = node.path.slice() } parseEvent(_thisNode) parseClass(_thisNode) parseModel(_thisNode) if(_thisNode.getAttribute('data-list-item')) { target = _thisNode } else { getItem(_thisNode) } } }(node) return target } 

之后在用這個列表項來按需拷貝出一定數量的列表項,並填充數據

function parseList(node) { const _item = parseListItem(node) const _list = node.getAttribute('data-list') const _listData = parseData(_list, node) _listData.data.forEach((_dataItem, index) => { const _copyItem = _item.cloneNode(true) if(node.path) { _copyItem.path = node.path.slice() } if(!_copyItem.path) { _copyItem.path = [] } _copyItem.path.push(index) scan(_copyItem) node.insertBefore(_copyItem, _item) }) node.removeChild(_item) } 

這樣我們就完成了模版的渲染,scan函數會掃描模版對模版進行渲染

解析數據

解析了模版之后,我們就要研究如何進行數據解析了,這里我采用劫持數據的方法來進行。

普通對象的劫持

如何劫持數據?一般對數據的劫持都是通過Object.defineProperty方法進行的,先看一個小例子:

 var obj = { name: 'mi' } function observe(obj, key) { let old = obj[key] Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function() { return old }, set: function(now) { if(now !== old) { console.log(`${old} ---> ${now}`) old = now } } }) } observe(obj, 'name') obj.name = 'mirone' //輸出結果: //"mi ---> mirone" 

這樣我們就通過object.defineProperty進行了數據劫持,如果我們想自定義劫持數據時發生的操作,只要添加一個回調函數參數即可:

function observer(obj, k, callback) { let old = obj[k] Object.defineProperty(obj, k, { enumerable: true, configurable: true, get: function() { return old }, set: function(now) { if(now !== old) { callback(old, now) } old = now } }) } 

嵌套對象的劫持

對於對象中的對象,我么還需要多進行一個步驟,使用遞歸來劫持對象中的對象:

//實現一個observeAllKey函數,劫持該對象的所有屬性 function observeAllKey(obj, callback) { Object.keys(obj).forEach(function(key){ observer(obj, key, callback) }) } function observer(obj, k, callback) { let old = obj[k] if (old.toString() === '[object Object]') { observeAllKey(old, callback) } else { //...同前文,省略 } } 

對象中數組的劫持

對於對象中的數組,我們使用重寫數組的prototype的方法來劫持它

function observeArray(arr, callback) { const oam = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'] const arrayProto = Array.prototype const hackProto = Object.create(Array.prototype) oam.forEach(function(method){ Object.defineProperty(hackProto, method, { writable: true, enumerable: true, configurable: true, value: function(...arg) { let me = this let old = arr.slice() let now = arrayProto[method].call(me, ...arg) callback(old, me, ...arg) return now }, }) }) arr.__proto__ = hackProto } 

寫完劫持數組的函數后,將它添加進主函數:

function observer(obj, k, callback) { let old = obj[k] if(Object.prototype.toString.call(old) === '[object Array]') { observeArray(old, callback) } else if (old.toString() === '[object Object]') { observeAllKey(old, callback) } else { //... } } 

處理路徑參數

之前我們所有的方法都是面對單個key值的,回想一下我們的模版,有很多例如todos:todo:member這樣的路徑,我們應該允許傳入一個路徑數組,根據路徑數組來監聽指定的對象數據

function observePath(obj, path, callback) { let _path = obj let _key path.forEach((p, index) => { if(parseInt(p) === p) { p = parseInt(p) } if(index < path.length - 1) { _path = _path[p] } else { _key = p } }) observer(_path, _key, callback) } 

之后再將它添加進主函數:

function observer(obj, k, callback) { if(Object.prototype.toString.call(k) === '[object Array]') { observePath(obj, k, callback) } else { let old = obj[k] if(Object.prototype.toString.call(old) === '[object Array]') { observeArray(old, callback) } else if (old.toString() === '[object Object]') { observeAllKey(old, callback) } else { //... } } } 

這樣,我們就完成了監聽函數。

綁定模版與數據

現在,我們要在解析過程中添加對數據的監視了,還記得之前的parse系列函數嗎?

function parseModel(node) { if(node.getAttribute('data-model')) { //...之前邏輯不變 observer(data, _data.path, function(old, now) { if(node.tagName === 'INPUT') { node.value = now } else { node.innerText = now } //添加console便於調試 console.log(`${old} ---> ${now}`) }) } } function parseClass(node) { if(node.getAttribute('data-class')) { //... observer(data, _data.path, function(old, now) { node.classList.remove(old) node.classList.add(now) console.log(`${old} ---> ${now}`) }) } } //當列表發生變化時,為了簡單直接重新渲染了當前列表 function parseList(node) { //... observer(data, _listData.path, () => { while(node.firstChild) { node.removeChild(node.firstChild) } const _listData = parseData(_list, node) _listData.data.forEach((_dataItem, index) => { node.appendChild(_item) const _copyItem = _item.cloneNode(true) if(node.path) { _copyItem.path = node.path.slice() } if(!_copyItem.path) { _copyItem.path = [] } _copyItem.path.push(index) scan(_copyItem) node.insertBefore(_copyItem, _item) }) node.removeChild(_item) }) } 

至此我們就基本完成了一個簡單的MVVM,之后我進行了一點細微的細節優化,源碼放在我的Gist上。各位也可以去本教程的JSBin查看效果。水平有限,歡迎吐槽。

感謝您的閱讀,如果有所幫助,請點個贊吧。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM