💯利用Vue原理實現一個mini版的MVVM框架

💯利用Vue原理實現一個mini版的MVVM框架

更新:最近我開發了一個簡化redux開發的框架booto,大家有興趣可以看參觀下

主要介紹

本文將利用Vue的核心原理,如模板解析、Data-binding、Virtual-DOM等知識來實現一個“主要原理五臟俱全的Mue.js”。不吹牛不裝逼,用淺顯易懂的人話分模塊講解其中奧秘,順便“含沙射影”的說Vue的原理。不貼大段無用代碼,用清晰的思路給讀者分享整個MVVM框架Mue.js的實現過程。

閱讀完你將獲得:

  • 知道Vue的核心思想
  • 對模板解析、Data-binding、Virtual-DOM會有比較深的了解,不再一臉懵逼
  • 你也能夠實現一個簡單版的MVVM框架

我實現一個這樣框架的目的也是想親自感受下Vue/React的奧秘所在。最終覺得“驚艷魔法的背後,實際上都是一些普通的代碼”,也有一些比較精彩的代碼技巧值得學習。

先來看下最終的實現效果,如下圖

本框架命名為Mue,指令都以m-開頭。上面效果對應的代碼如下:

Javascript代碼:

importMuefrom'../core/mue';vartemplate='<div>'+'<div>'+'<h1>{{ title }}</h1>'+'<h2>{{ info.desc }}</h2>'+'</div>'+'<h2>{{count}}</h2>'+'<loading m-if="loading">loading</loading>'+'<p class="el_input">輸入的內容是:{{inputText}}</p>'+'<input type="text" m-model="inputText" />'+'</div>';newMue({el:'#app',template,data:{title:'測試文字',info:{desc:'描述文字'},count:1,loading:true,inputText:''},mounted(){setTimeout(()=>{this.data.title='title新值';this.data.info.desc='desc新值';this.data.loading=false;},1500)setInterval(()=>{this.data.count++},100)}});

Html代碼:

<body><divid="app"></div><scriptsrc="../dist/mue.js"></script> // 编译后的JS代码文件
</body>

可以看出實際上跟Vue的用法非常相似(因為本身就是mini版的Vue)。功能很簡單,只實現了數據驅動、m-if、m-model等指令。代碼都在我的GitHub這裡。接下來將為你揭秘其中的原理。

實現原理

整體流程

上面是Mue的整體流程。與Vue的非常相似(可以跟github上的一篇Vue原理的文章對比下),核心的技術如上面提到的都已經用上了。但是Mue跟Vue比起來只是一個非常小的玩具,實現較為粗暴,讀者需正確看待。

與Vue類似,Mue的實現原理可以分為三個重要部分

1、 Data-binding ,也稱為響應式。是MVVM框架的重要技術。實際上是通過監聽數據變化,來觸發視圖View更新。可以通過數據劫持(Object.defineProperty定義getter、setter方法)、發布訂閱模式、代理模式(ES6有個Proxy,es5的polyfills也行)等來實現,本文的Mue與Vue都是第一種方式來實現。

2、模板解析,由於利用了虛擬DOM技術,必須把HTML模板(後文統稱template)進行解析轉換成Node樹(代表HTML的一個結構化數據,JavaScript中的對象),Mue中也用到了m- if、m-modal等指令、{{}}表達式等,所以也必須通過對template的解析,進行處理。有點類似JSX語法的模板轉換成hyperapp函數,但是多了對指令、組件的特殊處理。

3、 Virtual-DOM技術,該技術就是把HTML模板轉換成結構化數據,用簡化的Node對象替代複雜的真實DOM Node Element對象,利用diff算法對結構化的新舊nodes對象進行比對,以最低的算法複雜度找出需要修改的Node節點,然後根據Node 數據create、update、remove DOM Element。從而完成View的渲染與更新。實際上Virtual-DOM是為了提升性能而存在。如果只想要實現一個MVVM框架,這個不是必須的。

如果你看完上面的內容還是一臉懵逼的話,不要緊~。我們講一個複雜的東西一定是先有宏觀,再深入細節,最後回過頭來再一遍,總結以下,就能夠理解了。這就像兒時老師教導的一樣:“看一本書先看目錄,把書讀薄再讀厚最後再讀薄,就大概都能理解了”。看總體的說,Mue實際上就是利用模板解析生成渲染函數,渲染函數根據data生成真實DOM,Data-binding監聽數據變化觸發渲染函數再來一遍。這樣就完成了整個循環。

主體代碼

