寫在前面
一個好的架構需要經過血與火的歷練,一個好的工程師需要經過無數項目的摧殘。
昨天博主分析了一下在vue中,最為基礎核心的api,parse函數,它的作用是將vue的模板字符串轉換成ast,從而構建vnode,構建指令,實現virtual dom,然后在這基礎之上實現雙向綁定等。【vuejs深入二】vue源碼解析之一,基礎源碼結構和htmlParse解析器
今天博主就來詳細的實現一個擁有核心功能的htmlParse函數,看看它內部的實現邏輯,了解它是怎么樣去解析一個vue模板的。
小目標
我們最終的目標是將html轉換成ast對象,那么首先我們定一個小目標:
<div id="div1"></div>
我希望將上面的html解析成ast格式,類似於下面:
{ "tag":"div", "attrs":[ { "id":"div1" } ], "children":[], "type":1 }
最終想要達成的第一個小目標是可以將div標簽字符串輸出成這樣一個object格式,tag表示標簽名稱,attrs表示屬性,children表示這個div所有的子節點,type的話表示節點的類型,我們今天只三個類型:
1.元素類型,也就是標簽類型,所有用<tag attr=""></tag>這樣的標簽。2.變量text,現在我們實現一個{{text}}的變量轉換,它其實是一個節點。3.普通文本,普通文本包括普通文字和空格、換行。
基本結構
基本結構的設計決定的代碼能擴展多遠,如果一開始結構設計錯誤,最后在新加入的功能無法嵌入的時候,那就只有重構一條路可以走了。
首先理清楚我們的思路。
匹配單個字符》匹配標簽》匹配屬性》匹配文本》匹配結束標簽
然后,你想啊,html標簽都是有開始,有結束的。那么這里問題就來了,可以想到的方式,解析一個標簽的開始與結束吧,例如我們使用正則匹配開始標簽<div id='div1'> 然后找到結束標簽</div>,這樣是不是就可以解析div里面的內容了?
難。
開始標簽比較好找,結束標簽就惡心了,例如 <div><div></div></div> ,,完了,怎么區分嵌套關系?第一個<div>到底匹配哪一個結束標簽?
這個思路是錯的,很難。
那么我們換個思路,如果我們單個字符匹配呢,
例如我們匹配一個 <div><div></div></div>,
ok 腦補步驟
1。匹配到 < 匹配到這個字符我就可以認為,后面的要么是開始標簽,要么是結束標簽。
2。用正則匹配從<到后面的字符,如果是開始標簽,現在記錄一下,啊,我遇到了一個開始標簽<div> 順便用正則記錄attrs
3. 現在我們匹配走走走。。。走到<div></div></div>
4.又匹配到一個 < 老步驟啊。
5.發現是開始標簽,再次記錄,啊,我又遇到一個開始標簽 <div> 順便用正則記錄attrs
6. 現在我們匹配走走走。。。走到</div></div>
7. 又匹配到一個 < 老步驟啊。
8.發現是一個結束標簽</div> ,嗯?結束標簽!它是誰的結束標簽?想一想。。。。。。應該是最后一個遇到的開始標簽吧。 第一個遇到的結束標簽不就是最后一個開始標簽的結束么?
9.啊,結束了一個。
10.再匹配,再完成。
恩。。。思路清晰了有沒有,來實現走一個:
//轉化HTML至AST對象 function parse(template){ var currentParent; //當前父節點 var root; //最終生成的AST對象 var stack = []; //插入棧 var startStack = []; //開始標簽棧 var endStack = []; //結束標簽棧 //console.log(template); parseHTML(template,{ start:function start(targetName,attrs,unary,start,end,type,text){//標簽名 ,attrs,是否結束標簽,文本開始位置,文本結束位置,type,文本, var element = { //我們想要的對象 tag:targetName, attrsList:attrs, parent:currentParent, //需要記錄父對象吧 type:type, children:[] } if(!root){ //根節點哈 root = element; } if(currentParent && !unary){ //有父節點並且不是結束標簽? currentParent.children.push(element); //插入到父節點去 element.parent = currentParent; //記錄父節點 } if (!unary) { //不是結束標簽? if(type == 1){ currentParent = element;//不是結束標簽,當前父節點就要切換到現在匹配到的這個開始標簽哈,后面再匹配到 startStack.push(element); //推入開始標簽棧 } stack.push(element); //推入總棧 }else{ endStack.push(element); //推入結束標簽棧 currentParent = startStack[endStack.length-1].parent; //結束啦吧當前父節點切到上一個開始標簽,這能理解吧,當前這個已經結束啦 } //console.log(stack,"currentstack") }, end:function end(){ }, chars:function chars(){ } }); console.log(root,"root"); return root; }; /** * Not type-checking this file because it's mostly vendor code. */ /*! * HTML Parser By John Resig (ejohn.org) * Modified by Juriy "kangax" Zaytsev * Original code by Erik Arvidsson, Mozilla Public License * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js */ // Regular Expressions for parsing tags and attributes var singleAttrIdentifier = /([^\s"'<>/=]+)/; var singleAttrAssign = /(?:=)/; var singleAttrValues = [ // attr value double quotes /"([^"]*)"+/.source, // attr value, single quotes /'([^']*)'+/.source, // attr value, no quotes /([^\s"'=<>`]+)/.source ]; var attribute = new RegExp( '^\\s*' + singleAttrIdentifier.source + '(?:\\s*(' + singleAttrAssign.source + ')' + '\\s*(?:' + singleAttrValues.join('|') + '))?' ); // could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName // but for Vue templates we can enforce a simple charset var ncname = '[a-zA-Z_][\\w\\-\\.]*'; var qnameCapture = '((?:' + ncname + '\\:)?' + ncname + ')'; var startTagOpen = new RegExp('^<' + qnameCapture); var startTagClose = /^\s*(\/?)>/; var endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>'); var doctype = /^<!DOCTYPE [^>]+>/i; var comment = /^<!--/; var conditionalComment = /^<!\[/; //偷懶哈 上面的正則是我在vue上拿下來的,這個后期可以研究,下面的話簡單的寫兩個用用,和vue原版的是有一些差別的 //{{變量}} var varText = new RegExp('{{' + ncname + '}}'); //空格與換行符 var space = /^\s/; var checline = /^[\r\n]/; /** type 1普通標簽 type 2代碼 type 3普通文本 */ function parseHTML(html,options){ var stack = []; //內部也要有一個棧 var index = 0; //記錄的是html當前找到那個索引啦 var last; //用來比對,當這些條件都走完后,如果last==html 說明匹配不到啦,結束while循環 var isUnaryTag = false; while(html){ last = html; var textEnd = html.indexOf('<'); if(textEnd === 0){ //這一步如果第一個字符是<那么就只有兩種情況,1開始標簽 2結束標簽 //結束標簽 var endTagMatch = html.match(endTag); //匹配 if(endTagMatch){ console.log(endTagMatch,"endTagMatch"); isUnaryTag = true; var start = index; advance(endTagMatch[0].length); //匹配完要刪除匹配到的,並且更新index,給下一次匹配做工作 options.start(null,null,isUnaryTag,start,index,1); continue; } //初始標簽 var startMatch = parseStartTag(); if(startMatch){ parseStartHandler(startMatch);//封裝處理下 console.log(stack,"startMatch"); continue; } } if(html === last){ console.log(html,"html"); break; } } function advance (n) { index += n; html = html.substring(n); } //處理起始標簽 主要的作用是生成一個match 包含初始的attr標簽 function parseStartTag(){ var start = html.match(startTagOpen); if(start){ var match = { tagName: start[1], // 標簽名(div) attrs: [], // 屬性 start: index // 游標索引(初始為0) }; advance(start[0].length); var end, attr; while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {//在endClose之前尋找attribute advance(attr[0].length); match.attrs.push(attr); } if (end) { advance(end[0].length); // 標記結束位置 match.end = index; //這里的index 是在 parseHTML就定義 在advance里面相加 return match // 返回匹配對象 起始位置 結束位置 tagName attrs } } } //對match進行二次處理,生成對象推入棧 function parseStartHandler(match){ var _attrs = new Array(match.attrs.length); for(var i=0,len=_attrs.length;i<len;i++){ //這兒就是找attrs的代碼哈 var args = match.attrs[i]; var value = args[3] || args[4] || args[5] || ''; _attrs[i] = { name:args[1], value:value } } stack.push({tag: match.tagName,type:1, lowerCasedTag: match.tagName.toLowerCase(), attrs: _attrs}); //推棧 options.start(match.tagName, _attrs,false, match.start, match.end,1); //匹配開始標簽結束啦。 } }
我們執行 parse("<div id='test1'><div></div></div>"); 大功告成哈哈哈哈哈 呃。
神馬,你還想問我細節問題?
正好給你培養一下讀代碼的能力哈,思路有了,代碼有了,拉下去調試調試哈。當然博主在下一章還會詳細介紹的。
寫在后面
mvvm框架和webpack的出現確實改變了前端的開發方式,使得學習前端變成了一門有着深入學問的課題。在我們日常開發中應該不斷地學習,歸納,總結,尋找新的思想,對原有的代碼有好的補充和好的改進。
寫的不好,謝謝大家觀看。 后續有空會新增更多關於開發的知識分享。
如果你有什么疑問,你可以聯系我,或者在下方評論。