前端面試-模板編譯

Runningfyy發表於2021-10-18

image.png

1.前言

今天看到一道面試題,挺有意思的。
研究了一下,彙報一下學習所得。

const tmp = `
<h1>{{person.name}}</h1>
<h2>money:{{person.money}}</h1>
<h3>mother:{{parents[1]}}</h1>
`
//需要編寫render函式
const html = render(tmp, {
  person: {
    name: 'petter',
    money: '10w',
  },
  parents: ['Mr jack','Mrs lucy']
});

//期望的輸出
const expect = `
  <h1>petter</h1>
  <h2>money:100w</h2>
  <h2>mother:Mrs lucy</h2>
`

2.簡單模板編譯

2.1思路一:正則替換

1.先遍歷data找出所有的值

const val = {
 'person.name': 'petter',
 'person.money': '100w',
 'person.parents[0]': 'Mr jack'
 'person.parents[1]': 'Mrs lucy'
}

2.遍歷val,如果模板中有val,則全域性替換

這樣做有兩個問題,一個是陣列不好處理。第二個是層級不好處理。層級越深效能越差

2.2思路二:new Function + with

1.先把所有的大鬍子語法轉成標準的字串模板
2.利用new Function(' with (data){return 轉化後的模板 }')
這樣模板中的就可以直接使用${person.money}這種資料不需要額外轉化

const render = (tmp,data)=>{
    const genCode = (temp)=>{
        const reg = /\{\{(\S+)\}\}/g
        return temp.replace(reg,function(...res){
            return '${'+res[1]+'}'
        })
    }
    const code = genCode(tmp)
    const fn = new Function(
        
        'data',`with(data){ return \`${code}\` }`)  
    return fn(data)
}

我們看一下fn函式的效果

//console.log(fn.toString())
function anonymous(data) {
    with(data){ return `
        <h1>${person.name}</h1>
        <h2>money:${person.money}</h1>
        <h3>mother:${parents[1]}</h1>` 
        }
    }

這樣很好的解決的方案一的一些問題

3.帶邏輯的高階編譯

一般面試的時候不會帶有邏輯語法,但是我們需要知道邏輯語法的處理思路。

邏輯沒法用正則替換直接處理。我們只能用正則去匹配到這一段邏輯。
然後在語法框架下單獨去寫方法去處理邏輯。
所以我們首先需要拿到語法框架,也就是所謂的AST。它就是專門描述語法結構的一個物件

//比如現在的模板
const tmp = `
<h1>choose one person</h1>
<div #if="person1.money>person2.money">{{person1.name}}</div>
<div #else>{{person2.name}}</div>
// 資料
const obj = {
    person1: {
       money: 1000,
       name: '高帥窮'
    },
    person2: {
        money: 100000,
        name: '矮醜富'
     },
}
// 結果
let res = render(tmp,obj)
console.log(res) //<h1>choose one person</h1><div>矮醜富</div>
`

基本思路:
1.利用正則匹配拿到AST
2.利用AST去拼字串(字串裡面有一些方法,用來產出你所要的結果,需要提前定義好)
3.new function + with 去生成render函式
4.傳參執行render

3.1 生成ast

定義一個ast中節點的結構

class Node {
    constructor(tag,attrs,text){
        this.id = id++
        this.tag = tag
        this.text = this.handleText(text)
        this.attrs = attrs
        this.elseFlag = false
        this.ifFlag = false
        this.ifExp = ''
        this.handleAttrs()
    }
    handleText(text){
        let reg = /\{\{(\S+)\}\}/
        if(reg.test(text)){
            return text.replace(reg,function(...res){
                return res[1]
            })
        }else{
            return `\'${text}\'`
        }
       
    }
    handleAttrs(){
        const ifReg = /#if=\"(\S+)\"/
        const elesReg = /#else/
        if(elesReg.test(this.attrs)){
            this.elseFlag = true
        }
        const res = this.attrs.match(ifReg)
        if(res){
            this.ifFlag = true
            this.ifExp = res[1]
        }
    }
}

3.2 匹配正則 執行響應的回撥 拿到ast

我這裡寫的正則是每次匹配的是一行閉合標籤
如果匹配到則觸發相應的方法,將其轉化為一個節點存到ast陣列裡
每次處理完一行,則把它從tmep裡剪掉,再處理下一行,知道處理完

