一文搞懂vue編譯器(DSL)原理
什么是DSL
DSL是領域特定語言的縮寫,與JavaScript這種通用語言編譯器相對,它只針對某一個特殊應用場景工作
類似中英翻譯,它將源代碼翻譯為目標代碼,其轉(zhuǎn)換的標準流程過程包括:詞法分析、語法分析、語義分析、中間代碼生成、優(yōu)化、目標代碼生成等,此外,前述流程并非是嚴格必須的
vue中的DSL
- 詞法+語法+語義分析
- 生成token流
- 生成模板ast
- 將ast轉(zhuǎn)化為js ast
- 將ast轉(zhuǎn)化為render函數(shù)
const code = `` const tokens = tokenize(code) // 詞法+語法+語義分析,生成token流 const tAst = parse(tokens) // 生成ast const jsAst = transform(tAst) // 將ast轉(zhuǎn)化為jsAst const renderCode = generate(jsAst) // 將jsAst轉(zhuǎn)化為render函數(shù)
實現(xiàn)思路
- ast結(jié)構定義
首先我們要明確要生成的ast結(jié)構是什么樣的,比如如下的模板,div和h1怎么表示,開標簽中的屬性怎么區(qū)分,標簽的內(nèi)容放在那里等等
<div> <h1 v-if="show">我愛前端</h1> <div>
我們約定:ast是一個樹形結(jié)構,每一個節(jié)點對應一個html元素,該節(jié)點使用ts定義如下:
interface AstNode{ // 元素類型,是html原生還是vue自定義 type:string; // 元素名稱,是div還是h1 tag:string; // 子節(jié)點,h1是div的子節(jié)點 children:AstNode[]; // 開標簽屬性內(nèi)容 props:{ type:string; name?:string; exp?:{ ... } ... }[]; }
- 詞法、語法、語義分析
在工程化中,webpack或vite會幫我們把用戶側(cè)的源代碼拉取過來,我們使用node的readFileSync來代替這一行為
const fs = require('node:fs') const code = fs.readFileSync('./vue.txt','utf-8')
有了源代碼,接下來要考慮的就是如何對源碼進行切分,這需要使用到有限狀態(tài)機,即伴隨著源碼的不斷輸入,解析器能夠自動的在不同的狀態(tài)間進行遷移的過程,而有限則意味著狀態(tài)的種類是可枚舉完的
1-模擬源碼不斷輸入
使用while+substring每次刪除一個字符可以模擬字符的輸入
function parse(code){ while(code.length){ code = code.substring(1) } }
2-狀態(tài)遷移
我們根據(jù)html標簽的書寫規(guī)則來定義狀態(tài)遷移的條件,當遇到<時,將狀態(tài)從開始狀態(tài)標記為標簽開始;伴隨著while循環(huán)的執(zhí)行,首次遇到非空字符時,從標簽開始狀態(tài)切換為標簽名稱狀態(tài);當遇到>時,再從標簽名稱狀態(tài)切換為標簽初始狀態(tài)。至此形成一個閉環(huán),我們在這一個閉環(huán)內(nèi)記錄下的狀態(tài)集合則稱之為一個token,如圖所示
3-代碼實現(xiàn)
3.1 定義狀態(tài)機的狀態(tài)
const State = { // 初始 initial:1, // 標簽開始 start:2, // 標簽名稱 startName:3, // 標簽文本 text:4, // 標簽結(jié)束 end:5, // 標簽結(jié)束名稱 endName:6 }
3.2 編寫輔助函數(shù),判斷是否是字符
const isAlpha = function(char){ return /[a-zA-Z1-6]/.test(char) }
3.3 實現(xiàn)tokenize函數(shù)
通過while循環(huán)依次取得每一個字符,當遇到規(guī)則字符(如<或/或>)時,根據(jù)當前所處的狀態(tài)進行狀態(tài)遷移,當遷移回初始狀態(tài)時記錄一個token
function tokenize(code){ let currentState = State.initial const tokens = [] const chars = [] while(code.length){ const act = code[0] switch(currentState){ case State.initial: if(act === '<'){ currentState = State.start }else if(isAlpha(act)){ currentState = State.text chars.push(act) } break case State.start: if(isAlpha(act)){ currentState = State.startName chars.push(act) }else if(act === '/'){ currentState = State.end } break case State.startName: if(isAlpha(act)){ chars.push(act) }else if(act === '>'){ // 切到初始狀態(tài),形式閉環(huán),記錄token currentState = State.initial tokens.push({ type:'tag', name:chars.join('') }) chars.length = 0 } break case State.text: /** * 1.<div></div> act = i * 2.<div>我愛前端</div> act = 愛 */ if(isAlpha(act)){ chars.push(act) }else if(act === '<'){ currentState = State.start tokens.push({ type:'text', content:chars.join('') }) chars.length = 0 } break case State.end: // 當遇到/才會切換到結(jié)束標簽狀態(tài) if(isAlpha(act)){ currentState = State.endName chars.push(act) } break case State.endName: if(isAlpha(act)){ chars.push(act) }else if(act === '>'){ currentState = State.initial tokens.push({ type:'tagEnd', name:chars.join('') }) chars.length = 0 } break } code = code.substring(1) } return tokens }
運行結(jié)果如下:
- 生成tAst
由于vue是在js下實現(xiàn)的編譯器,并不會創(chuàng)造新的運算符號,所以并不需要進行遞歸下降才能實現(xiàn)ast,我們只需要對上一步生成的tokens進行遍歷掃描即可
1-如何掃描
觀察我們生成的tokens,最先開始的div標簽,最后結(jié)束,同時,后進入的h1標簽是div標簽的子節(jié)點
因此,我們需要初始化一個棧,當遇到type為tag的標簽時向棧頂壓入一個ast節(jié)點,并將其作為前一個棧頂節(jié)點的子節(jié)點,當遇到type為tagEnd時則從棧頂彈出,標識一次標簽的完整匹配
2-代碼實現(xiàn)
2.1 初始化虛擬根節(jié)點
由于樹形結(jié)構必存在根節(jié)點,而html則是多根的,因此我們在代碼里初始化一個根
const root = { type:'Root', children:[] } 復制代碼
2.2 初始化棧
將虛擬根作為默認的棧頂,這樣在掃描實際的tokens時,就能默認作為其子節(jié)點了
const stack = [root] 復制代碼
2.3 創(chuàng)建節(jié)點
class Node{ constructor(type,tag){ this.type = type this.tag = tag this.children = [] } setContent(content){ this.content = content } } 復制代碼
2.4 掃描
依次從tokens中取出,并判斷其type類型,如果是tag則作為子節(jié)點向原棧頂追加,如果是tagEnd則從棧頂彈出
while(tokens.length){ const p = stack[stack.length - 1] const act = tokens.shift() switch(act.type){ case 'tag':{ const e = new Node('Element',act.name) p.children.push(e) stack.push(e) break } case 'text':{ const e = new Node('text') e.setContent(act.content) p.children.push(e) break } case 'tagEnd':{ stack.pop() } } }
2.5 生成的ast如下
- transform
現(xiàn)在,我們已經(jīng)完成了模板的ast化,接下來就是考慮如何將這個模板的ast轉(zhuǎn)化為js ast,這一過程我們稱之為transform,它定義了對ast節(jié)點操作的一系列方法
1-節(jié)點的訪問
節(jié)點操作的前提一定是先拿到這個節(jié)點,因此我們需要能夠遍歷到樹中的每一個節(jié)點
1.1 深度優(yōu)先遍歷
function transform(tAst){ const children = tAst.children if(Array.isArray(children)){ for(let i=0;i<children.length;i++){ transform(children[i]) } } }
1.2 定義訪問操作
如果將訪問操作的代碼內(nèi)置到transform當中,則該函數(shù)一定會又臭又長,且不易后續(xù)擴展,因此我們需要將該操作進行提取,ast的訪問應該算是訪問者模式的典型應用,不過為了保持和vue一致,咱們也采用函數(shù)回調(diào)的方式來實現(xiàn)
function transform(tAst,ctx){ const act = tAst const transforms = ctx.nodeTransforms for(let i=0;i<transforms;i++){ if(typeof transforms[i] === 'function'){ transforms[i](act,ctx) } } ...... }
2-擴展ctx
在進行節(jié)點操作之前,我們還需要動態(tài)的給ctx掛載一些狀態(tài)信息,用以標記當前transform的運行狀態(tài),比如當前運行的是哪一顆節(jié)點樹、當前的節(jié)點樹的父節(jié)點是誰、當前節(jié)點的兄弟節(jié)點是誰以及當前節(jié)點樹是父節(jié)點的第幾個子節(jié)點
function transform(tAst,ctx){ // 當前的節(jié)點樹 ctx.act = tAst const transforms = ctx.nodeTransforms for(let i=0;i<transforms.length;i++){ if(typeof transforms[i] === 'function'){ transforms[i](ctx.act,ctx) } } const children = ctx.act.children if(Array.isArray(children)){ // 當前節(jié)點的父節(jié)點 ctx.parent = ctx.act for(let i=0;i<children.length;i++){ // 當前節(jié)點樹是父節(jié)點的第幾個子節(jié)點 ctx.index = i // 當前節(jié)點的兄弟節(jié)點 ctx.siblings = [arr[i-1],arr[i+1]].map(v=>v||null) transform(children[i],ctx) } } }
3-節(jié)點替換
至此,我們的transform的主框架就搭好了,要實現(xiàn)節(jié)點替換就只需要在nodeTransforms中增加處理函數(shù)即可,比如我們將h1標簽替換為p標簽
function _replaceNode(newNode){ this.act = newNode this.parent.children[this.index] = newNode } function transformElement(node,ctx){ if(node.type === 'Element'){ switch(node.tag){ case 'h1': _replaceNode.call(ctx,{ type:'Element', tag:'p', children:node.children }) break } } }
4-等待子節(jié)點處理完畢
目前的實現(xiàn)中,在對當前節(jié)點進行處理時,其子節(jié)點一定還未被處理,但在實際需求中,往往需要等子節(jié)點處理完畢后再根據(jù)其執(zhí)行結(jié)果決定如何處理當前節(jié)點,因此需要對transform進行改進
我們?yōu)閚odeTransforms設計一個返回值,該值是一個函數(shù),當正向訪問結(jié)束后,使用該返回函數(shù)做反向遍歷即可
function transform(tAst,ctx){ // 退出回調(diào)列表 const cbs = [] ... const cb = transforms[i](ctx.act,ctx) if(typeof cb === 'function'){ cbs.push(cb) } ... // 退出 let i = cbs.length while(i--){ cbs[i]() } }
5-生成js ast
由于我們最終的產(chǎn)物是一個render函數(shù),因此需要將模板ast轉(zhuǎn)換為js ast,以前文的模板為例
<div> <h1>123</h1> </div>
其對應的render函數(shù)如下
function render(){ return h('div',h('h1','123')) }
5.1 ast節(jié)點類型
在模板的ast節(jié)點定義時,我們把一個元素節(jié)點視為一個ast節(jié)點,而在JavaScript中,則為一條js語句等同于一個ast節(jié)點
觀察render函數(shù)的js代碼,不難發(fā)現(xiàn),其由函數(shù)定義、函數(shù)參數(shù)和函數(shù)返回值三部分構成,同樣的,我們使用type來標記其類型
另外,我們的目標代碼是明確的,并非所有的js語句,因此,我們可以定義任何的type名稱來做專屬標識,比如我就想使用Function來表示render函數(shù),使用ReturenCb來表示h函數(shù)......
本文,使用FunctionDecl+id.name標識render函數(shù);params標識render函數(shù)的參數(shù);body標識render函數(shù)的函數(shù)體,由于函數(shù)體內(nèi)又可能存在多個js語句,因此它被設計為一個數(shù)組,最后使用ReturnStatement標識return語句,其返回的是一個h函數(shù),而參數(shù)使用arguments標記
{ type:'FunctionDecl', id:{ type:"Identifier", name:"render" }, params:[], body:[ { type:"ReturnStatement", return:{ type:"CallExpression", callee:{ type:"Identifier", name:"h" }, arguments:[ { type:"StringLiteral", value:"div" }, { type:"ArrayExpression", elements:[ //CallExpression類型, //CallExpression類型, ] } ] } } ] }
5.2 定義類型生成器
編寫一個newType函數(shù)用于統(tǒng)一處理各種節(jié)點類型的生成
function _newType(type,value,arguments){ const o = { type, } switch(type){ case 'StringLiteral': o.value = value break case 'Identifier': o.name = value break case 'ArrayExpression': o.elements = value break case 'CallExpression': o.callee = _newType('Identifier',value) o.arguments = arguments break } return o }
5.3 為tAst添加jsCode屬性收集當前節(jié)點的轉(zhuǎn)換結(jié)果
5.4 重新實現(xiàn)transformElement函數(shù)
對當前語句的處理必須等到子節(jié)點轉(zhuǎn)換完畢,因為只有此時jscode才是可用的
function transformElement(node,ctx){ return ()=>{ if(node.type === 'Element'){ } } }
從5.1的節(jié)點類型定義可以知道,每一個節(jié)點本質(zhì)上都是一個h函數(shù)
const callee = _newType('CallExpression','h',[ _newType('StringLiteral',node.tag) // 參數(shù)二取決于子節(jié)點的數(shù)量,需要動態(tài)生成 ])
生成參數(shù)二
node.children.length === 1 ? callee.arguments.push(node.children[0].jsCode) : callee.arguments.push(node.children.map(v=>v.jsCode))
最后將當前節(jié)點的轉(zhuǎn)換結(jié)果掛載到jsCode
node.jsCode = callee
5.5 新增transformRoot函數(shù)
至此,我們已經(jīng)完成了對實際模板節(jié)點的轉(zhuǎn)化,即
將
<div> <h1>123</h1> </div>
轉(zhuǎn)為了
h('div',[ h('h1','123') ])
因此我們還需要處理生成render函數(shù),而這正好與我們在一開始生成的虛擬根節(jié)點相對應
function transformRoot(node){ return ()=>{ if(node.type === 'Root'){ node.jsCode = { type:"FunctionDecl", id:_newType("Identifier","render"), params:[], body:[ { type:"ReturnStatement", return:node.children[0].jsCode } ] } } } }
6-轉(zhuǎn)換結(jié)果如下
- 生成目標代碼
我們在前一部分已經(jīng)為根節(jié)點添加了jsCode屬性,該屬性就是tAst所對應的jsAst,因此我們只需要找到每一個節(jié)點并將他們轉(zhuǎn)化成字符串進行拼接就可以了
1-定義上下文
我們說了,代碼生成本質(zhì)上是在做字符串的拼接工作,為此我們將拼接時出現(xiàn)頻率較大的函數(shù)定義在上下文中以方便復用,其中的code就是我們最終生成的代碼的容器,而newLine則更多是為了生成代碼的可讀性
const ctx = { code: "", append(code) { this.code += code; }, newLine(indent = 1) { this.code += "\n" + " ".repeat(indent * 2); }, };
2-定義類型生成函數(shù)
2.1 首先我們將每一種js節(jié)點類型所對應的生成函數(shù)放到一個統(tǒng)一的genMap中
const genMap = new Map([ ['FunctionDecl',genFunctionDecl], ['ReturnStatement',genReturnStatement], ['StringLiteral',genStringLiteral], ['CallExpression',genCallExpression], ['ArrayExpression',genArrayExpression] ])
2.2 對參數(shù)的遍歷生成單獨做一個genParams
function genParams(nodes,ctx) { nodes.forEach((v,i)=>{ genMap.get(v.type)(v,ctx) if(i<nodes.length-1){ ctx.append(',') } }) }
2.3 分別實現(xiàn)
分別對函數(shù)名稱、函數(shù)參數(shù)、函數(shù)體做生成,他們都在節(jié)點中有著一一對應的節(jié)點屬性
2.3.1 genFunctionDecl
代碼實現(xiàn)
function genFunctionDecl(node, ctx) { ctx.append(`function ${node.id.name}(`); genParams(node.params, ctx); ctx.append("){"); ctx.newLine(); node.body.forEach((v) => genMap.get(v.type)(v, ctx)); ctx.newLine(0) ctx.append('}') }
生成結(jié)果
2.3.2 genReturnStatement
該函數(shù)就是為code拼接return字符,至于真正的函數(shù)體,是由genCallExpression完成的
代碼實現(xiàn)
function genReturnStatement(node,ctx) { ctx.append('return ') genMap.get(node.return.type)(node.return,ctx) }
生成結(jié)果
2.3.3 genCallExpression
代碼實現(xiàn)
function genCallExpression(node,ctx) { ctx.append(`${node.callee.name}(`) genParams(node.arguments,ctx) ctx.append(')') }
生成結(jié)果
2.3.4 genStringLiteral
代碼實現(xiàn)
function genStringLiteral(node,ctx) { ctx.append(`'${node.value}'`) }
生成結(jié)果
2.3.5 genArrayExpression
目前在我們的示例中是不存在該類型的,因此我們將模板源碼做下調(diào)整
<div> <h1>123</h1> <h2>456</h2> </div>
代碼實現(xiàn)
function genArrayExpression(node,ctx) { ctx.append('[') genParams(node.elements,ctx) ctx.append(']') }
生成結(jié)果
3-最終的完整生成結(jié)果
到此這篇關于一文搞懂vue編譯器(DSL)原理的文章就介紹到這了,更多相關vue編譯器DSL內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
element-ui 限制日期選擇的方法(datepicker)
本篇文章主要介紹了element-ui 限制日期選擇的方法(datepicker),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-05-05fullcalendar日程管理插件月份切換回調(diào)處理方案
這篇文章主要為大家介紹了fullcalendar日程管理插件月份切換回調(diào)處理的方案示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-03-03vue項目中解決 IOS + H5 滑動邊界橡皮筋彈性效果(解決思路)
最近遇到一個問題,我們在企業(yè)微信中的 H5 項目中需要用到table表格(支持懶加載 上劃加載數(shù)據(jù)),但是他們在鎖頭、鎖列的情況下,依舊會出現(xiàn)邊界橡皮筋效果,這篇文章主要介紹了vue項目中解決 IOS + H5 滑動邊界橡皮筋彈性效果,需要的朋友可以參考下2023-02-02如何使用Webstorm和Chrome來調(diào)試Vue項目
這篇文章主要介紹了如何使用Webstorm和Chrome來調(diào)試Vue項目,對Vue感興趣的同學,一定要看一下2021-05-05nuxt框架中對vuex進行模塊化設置的實現(xiàn)方法
這篇文章主要介紹了nuxt框架中對vuex進行模塊化設置的實現(xiàn)方法,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-09-09