close

大家好,我是零一,瀏覽器底層有一塊非常重要的事情就是 HTML 解析器,HTML 解析器的工作是把 HTML 字符串解析為樹,樹上的每個節點是一個 Node,很多同學都好奇是怎麼實現的,這篇文章就用 JS 來實現一個簡單的 HTML 解析器。

下面的代碼改造自 node-html-parser

原理講解

1、效果

我們需要實現一個 parse 方法,並且傳入 HTML 字符串,返回一個樹結構:

constroot=parse(`<divid="test"class="container"c="b"><divclass="text-block"><span id="xxx">HelloWorld</span></div><imgsrc="xx.jpg"/></div>`);console.log(root);//[{"tagName":"","children":[{"tagName":"div","attrs":{"id":"test","class":"container"},"rawAttrs":"id=\"test\"class=\"container\"c=\"b\"","type":"element","range":[0,128],"children":[{"tagName":"div","attrs":{"class":"text-block"},"rawAttrs":"class=\"text-block\"","type":"element","range":[39,102],"children":[{"tagName":"span","attrs":{"id":"xxx"},"rawAttrs":"id=\"xxx\"","type":"element","range":[63,96],"children":[{"type":"text","range":[78,89],"value":"HelloWorld"}]}]},{"tagName":"img","attrs":{},"rawAttrs":"src=\"xx.jpg\"","type":"element","range":[102,122],"children":[]}]}]}]2、核心原理
用正則匹配出 <tag class="tag" aa="">、</tag>
通過先進後出(棧)的方式匹配標籤對(<tag></tag>)
3、初始化

首先我們需要初始化一些簡單的變量和方法備用:

//初始化2種Node類型//HTML[nodeType](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType)會比較多,這裡為了讓大家明白核心原理,省去了一些不重要的constnodeType={TEXT:'text',ELEMENT:'element',};//最外層增加一個模擬的根節點標籤constframeflag='rootnode';//計算一個完整標籤的範圍,eg.[0,50]constcreateRange=(startPos,endPos)=>{//因為最外層模擬了<rootnode>,所以需要將這部分長度減掉constframeFlagOffset=frameflag.length+2;return[startPos-frameFlagOffset,endPos-frameFlagOffset]};//找到數組的最後一項functionarrBack(arr){returnarr[arr.length-1];}functionparse(data){//最外層模擬的節點constroot={tagName:'',children:[],};//設置root為父節點letcurrentParent=root;//棧管理conststack=[root];letlastTextPos=-1;//將模擬的根節點和需要解析的html拼接data=`<${frameflag}>${data}</${frameflag}>`;//...開始遍歷/解析//通過處理,將stack返回就是最終的結果returnstatck;}4、遍歷解析/提取 HTML 標籤字符串

我們用一個例子來說明,給出一個 HTML 片段:

<divid="test"class="container"c="b"><divclass="text-block"><spanid="xxx">HelloWorld</span></div><imgsrc="xx.jpg"/></div>

對於這個片段,我們需要依次解析出下面的字符串:

<divid="test"class="container"c="b"><divclass="text-block"><spanid="xxx"></span></div><imgsrc="xx.jpg"/></div>

再說解析之前,我們來學習下 RegExp.prototype.exec() 的使用方法,已經會的可以跳過

exec() 方法會搜索匹配指定的字符串,返回一個數組或 null,如果正則設置了 global,會逐條的遍歷所有匹配結果,每次匹配到都會將匹配的字符串末尾位置記錄在 lastIndex 屬性中,看下下面 Demo

