使用有限狀態(tài)機(jī)實(shí)現(xiàn)簡(jiǎn)版的html解析器
FSM(Finite State Machines) 有限狀態(tài)機(jī),也叫有限狀態(tài)自動(dòng)機(jī),是為研究有限內(nèi)存的計(jì)算過(guò)程和某些語(yǔ)言類而抽象出的一種計(jì)算模型,它擁有有限個(gè)數(shù)量的狀態(tài),每個(gè)狀態(tài)可以遷移到零個(gè)或多個(gè)狀態(tài),輸入字串決定執(zhí)行哪個(gè)狀態(tài)的遷移。
有限狀態(tài)機(jī)有什么用
代碼編譯器在工作時(shí)就需要通過(guò)詞法分析、語(yǔ)法分析、語(yǔ)義分析來(lái)得到 AST(Abtract Syntaxt Tree) 抽象語(yǔ)法樹。需要先詞法分析拿到的所有 token 流,接著通過(guò)語(yǔ)法分析將 token 流進(jìn)行文法校驗(yàn)生成語(yǔ)法解析樹,這個(gè)過(guò)程一般有兩種:
- 邊分詞邊生成 AST,像解析 HTML、CSS
- 先分詞生成所有 token,再來(lái)進(jìn)行語(yǔ)法分析生成 AST,像 js
我們?cè)谇岸斯ぷ髦薪?jīng)常用到的:babel、typescript、eslint、postcss、prettier、uniapp、htmlparse、代碼編輯器的語(yǔ)法高亮...這些其實(shí)都是基于 AST 抽象語(yǔ)法樹來(lái)實(shí)現(xiàn)的,而為了得到 AST 我們需要先進(jìn)行分詞,而分詞一個(gè)比較好的方式就是通過(guò)有限狀態(tài)機(jī)來(lái)實(shí)現(xiàn)。
代碼的本質(zhì)就是字符串,分詞就是把代碼字符串變成一個(gè)個(gè)最小單元到不能再拆分的單詞,也叫 token(注意不是咱們平時(shí)用到的登錄態(tài) token),分詞器的英文 tokenizer。代碼其實(shí)跟我們一篇英文文章、一首中文古詩(shī)、一個(gè)數(shù)學(xué)運(yùn)算...都是一樣的,我們一樣可以用分詞技術(shù)來(lái)拆分這些元素。
有限狀態(tài)機(jī)是怎么工作的
為了理解有限狀態(tài)機(jī)到底是怎么工作的,我們先來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的減法運(yùn)算分詞。要求用狀態(tài)機(jī)把 500-250=250 這個(gè)減法運(yùn)算分詞成一個(gè)數(shù)組,首先定義一共有2種狀態(tài):number-數(shù)字、operator-運(yùn)算符,每一個(gè)最小的 token 只能是這兩個(gè)當(dāng)中的一個(gè),代碼如下
// 500-250=250 // [ // { type: 'number', value: '500' }, // { type: 'operator', value: '-' }, // { type: 'number', value: '250' }, // { type: 'operator', value: '=' }, // { type: 'number', value: '250' } // ] function mathTokenizer(text) { // 匹配數(shù)字正則 const numberReg = /[0-9]/ // 匹配運(yùn)算符正則 const operatorReg = /[-=]/ // 存儲(chǔ)所有讀取到的 token 數(shù)組 let tokens = [] // 當(dāng)前正在讀取的 token 信息 let currentToken = {} // 初始狀態(tài) function init(e) { if (numberReg.test(e)) { currentToken = { type: 'number', value: e } return onNumber } else if (operatorReg.test(e)) { currentToken = { type: 'operator', value: e } return onOperator } } // 讀取到數(shù)字 function onNumber(e) { if (numberReg.test(e)) { currentToken.value += e return onNumber } if (operatorReg.test(e)) { pushToken(currentToken) currentToken = { type: 'operator', value: e } return onOperator } } // 讀取到運(yùn)算符 function onOperator(e) { if (numberReg.test(e)) { pushToken(currentToken) currentToken = { type: 'number', value: e } return onNumber } if (operatorReg.test(e)) { pushToken(currentToken) currentToken = { type: 'operator', value: e } return onOperator } } // 每次讀取到完整的一個(gè) token 后存入到數(shù)組中 function pushToken(token) { tokens.push(token) currentToken = {} } // 遍歷讀取數(shù)組 function parse(str) { const len = str.length let stateMachine = init for (let i = 0; i < len; i++) { const char = str[i] stateMachine = stateMachine(char) // 最后一個(gè)字符匹配完了要自己 pushToken if (i === len - 1) { pushToken(currentToken) } } return tokens } return parse(text) } const arr = mathTokenizer('500-250=250') console.log(arr)
簡(jiǎn)版的 html 解析器
詞法分析,生成 token 流
利用狀態(tài)機(jī)來(lái)生成 token 流,為了方便理解以下示例不考慮標(biāo)簽屬性節(jié)點(diǎn)、自閉合標(biāo)簽和一些異常情況。
我們先定義5個(gè)狀態(tài):標(biāo)簽開始、結(jié)束標(biāo)簽開始、標(biāo)簽名、標(biāo)簽結(jié)束、文本,每次讀取到的內(nèi)容會(huì)在這5個(gè)狀態(tài)之間切換,每次讀取時(shí)只要不是標(biāo)簽開始、結(jié)束標(biāo)簽開始、標(biāo)簽名、標(biāo)簽結(jié)束這4個(gè)狀態(tài)的我們都當(dāng)成文本來(lái)處理。
實(shí)際上我們只需要存儲(chǔ):開始標(biāo)簽、文本、結(jié)束標(biāo)簽這3個(gè)狀態(tài),所以定義的節(jié)點(diǎn) type 分別為:startTag、text、endTag。你要按前面定義的5個(gè)狀態(tài)來(lái)儲(chǔ)存其實(shí)也是可以的,在下面生成 AST 直接忽略掉我們不需要的標(biāo)簽開始、標(biāo)簽結(jié)束這些狀態(tài)信息就行了,只不過(guò)這里我們直接在分詞這一步提前就給過(guò)濾了。
這里我們可以把狀態(tài)機(jī)理解成一個(gè)函數(shù),每遍歷到一個(gè)字符我們都將這個(gè)字符傳到函數(shù)中,而函數(shù)中可以根據(jù)這個(gè)字符來(lái)判斷下一個(gè)狀態(tài)是什么,再返回出去下一個(gè)狀態(tài)函數(shù)就行了。
function htmlTokenizer(str){ // 標(biāo)簽開始 const tagStartReg = /</ // 結(jié)束標(biāo)簽開始 const closeTagReg = /// // 標(biāo)簽結(jié)束 const tagEndReg = />/ // 標(biāo)簽名 const tagNameReg = /[a-zA-Z]/ let tokens = [] let currentToken = {} // 初始狀態(tài) function init(e) { if (tagStartReg.test(e)) { currentToken = { type: 'startTag', tagName: '' } return init } if (closeTagReg.test(e)) { currentToken = { type: 'endTag', tagName: '' } return onTagName } if (tagNameReg.test(e)) { currentToken.tagName += e return onTagName } // 不是上面3個(gè)狀態(tài)的都當(dāng)成文本節(jié)點(diǎn)處理 currentToken = { type: 'text', text: e } return onText } // 讀取到標(biāo)簽名 function onTagName(e) { if (tagEndReg.test(e)) { pushToken(currentToken) return init } else { currentToken.tagName = (currentToken.tagName || '') + e return onTagName } } // 讀取到文本 function onText(e) { if (tagStartReg.test(e)) { pushToken(currentToken) currentToken = { type: 'startTag', tagName: '' } return init } else { currentToken.text = (currentToken.text || '') + e return onText } } // 每次讀取到完整的一個(gè) token 后存入到數(shù)組中 function pushToken(e) { tokens.push(e) currentToken = {} } // 遍歷讀取數(shù)組 function parse(chars){ let stateMachine = init for (const char of chars) { stateMachine = stateMachine(char) } return tokens } return parse(str) } const tokenList = htmlTokenizer('<div>靜夜思<p>鋤禾日當(dāng)午</p>周小黑<p>粒粒皆辛苦</p>公元一七八八年</div>') console.log(tokenList)
語(yǔ)法分析,生成 AST 抽象語(yǔ)法樹
這一步主要就怎么能把分詞得到的數(shù)組轉(zhuǎn)換成樹形 tree 數(shù)據(jù)結(jié)構(gòu),日常開發(fā)中我們 array 轉(zhuǎn) tree 一般都是需要根據(jù)父親 id 之類的來(lái)實(shí)現(xiàn)遍歷生成,但是這里咱拿到的數(shù)組是沒(méi)有這個(gè)父 id 的,那要怎么實(shí)現(xiàn)呢?
先觀察數(shù)據(jù)結(jié)構(gòu),雖然是一個(gè)數(shù)組,但是這個(gè)數(shù)組其實(shí)是個(gè)類似中心對(duì)稱結(jié)構(gòu)的,我們暫時(shí)先忽略掉數(shù)組里的 type 為 text 的文本內(nèi)容(因?yàn)檫@個(gè)其實(shí)我們是不能把它當(dāng)成一個(gè)父節(jié)點(diǎn)的,它只能是某個(gè)標(biāo)簽的子節(jié)點(diǎn)),過(guò)濾掉文本后數(shù)組第1個(gè)元素和最后1個(gè)元素正好是1對(duì),第2個(gè)元素和倒數(shù)第2個(gè)元素又是1對(duì),我們要實(shí)現(xiàn)的就是把內(nèi)層獲取到的一對(duì)對(duì)標(biāo)簽不斷掛載到它前面一對(duì)簽的 children 屬性上來(lái)實(shí)現(xiàn) tree 結(jié)構(gòu)。
那我們可以從數(shù)組第一項(xiàng)目開始遍歷,然后用一個(gè)數(shù)組來(lái)模擬 stack 棧存每次遍歷到的標(biāo)簽信息(棧的特點(diǎn)是先進(jìn)后出,類似我們往一個(gè)桶里放東西,放在最上面的可以最先拿出來(lái),規(guī)定數(shù)組只能使用 push 和 pop 就能模擬棧了)。
當(dāng)遇到開始標(biāo)簽的時(shí)候就說(shuō)明遇到一個(gè)新的標(biāo)簽了,這時(shí)就往棧里 push 進(jìn)去,當(dāng)遇到結(jié)束標(biāo)簽時(shí)就說(shuō)明當(dāng)前這個(gè)標(biāo)簽的所有信息都已經(jīng)讀取處理完了,那我們就可以將它從棧里彈出來(lái),然后現(xiàn)在棧里最上面的一個(gè)元素其實(shí)就是當(dāng)前彈出來(lái)的父標(biāo)簽了,直接掛載到 children 上就行了。整個(gè)過(guò)程其實(shí)主要就是理解下面2點(diǎn):
- 用棧來(lái)緩存節(jié)點(diǎn):嵌套在內(nèi)部的節(jié)點(diǎn)就可以先出棧,根節(jié)點(diǎn)最后出棧
- 用引用類型對(duì)象的特點(diǎn),來(lái)不斷掛載節(jié)點(diǎn)
function htmlAst(tokenList) { let stack = [] for (let i = 0; i < tokenList.length; i++) { const node = tokenList[i] // 開始標(biāo)簽:入棧 if (node.type === 'startTag'){ stack.push(node) } // 結(jié)束標(biāo)簽:出棧 if (node.type === 'endTag') { const currentNode = stack.pop() const parent = stack[stack.length - 1] if (parent) { if (!parent.children) parent.children = [] parent.children.push(currentNode) } else { const root = { type: 'document', children: [currentNode] } return root } } // 文本:加到父標(biāo)簽的 children 上 if (node.type === 'text') { const parent = stack[stack.length - 1] if (!parent.children) parent.children = [] parent.children.push(node) } } }
然后就能拿到我們需要的 AST 語(yǔ)法樹了,結(jié)構(gòu)如下:
{ "type": "document", "children": [ { "type": "startTag", "tagName": "div", "children": [ { "type": "text", "text": "靜夜思" }, { "type": "startTag", "tagName": "p", "children": [ { "type": "text", "text": "鋤禾日當(dāng)午" } ] }, { "type": "text", "text": "周小黑" }, { "type": "startTag", "tagName": "p", "children": [ { "type": "text", "text": "粒粒皆辛苦" } ] }, { "type": "text", "text": "公元一七八八年" } ] } ] }
理解了狀態(tài)機(jī)就如給你按上了一雙翅膀,不管給你任何一段字符任容,都可以通過(guò)狀態(tài)機(jī)來(lái)拆分成我們想要的結(jié)構(gòu),理解了上面這些再去看 vue 里的模板編譯,你就能知道它到底是怎么加進(jìn)去那些語(yǔ)法糖的了。還比如小程序中的富文本解析,特定平臺(tái)的小程序?qū)嶋H上是不能識(shí)別瀏覽器里的 html 的,那我們就需要先將 html 通過(guò)狀態(tài)機(jī)轉(zhuǎn)成 AST,然后再按照小程序的語(yǔ)法來(lái)進(jìn)行特定的轉(zhuǎn)換。
到此這篇關(guān)于使用有限狀態(tài)機(jī)實(shí)現(xiàn)簡(jiǎn)版的html解析器的文章就介紹到這了,更多相關(guān)有限狀態(tài)機(jī)實(shí)現(xiàn)html解析器內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JS實(shí)現(xiàn)注冊(cè)界面表單校驗(yàn)
這篇文章主要為大家詳細(xì)介紹了JS實(shí)現(xiàn)注冊(cè)界面表單校驗(yàn),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08JavaScript數(shù)組去重算法實(shí)例小結(jié)
這篇文章主要介紹了JavaScript數(shù)組去重算法,結(jié)合實(shí)例形式總結(jié)分析了JavaScript數(shù)組去重相關(guān)的讀寫、遍歷、比較、排序等操作及算法改進(jìn)相關(guān)實(shí)現(xiàn)技巧,需要的朋友可以參考下2018-05-05JS數(shù)組搜索之折半搜索實(shí)現(xiàn)方法分析
這篇文章主要介紹了JS數(shù)組搜索之折半搜索實(shí)現(xiàn)方法,結(jié)合具體實(shí)例形式分析了javascript數(shù)組折半搜索算法的原理、實(shí)現(xiàn)技巧與相關(guān)注意事項(xiàng),需要的朋友可以參考下2017-03-03JavaScript 中定義函數(shù)用 var foo = function () {} 和 function foo()區(qū)
這篇文章主要介紹了JavaScript 中定義函數(shù)用 var foo = function () {} 和 function foo()區(qū)別介紹,需要的朋友可以參考下2018-03-03Three.Js實(shí)現(xiàn)看房自由小項(xiàng)目
目前隨著元宇宙概念的爆火,THREE技術(shù)已經(jīng)深入到了物聯(lián)網(wǎng)、VR、游戲、數(shù)據(jù)可視化等多個(gè)平臺(tái),今天我們主要基于THREE實(shí)現(xiàn)一個(gè)三維的VR看房小項(xiàng)目,感興趣的朋友跟隨小編一起看看吧2022-10-10Bootstrap fileinput 上傳新文件移除時(shí)觸發(fā)服務(wù)器同步刪除的配置
這篇文章主要介紹了Bootstrap fileinput 上傳新文件移除時(shí)觸發(fā)服務(wù)器同步刪除的配置 ,需要的朋友可以參考下2018-10-10微信小程序使用 official-account 組件實(shí)現(xiàn)一鍵跳轉(zhuǎn)公眾號(hào)
本文詳細(xì)介紹了如何在微信小程序中實(shí)現(xiàn)一鍵跳轉(zhuǎn)到公眾號(hào)的功能,包括準(zhǔn)備工作、使用`<official-account>`組件實(shí)現(xiàn)跳轉(zhuǎn)、關(guān)聯(lián)小程序與公眾號(hào)的方法,以及常見(jiàn)錯(cuò)誤及解決方案,通過(guò)本文的指導(dǎo),開發(fā)者可以順利實(shí)現(xiàn)這一功能,提升用戶體驗(yàn)2024-11-11