鏈接: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'))