constregex=/foo/g;conststr='tablefootball,foosball';letmatchArray;while((matchArray=regex.exec(str))!==null){console.log(`Found${matchArray[0]}.Nextstartsat${regex.lastIndex}.`);//expectedoutput:"Foundfoo.Nextstartsat9."//expectedoutput:"Foundfoo.Nextstartsat19."}

那麼我們就可以利用 regex.exec 特性將需要的字符串依次匹配出來:

//參考標籤文檔:https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-nameconstkMarkupPattern=/<(\/?)([a-zA-Z][-.:0-9_a-zA-Z]*)((?:\s+[^>]*?(?:(?:'[^']*')|(?:"[^"]*"))?)*)\s*(\/?)>/g;while((match=kMarkupPattern.exec(data))){/***matchText:匹配的字符eg.<span id="xxx">*leadingSlash:是否為閉合標籤eg./*tagName:標籤名eg.span*attributes:屬性eg.id="xxx"*closingSlash:是否為自閉合eg./*/let{0:matchText,1:leadingSlash,2:tagName,3:attributes,4:closingSlash}=match;//本次匹配到的字符串constmatchLength=matchText.length;//本次匹配的起始位置consttagStartPos=kMarkupPattern.lastIndex-matchLength;//本次匹配的末尾位置consttagEndPos=kMarkupPattern.lastIndex;if(lastTextPos>-1){//處理文本,eg.helloworld//上次匹配的末尾位置+本次匹配的字符長度小於本次匹配的末尾位置就說明中間有text,這個稍微想下其實還是比較好理解的//如果沒有text,lastTextPos+matchLength都會等於tagEndPosif(lastTextPos+matchLength<tagEndPos){//上次匹配的末尾位置到本次匹配的起始位置consttext=data.substring(lastTextPos,tagStartPos);currentParent.children.push({type:nodeType.TEXT,range:createRange(lastTextPos,tagStartPos),value:text,});}}//記錄上次匹配的位置lastTextPos=kMarkupPattern.lastIndex;//如果匹配到的標籤是模擬標籤,就跳過if(tagName===frameflag)continue;//...處理nodeType為element邏輯}5、處理開標籤(eg. <div>)

接下來我們開始處理開標籤的邏輯(比如 <div>、<img />),開標籤包含了閉合標籤和非閉合標籤,直接看代碼:

if(!leadingSlash){constattrs={};//解析id、class屬性,並且掛到attrs對象下constkAttributePattern=/(?:^|\s)(id|class)\s*=\s*((?:'[^']*')|(?:"[^"]*")|\S+)/gi;for(letattMatch;(attMatch=kAttributePattern.exec(attributes));){const{1:key,2:val}=attMatch;//屬性值是否帶引號constisQuoted=val[0]===`'`||val[0]===`"`;attrs[key.toLowerCase()]=isQuoted?val.slice(1,val.length-1):val;}constcurrentNode={tagName,attrs,rawAttrs:attributes.slice(1),type:nodeType.ELEMENT,//這裡的range不一定是正確的range,需要匹配到閉標籤以後更新range:createRange(tagStartPos,tagEndPos),children:[],};//將當前節點信息放入到currentParent的children中currentParent.children.push(currentNode);//重置currentParent節點為當前節點currentParent=currentNode;//將每個節點依次塞到棧中,然後在後面的閉標籤中以棧的方式釋放stack.push(currentParent);}

這裡 stack 非常重要,利用了棧的先進後出原理一一匹配到對應的開閉標籤

6、處理閉標籤和自閉合標籤(eg. </div>、<img />)

上面處理開標籤過程中將標籤放入棧中以後,我們還需要匹配到閉標籤後更新 range 並且將之從棧(stack)中踢出:

//自閉合元素constkSelfClosingElements={area:true,img:true,//...省略了部分標籤};if(leadingSlash||closingSlash||kSelfClosingElements[tagName]){//開閉標籤名是否匹配,比如有可能寫成<div></div1>,這種就需要異常處理if(currentParent.tagName===tagName){//更新range,之前處理開標籤算出的range是不包含閉標籤的currentParent.range[1]=createRange(-1,Math.max(lastTextPos,tagEndPos))[1];//將處理完的開閉標籤踢出stack.pop();//將stack的最後一個節點賦值給currentParentcurrentParent=arrBack(stack);}else{//<div></div1>,異常直接從棧中踢出,不更新rangestack.pop();currentParent=arrBack(stack);}}最後

上述講解了如何用 JS 實現一個基本的 HTML 解析器,但還有一些代碼沒有處理,比如省略了 script、style 等標籤的處理(nodeType 不全),而且上面的節點我都用普通 Object 來替換,但其實每個 nodeType 對應的對象都會繼承自 Node,分別會有 Element、HTMLElement、Text、Comment 等,有興趣的同學可以基於 W3C 標準實現真正的 HTML 解析器。

往期推薦

小程序的鼻祖在國內就這麼消亡了!

不用跑項目,組件效果所見即所得,絕了!

86張腦圖,一口氣看完 React

我是傻x,被迫看了 1 天源碼,千萬別學我!

12個可能你沒見過,但非常實用的 HTML 標籤

CSS狀態管理,玩出花了!

創作不易,加個點讚、在看支持一下哦!

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()