exportdefaultfunctionMue(options){this._init(options);//初始化Mue對象}watchData(Mue);//給Mue增加data-bindingpatchInit(Mue);//給Mue增加patch方法,create、update、remove Elment的方法Mue.prototype._init=function(options){const{el,template,data,mounted,methods}=options;this.el=el;this.data=data;// template解析成nodeObject(非vdom的那個nodeObject)constparsedNodes=parse(template);//解析生成的nodeObject生成函數字符串letcompileStr='return '+buildRenderStr(parsedNodes);//用函數字符串生成compiler函數this.compiler=buildCompiler(compileStr);//用compiler生成VDOM,並調用patch方法生成DOMthis.render();//掛載執行的回調函數mounted.call(this);//監聽data變化this.defineReactive();};

主體代碼邏輯非常清晰,與整體流程圖呼應。

模板解析

先從模板解析講起,比較符合思路。

template轉AST

constparsedNodes=parse(template);

解析HTML字符串轉換成AST(詞法樹),下面是一個比較簡單的例子。

模板,含有m-if指令、{{}}表達式

經過解析函數parse解析以後得到下面的AST數據結構

{"type":1,"tag":"div","attrsList":[],"attrsMap":{},"children":[{"type":1,"tag":"h2","attrsList":[{"name":"m-if","value":"count"}],"attrsMap":{"m-if":"count"},"parent":"[Circular ~]","children":[{"type":2,"expression":"this._s(this.data.count)","text":"{{count}}"}]}]}

可以看出對HTML每個標籤進行了分析提煉出了DOM Element的標籤類型、標籤名tagName、標籤上的屬性attributes、以及子元素children。這個數據結構與DOM結構是一一對應的,代表了DOM結構。這裡的數據結構與vnodes樹很像,但是他們是不同的,讀完本文你會理解。現在先知道他們是不同的即可。

解析函數我們工作中不怎麼會遇到,本文中的Mue的parse函數我也是copy別人的。可以在這裡看源碼。我們看下偽代碼代碼,大致了解下。

/*** Convert HTML string to AST.*/exportdefaultfunctionparse(template,options){//節點棧conststack=[]//根節點,最終改回返回letroot//當前的父節點letcurrentParentparseHTML.parse(template,{// node的開始start(tag,attrs,unary){// unary是否一元標籤,如<img/>constelement={type:1,tag,// attrsList數組形式attrsList:attrs,// attrsMap對象形式,如{id: 'app', 'm-text': 'xxx'}attrsMap:makeAttrsMap(attrs),parent:currentParent,children:[]}//處理m-for ,生成element.for, element.aliasprocessFor(element)//處理m-if ,生成element.if, element.else, element.elseifprocessIf(element)//處理m-once ,生成element.onceprocessOnce(element)//處理key ,生成element.keyprocessKey(element)//處理屬性//第一,處理指令:m-bind m-on以及其他指令的處理//第二,處理普通的html屬性,如style class等processAttrs(element)// tree managementif(!root){//確定根節點root=element}if(currentParent){//當前有根節點currentParent.children.push(element)element.parent=currentParent}if(!unary){//不是一元標籤(<img/>等)currentParent=elementstack.push(element)}},// node的結束end(){// pop stackstack.length-=1currentParent=stack[stack.length-1]},//字符chars(text){constchildren=currentParent.childrenletexpression//處理字符expression=parseText(text)//如'_s(price)+"元"'children.push({type:2,expression,text})}})returnroot}

parseHTML操作字符串找出"<"、">"、"/>" 的字符的文字,用正則匹配的方法進行文本的截取找出type、tagName、attribute、attribute的value、文本等。形成node。 node就是包含上面這些元素的對象。 (tips:看了源碼,需要很大的耐心和很溜的正則運用技能才能寫出這樣的解析代碼,有時間有毅力的話也是可以寫出來的)。

生成compiler函數

// 解析生成的nodeObject生成函数字符串
letcompileStr='return '+buildRenderStr(parsedNodes);// 用函数字符串生成render函数
this.compiler=buildCompiler(compileStr);

生成了node樹,我們需要像JSX一樣,將node樹替換成compiler函數字符串。

returnthis._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])

通過new Function('str') 的方式生成函數。即

render=newFunction('return this._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])')

實際上,compiler函數就是

this._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])

有沒有發現跟DOM結構非常像,有沒有跟React JSX轉換後的代碼非常相似?

同時,你一定注意到了_h, _s 兩個函數。

_h=function(nodeName,attributes,children){// 省略
returnnode}_s=function(expression){returnexpression;}

_h函數代表的是根據tag、attributes、children生成VNode的函數、_s函數代表表達式運行函數。

