不用正規表示式,用javascript從零寫一個模板引擎(一)

阿集啊發表於2019-02-27

前言

模板引擎的作用就是將模板渲染成html,html = render(template,data),常見的js模板引擎有Pug,Nunjucks,Mustache等。網上一些製作模板引擎的文章大部分是用正規表示式做一些hack工作,看完能收穫的東西很少。本文將使用編譯原理那套理論來打造自己的模板引擎。之前玩過一年Django,還是偏愛那套模板引擎,這次就打算自己用js寫一個,就叫jstemp

預覽功能

寫一個庫,不可能一次性把所有功能全部實現,所以我們第一版就挑一些比較核心的功能

var jstemp = require('jstemp');
// 渲染變數
jstemp.render('{{value}}', {value: 'hello world'});// hello world


// 渲染if/elseif/else表示式 
jstemp.render('{% if value1 %}hello{% elseif value %}world{% else %}byebye{% endif %}', {value: 'hello world'});// world

// 渲染列表
jstemp.render('{%for item : list %}{{item}}{%endfor%}', {list:[1, 2, 3]});// 123複製程式碼

詞法分析

詞法分析就是將字串分割成一個一個有意義的token,每個token都有它要表達的意義,供語法分析器去建AST。
jstemp的token型別如下

{
    EOF: 0, // 檔案結束
    Character: 1, // 字串
    Variable: 2, // 變數開始{{
    VariableName: 3, // 變數名
    IfStatement: 4,// if 語句
    IfCondition: 5,// if 條件
    ElseIfStatement: 6,// else if 語句
    ElseStatement: 7,// else 語句
    EndTag: 8,// }},%}這種閉合標籤
    EndIfStatement: 9,// endif標籤
    ForStatement: 10,// for 語句
    ForItemName: 11,// for item 的變數名
    ForListName: 12,// for list 的變數名
    EndForStatement: 13// endfor 標籤
};複製程式碼

一般來說,詞法分析有幾種方法(歡迎補充)

  • 使用正規表示式
  • 使用開源庫解析,如ohm,yacc,lex
  • 自己寫有窮狀態自動機進行解析

作者本著自虐的心理,採取了第三種方法。

舉例說明有窮狀態自動機,解析<p>{{value}}</p>的過程

輸入圖片說明
輸入圖片說明

  1. Init 狀態
  2. 遇到<,轉Char狀態
  3. 直到遇到{轉化為LeftBrace,返回一個token
  4. 再遇{轉Variable狀態,返回一個token
  5. 解析value,直到}},再返回一個token
  6. }}後再轉狀態,再返回token,轉init狀態

結果是{type:Character,value:'<p>'},{type:Variable},{type:VariableName, valueName: 'value'},{type:EndTag},{type:Character,value:'</p>'}這五個token。(當然如果你喜歡,可以把{{value}}當作一個token,但是我這裡分成了五個)。最後因為考慮到空格和if/elseif/else,for等情況,狀態機又複雜了許多。

程式碼的話就是一個迴圈加一堆switch 轉化狀態(特別很累,也很容易出錯),有一些情況我也沒考慮全。截一部分程式碼下來看

nextToken() {
        Tokenizer.currentToken = '';
        while (this.baseoffset < this.template.length) {
            switch (this.state) {
                case Tokenizer.InitState:
                    if (this.template[this.baseoffset] === '{') {
                        this.state = Tokenizer.LeftBraceState;
                        this.baseoffset++;
                    }
                    else if (this.template[this.baseoffset] === '\\') {
                        this.state = Tokenizer.EscapeState;
                        this.baseoffset++;
                    }
                    else {
                        this.state = Tokenizer.CharState;
                        Tokenizer.currentToken += this.template[this.baseoffset++];
                    }
                    break;
                case Tokenizer.CharState:
                    if (this.template[this.baseoffset] === '{') {
                        this.state = Tokenizer.LeftBraceState;
                        this.baseoffset++;
                        return TokenType.Character;
                    }
                    else if (this.template[this.baseoffset] === '\\') {
                        this.state = Tokenizer.EscapeState;
                        this.baseoffset++;
                    }
                    else {
                        Tokenizer.currentToken += this.template[this.baseoffset++];
                    }
                    break;
                case Tokenizer.LeftBraceState:
                    if (this.template[this.baseoffset] === '{') {
                        this.baseoffset++;
                        this.state = Tokenizer.BeforeVariableState;
                        return TokenType.Variable;
                    }
                    else if (this.template[this.baseoffset] === '%') {
                        this.baseoffset++;
                        this.state = Tokenizer.BeforeStatementState;
                    }
                    else {
                        this.state = Tokenizer.CharState;
                        Tokenizer.currentToken += '{' + this.template[this.baseoffset++];
                    }
                    break;
                // ...此處省去無數case
                default:
                    console.log(this.state, this.template[this.baseoffset]);
                    throw Error('錯誤的語法');
            }
        }
        if (this.state === Tokenizer.InitState) {
            return TokenType.EOF;
        }
        else if (this.state === Tokenizer.CharState) {
            this.state = Tokenizer.InitState;
            return TokenType.Character;
        }
        else {
           throw Error('錯誤的語法');
        }
    }複製程式碼