const genAST = (temp)=>{ //只適用標籤間沒有文字
        const root = []
        const blockreg =  /(\s*<(\w+)([^]*?)>([^>]*?)<\/\2>\s*)/    // ?一定要加 非貪婪模式 否則會匹配到後面啷個標籤
        while(temp ){
            let block = temp.match(blockreg)
            let node = new Node(block[2],block[3],block[4])
            root.push(node)
            temp = advance(temp,block[1].length)
        }
        return root
    }
    const ast = genAST(temp)
    console.log(ast) 

我們看一下拿到的ast

[
            Node {
                id: 1,
                tag: 'h1',
                text: "'choose one person'",
                attrs: '',
                elseFlag: false,
                ifFlag: false,
                ifExp: ''
            },
        Node {
            id: 2,
            tag: 'div',
            text: 'person1.name',
            attrs: ' #if="person1.money>person2.money"',
            elseFlag: false,
            ifFlag: true,
            ifExp: 'person1.money>person2.money'
        },
        Node {
            id: 3,
            tag: 'div',
            text: 'person2.name',
            attrs: ' #else',
            elseFlag: true,
            ifFlag: false,
            ifExp: ''
        }
    ]

3.2 拼字串

下面開始拼字串

const genCode = (ast)=>{
        let str = ''
        for(var i = 0;i<ast.length;i++){
            let cur = ast[i]
            if(!cur.ifFlag && !cur.elseFlag){
                str+=`str+=_c('${cur.tag}',${cur.text});`
            }else if(cur.ifFlag){
                str+=`str+=(${cur.ifExp})?_c('${cur.tag}',${cur.text})`
            }else if(cur.elseFlag){
                str+=`:_c('${cur.tag}',${cur.text});`
            }        
            
        }
        return str
    }
    const code = genCode(ast)
 

我們瞅一眼拼好的字串

//  console.log('code:',code) 
// code: str+=_c('h1','choose one person');str+=(person1.money>person2.money)?_c('div',person1.name):_c('div',person2.name);

3.3 生成render函式並執行

function render(){
    //...
   const fn = new Function('data',`with(data){ let str = ''; ${code} return str }`)  
   return fn(data)
}

   

我們瞅一眼最終的fn函式

// console.log(fn.toString())    
function anonymous(data) {
            with(data){ 
                let str = ''; 
                str+=_c('h1','choose one person');
                str+=(person1.money>person2.money)?_c('div',person1.name):_c('div',person2.name); 
                return str 
            }
        }

我們再定義一下_c,advance

 const creatEle=(type,text)=> `<${type}>${text}</${type}>`
 
 data._c = creatEle //這裡很重要 因為_c其實讀的是with中data引數的_c,一定要給賦值上

 const advance = (temp,n)=>{
        return temp.substring(n)
    }

3.4 完整程式碼

const tmp = `
<h1>choose one person</h1>
<div #if="person1.money>person2.money">{{person1.name}}</div>
<div #else>{{person2.name}}</div>
`
let id = 1
class Node {
    constructor(tag,attrs,text){
        this.id = id++
        this.tag = tag
        this.text = this.handleText(text)
        this.attrs = attrs
        this.elseFlag = false
        this.ifFlag = false
        this.ifExp = ''
        this.handleAttrs()
    }
    handleText(text){
        let reg = /\{\{(\S+)\}\}/
        if(reg.test(text)){
            return text.replace(reg,function(...res){
                return res[1]
            })
        }else{
            return `\'${text}\'`
        }
       
    }
    handleAttrs(){
        const ifReg = /#if=\"(\S+)\"/
        const elesReg = /#else/
        if(elesReg.test(this.attrs)){
            this.elseFlag = true
        }
        const res = this.attrs.match(ifReg)
        if(res){
            this.ifFlag = true
            this.ifExp = res[1]
        }
    }
}
const render = (temp,data)=>{
    const creatEle=(type,text)=> `<${type}>${text}</${type}>`
    data._c = creatEle
    const advance = (temp,n)=>{
        return temp.substring(n)
    }
    
    const genAST = (temp)=>{ //只適用標籤間沒有文字
        const root = []
        const blockreg =  /(\s*<(\w+)([^]*?)>([^>]*?)<\/\2>\s*)/    // ?一定要加 非貪婪模式 否則會匹配到後面啷個標籤
        while(temp ){
            let block = temp.match(blockreg)
            let node = new Node(block[2],block[3],block[4])
            root.push(node)
            temp = advance(temp,block[1].length)
        }
        return root
    }
    const ast = genAST(temp)
    console.log(ast) 
    
    const genCode = (ast)=>{
        let str = ''
        for(var i = 0;i<ast.length;i++){
            let cur = ast[i]
            if(!cur.ifFlag && !cur.elseFlag){
                str+=`str+=_c('${cur.tag}',${cur.text});`
            }else if(cur.ifFlag){
                str+=`str+=(${cur.ifExp})?_c('${cur.tag}',${cur.text})`
            }else if(cur.elseFlag){
                str+=`:_c('${cur.tag}',${cur.text});`
            }        
            
        }
        return str
    }
    const code = genCode(ast)
    console.log('code:',code) // code: str+=_c('h1','choose one person');str+=(person1.money>person2.money)?_c('div',person1.name):_c('div',person2.name);
    
    const fn = new Function('data',`with(data){ let str = ''; ${code} return str }`)  
    console.log(fn.toString())    
    
    return fn(data)
}