其中還有this.data.count,這個this指的就是Mue構造出來的對象,this.data 就是傳入的data ,如下圖所示

newMue({el:'#app',template,data:{count:1}});Mue.prototype._init=function(options){const{el,template,data,mounted,methods}=options;this.data=data;// 省略其他  
};

看到這裡,可以看出compiler函數一旦運行,就是生成VNode樹。模板解析的全部流程就分析完了。

具體的buildRenderStr函數邏輯也很簡單,如下所示

functionbuildRenderStr(node){lettempStr='';//如果node是個dom節點if(node.type===1){//無子元素if(node.children.length===0){tempStr=`this._h('${node.tag}',${JSON.stringify(node.attrsMap)})`;}//有子元素else{letchildren=node.children;leth_childs=[];for(leti=0;i<children.length;i++){h_childs.push(buildRenderStr(children[i]));}h_childs='['+h_childs.join(',')+']';tempStr=`this._h('${node.tag}',${JSON.stringify(node.attrsMap)},${h_childs})`;}}//如果node是文字elseif(node.type===2){tempStr=node.expression?node.expression:`'${node.text}'`;}returntempStr;}

Virtual-DOM Diff運算後patch生成Real DOM

// 用compiler生成VDOM,并调用patch方法生成DOM this . render (); // 挂载执行的回调函数mounted . call ( this );

這一步運行了compiler函數,並且調用了patch函數(patch函數會對oldVNodes與VNodes進行diff運行,更新需要修改的Element,當然,第一次oldVNodes為空則是直接生成),下面是render函數

Mue.prototype.render=function(){// render函數生成VDOMletvNodes=this.compiler();// VDOM生成real DOMletcontainer=document.querySelector(this.el);letrootElement=(container&&container.children[0])||null;letoldNode=rootElement&&recycleElement(rootElement);this.patch(container,rootElement,oldNode,(oldNode=vNodes));}

VNodes範例如下,與oldVnodes進行比對,從而找出差異部分進行更新(第一次render沒有oldVNodes,直接就生成)。

{"nodeName":"div","attributes":{},"children":[{"nodeName":"div","attributes":{},"children":[{"nodeName":"h1","attributes":{},"children":["title新值"]},{"nodeName":"h2","attributes":{},"children":["desc新值"]}]},{"nodeName":"h2","attributes":{},"children":[1]},{"nodeName":"p","attributes":{"class":"el_input"},"children":["輸入的內容是:"]},{"nodeName":"input","attributes":{"type":"text","m-model":"inputText"},"value":""}]}

diff算法示意圖如下:

我的diff算法(在patch函數中)非常粗暴簡單,並且性能也很糟糕。大家只需了解即可。大家可以參考比較好的兩個開源Virtual-DOM組件。

我的diff算法(在patch函數中)非常粗暴簡單,並且性能也很糟糕。大家只需了解即可。大家可以參考比較好的兩個開源Virtual-DOM組件。

github.com/Matt-Esch/vi

github.com/snabbdom/sna

還可以參考這篇實現一個virtual-dom的博文

實際上Vue的Virtual-DOM技術也是參考的snabbdom。開源的東西就是讓別人參考的。沒必要自己再去實現一遍證明自己很牛逼。今後Mue也只會參考其他方式進行優化,變成真正有效率的Virtual-DOM。

具體的Diff算法我就不折騰了。用簡單的土話來描述就是“遍歷VNodes,比較前後兩次VNodes的差異,用了一個比較有效率的比較方法”。找出以後,用CreateElement、UpdateElement、RemoveElement操作真實DOM。

這裡舉一個CreateElement來說明,代碼如下:

Mue.prototype.createElement=function(node,isSvg){let_mue=this;varelement=typeofnode==="string"||typeofnode==="number"?document.createTextNode(node):(isSvg=isSvg||node.nodeName==="svg")?document.createElementNS("http://www.w3.org/2000/svg",node.nodeName):document.createElement(node.nodeName)//嵌套創建if(node.childreninstanceofArray){for(vari=0;i<node.children.length;i++){element.appendChild(this.createElement(node.children[i]))}}//如果是input標籤if(node.nodeName==='input'){element.value=node.value;element.addEventListener('input',function(e){letexpression=node.attributes["m-model"];letval=e.target.value;letstr=`this.data.${expression}='${val}'`;(newFunction(str)).call(_mue)})}returnelement}

這裡我用了比較簡單粗暴的方式,僅僅實現了普通標籤、普通文本、input標籤、m-model指令等。實際上Vue複雜的多。就是把VNode變成Element,代碼也很簡單,就不再贅述。

最後Element生成後,調用下傳入的mounted回調函數,告訴業務代碼組件已經掛到DOM上了,可以做事了。

Data-bindding

重頭戲來了!

import{isType}from"../utils/index";exportfunctionwatchData(Mue){Mue.prototype.defineReactive=function(){let_mue=this;Object.keys(this.data).forEach(prop=>{defineProp(this.data,prop,this.data[prop]);});functiondefineProp(data,prop,val){if(isType(val,'object')){Object.keys(val).forEach(_prop=>{defineProp(val,_prop,val[_prop]);});}else{Object.defineProperty(data,prop,{get:function(){returnval;},set:function(newVal){val=newVal_mue.reactiveCollection();}})}}}Mue.prototype.reactiveCollection=function(){let_this=this;_this.render();}}

這個已經婦孺皆知的技術,實際上沒必要說太多。但是實際上這才是數據驅動框架的基礎與點睛之筆,我還是很詳細的說下。

JavaaScipt的Object.defineProperty可以給對象屬性prop定義set、get。

Object.defineProperty(data,prop,{get:function(){console.log('调用了get了');returnval;},set:function(newVal){val=newValconsole.log('发现重新赋值了');}})

比如對象

data:{count:1}

當調用data.count時會運行get,當data.count = 2時,會觸發set。 Mue利用這個原理set的會觸發reactiveCollection函數,進而觸發render函數再來一遍。 render函數中的compiler函數還記得嗎?這裡再貼一遍代碼,如下所示。

this._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])

上面已經對data進行了設值,如data.count = 2; 此時運行compile函數對this.data.count進行重新運算。最終提現在DOM上的就是count的值修改了。至此,完成了視圖的更新。完成了數據驅動視圖變化。後續的data修改即重複此過程。

由此可見,模板解析只做了一次,後面的render則是監聽數據變化不斷運行的。 diff算法保證了性能最優。還有m-if、m-model則是通過特殊處理;

m-if 在_h函數中覺得node是否存在。

//只考慮m-if、m-for的情況directives.forEach(item=>{if(item.key==='m-if'){letpropValue=newFunction(`return this.data.${item.prop}`).call(this);isNeed=propValue===true?true:false;}elseif(item.key==='m-model'){letpropValue=newFunction(`return this.data.${item.prop}`).call(this);node.value=propValue;}});

m-model則是在createElement中加事件監聽,如果有變化觸發this.data的值改變,來實現View-Model的改變。

element.addEventListener('input',function(e){letexpression=node.attributes["m-model"];letval=e.target.value;letstr=`this.data.${expression}='${val}'`;(newFunction(str)).call(_mue)})

至此,整個MVVM框架的運行過程就結束了。回頭看下整個流程,我們再把Mue讀薄一次。

Template 解析生成compiler函數=> render運行compiler生成VNodes => Patch進行diff運算並生成與修改Real DOM,同時Data遍歷進行雙向綁定=> 監聽到數據改變則激活Render根據Data新的值再運行一遍觸發視圖更新。

本文通過運用Vue核心原理實現一個Mue.js,可能存在許多不恰當的地方,還請讀者積極批評指正!

還有更多的事件要做

上面就是Mue的整個流程。真的是非常簡陋。還有很多事情需要再做:

  1. 如何實現m-for,這個還是需要再深入研究的
  2. Mue還不支持組件化,以及伴隨組件的生命週期、組件通信
  3. diff算法性能有點差,新的DOM
  4. .mue文件格式的實現,方便書寫模板
  5. 還需要支持更多標籤
  6. 還有更多

感悟

實現一個MVVM框架不是很難,但是要做好需要很多條件

  • Javascript基礎,開發過程遇到了很多工作中不常見的知識,原型、繼承、new Function()、各種正則
  • 全面的知識,如算法,數據結構知識。框架中很多地方都用到了
  • 時間,你需要很多時間去投入,去專研。研究一些現有的優秀框架,學習他們的做法,集大成。
  • 毅力,初次寫可能很亂,不斷的顛覆自己,一版兩版三版,總有一版讓你梳理出較好的框架。

當然,是否真有必要去實現一個自己的框架?如果你的框架不具顛覆性,可能自己自娛自樂,因為“沒有貢獻的東西都沒有價值”。

參考文獻

本文參考了許多文檔、開源項目,很多代碼都是直接拉過來了。我只是搬運工。

github.com/jorgebucaran

github.com/wangfupeng19

github.com/snabbdom/sna

ustbhuangyi.github.io/v

github.com/youngwind/bl

What do you think?

Written by marketer

像呼吸一樣自然:React hooks + RxJS

如何優雅的使用Angular 表單驗證