具體程式碼看這裡

語法分析

當我們將字串序列化成一個個token後,就需要建AST樹。樹的根節點rootNode為一個childNodes陣列用來連線子節點

let rootNode = {childNodes:[]}複製程式碼

字串節點

{
    type:'character',
    value:'123'
}複製程式碼

變數節點

{
    type:'variable',
    valueName: 'name'
}複製程式碼

if 表示式的節點和for表示式節點可以巢狀其他語句,所以要多一個childNodes陣列來裝語句內的表示式,childNodes 可以裝任意的node,然後我們解析的時候遞迴向下解析。elseifNodes 裝elseif/else 節點,解析的時候,當if的conditon為false的時候,按順序取elseifNodes陣列裡的節點,誰的condition為true,就執行誰的childNodes,然後返回結果。

// if node
{
    type:'if',
    condition: '',
    elseifNodes: [],
    childNodes:[],
}
// elseif node
{
    type: 'elseif',// 其實這個屬性沒用
    condition: '',
    childNodes:[]
}
// else node
{
    type: 'elseif',// 其實這個屬性沒用
    condition: true,
    childNodes:[]
}複製程式碼

for節點

{
    type:'for',
    itemName: '',
    listName: '',
    childNodes: []
}複製程式碼

舉例:

let template = `
<p>how to</p>
{%for num : list %}
    let say{{num.num}}
{%endfor%}
{%if obj%}
    {{obj.test}}
{%else%}
    hello world
{%endif%}
`;

// AST樹為
let rootNode = {
    childNode:[
        {
            type:'char',
            value: '<p>how to</p>'
        },
        {
            type:'for',
            itemName: 'num',
            listName: 'list',
            childNodes:[
                {
                    type:'char',
                    value:'let say',
                },
                {
                    type: 'variable',
                    valueName: 'num.num'
                }
            ]
        },
        {
            type:'if',
            condition: 'obj',
            childNodes: [
                {
                    type: 'variable',
                    valueName: 'obj.test'
                }
            ],
            elseifNodes: [
                {
                    type: 'elseif',
                    condition:true,
                    childNodes:[
                        {
                            type: 'char',
                            value: 'hello world'
                        }
                    ]
                }
            ]
        }
    ]
}複製程式碼

具體建樹邏輯可以看程式碼

解析AST樹

解析變數節點
從rootNode節點開始解析

let html = '';
for (let node of rootNode.childNodes) {
    html += calStatement(env, node);
}複製程式碼

calStatement為所有語句的解析入口

function calStatement(env, node) {
    let html = '';
    switch (node.type) {
        case NodeType.Character:
            html += node.value;
            break;
        case NodeType.Variable:
            html += calVariable(env, node.valueName);
            break;
        case NodeType.IfStatement:
            html += calIfStatement(env, node);
            break;
        case NodeType.ForStatement:
            html += calForStatement(env, node);
            break;
        default:
            throw Error('未知node type');
    }
    return html;
}複製程式碼

解析變數

// env為資料變數如{value:'hello world'},valueName為變數名
function calVariable(env, valueName) {
    if (!valueName) {
        return '';
    }
    let result = env;
    for (let name of valueName.split('.')) {
        result  = result[name];
    }
    return result;
}複製程式碼

解析if 語句及condition 條件

// 目前只支援變數值判斷,不支援||,&&,<=之類的表示式
function calConditionStatement(env, condition) {
    if (typeof condition === 'string') {
        return !!calVariable(env, condition);
    }
    return !!condition;
}

function calIfStatement(env, node) {
    let status = calConditionStatement(env, node.condition);
    let result = '';
    if (status) {
        for (let childNode of node.childNodes) {
            // 遞迴向下解析子節點
            result += calStatement(env, childNode);
        }
        return result;
    }

    for (let elseifNode of node.elseifNodes) {
        let elseIfStatus = calConditionStatement(env, elseifNode.condition);
        if (elseIfStatus) {
            for (let childNode of elseifNode.childNodes) {
                // 遞迴向下解析子節點
                result += calStatement(env, childNode);
            }
            return result;
        }
    }
    return result;
}複製程式碼

解析for節點

function calForStatement(env, node) {
    let result = '';
    let obj = {};
    let name = node.itemName.split('.')[0];
    for (let item of env[node.listName]) {
        obj[name] = item;
        let statementEnv = Object.assign(env, obj);
        for (let childNode of node.childNodes) {
            // 遞迴向下解析子節點
            result += calStatement(statementEnv, childNode);
        }
    }
    return result;
}複製程式碼

結束語

目前的實現的jstemp功能還比較單薄,存在以下不足:

  1. 不支援模板繼承
  2. 不支援過濾器
  3. condition表示式支援有限
  4. 錯誤提示不夠完善
  5. 單元測試,持續整合沒有完善

...
未來將一步步完善,另外無恥求個star
github地址

相關文章