const obj = {
    person1: {
       money: 1000,
       name: '高帥窮'
    },
    person2: {
        money: 100000,
        name: '矮醜富'
     },
}
let res = render(tmp,obj)
console.log(res) //<h1>choose one person</h1><div>矮醜富</div>

3.5 優點與待改進點

首先可以肯定,模板編譯大家都是這麼做的,處理模板=>生成ast=>生成render函式=>傳參執行函式

好處: 由於模板不會變,一般都是data變,所以只需要編譯一次,就可以反覆使用
侷限性: 這裡說的侷限性是指我寫的方法的侷限性,
1.由於正則是專門為這道題寫的,所以模板格式換一換就正則就不生效了。根本原因是我的正則匹配的是類似一行標籤裡面的所有東西。我的感悟是匹配的越多,情況越複雜,越容易出問題。
2.node實現和if邏輯的實現上比較簡陋
改進點: 對於正則可以參考vue中的實現,匹配力度為開始便籤結束便籤。從而區分是屬性還是標籤還是文字。具體可以看下vue中的實現。

4.一些應用

1.pug

也是模板編譯成ast生成render然後再new Function,沒用with,但是實現了一個類似的方法,把引數一個個傳進去了,感覺不是特別好

const pug = require('pug');
const path = require('path')

const compiledFunction = pug.compile('p #{name1}的 Pug 程式碼,用來除錯#{obj}');
// console.log(compiledFunction.toString())
console.log(compiledFunction({
    name1: 'fyy',
    obj: 'compiler'
}));

//看一下編譯出的函式
// function template(locals) {
//     var pug_html = ""
//     var locals_for_with = (locals || {});

//     (function (name1, obj) {

//         pug_html = pug_html + "\u003Cp\u003E"; //p標籤

//         pug_html = pug_html + pug.escape(name1);
//         pug_html = pug_html + "的 Pug 程式碼,用來除錯";

//         pug_html = pug_html + pug.escape(obj) + "\u003C\u002Fp\u003E";
//     }.call(this,locals_for_with.name1,locals_for_with.obj));
//     return pug_html;
// }

附上除錯的關鍵圖
返回的是new Function的函式

看下compileBody裡面有啥 ,原來是生成了ast,看下它的ast原來是長這屌樣

再看一下根據ast生成的字串函式
image.png

2.Vue

vue的話後面會寫文章細說,先簡單看看

 //html
 <div id="app" a=1 style="color:red;background:lightblue">
        <li b="1">{{name}}</li>
 </div>
//script
let vm = new Vue({
            data() {

                return {
                    name:'fyy'
                }
            },
        });
        vm.$mount('#app')

我們看下這段程式碼是怎麼編譯的

function compileToFunction(template) {
    let root = parserHTML(template) //ast

    // 生成程式碼
    let code = generate(root)
    console.log(code)
    // _c('div',{id:"app",a:"1",style:{"color":"red","background":"lightblue"}},_c('li',{b:"1"},_v(_s(name))))   //name取的是this上的name
    let render = new Function(`with(this){return ${code}}`); // code 中會用到資料 資料在vm上

    return render;

    // html=> ast(只能描述語法 語法不存在的屬性無法描述) => render函式 + (with + new Function) => 虛擬dom (增加額外的屬性) => 生成真實dom
}

5.總結

總的來說,感覺模板編譯就是正則匹配生成ast+根據邏輯拼字串函式的一個過程,當然難點也就在這兩個地方。
萬幸,一般面試估計只會出的2.2的難度。本文章知識點應該是能完全覆蓋的。如果不寫框架的話,懂這些應該夠用了。
後面的文章會具體分析下vue是怎麼做這塊的

相關文章