60行程式碼實現簡單模板語法

尹光耀發表於2018-12-09

不久前看過一篇不錯的文章,作者用了15行程式碼就實現了一個簡單的模板引擎,我覺得很有趣,建議在讀這篇文章之前先看一下這個,這裡是傳送門:只有20行的Javascript模板引擎

這個模板引擎實現的核心點是利用正規表示式來匹配到模板語法裡面的變數和JS語句,再將這些匹配到的欄位push到一個陣列中,最後連線起來,用Function來解析字串,最後將執行後的結果放到指定DOM節點的innerHTML裡面。

但是這個模板引擎還是有很多不足,比如不支援取餘運算,不支援自定義模板語法,也不支援if、for、switch之外的JS語句,缺少HTML實體編碼。

恰好我這陣子也在看underscore原始碼,於是就參考了一下underscore中template方法的實現。

這個是我參考template後實現的模板,一共只有60行程式碼。

(function () {
    var root = this;
    var html2Entity = (function () {
        var escapeMap = {
            '&': '&',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#x27;',
            '`': '&#x60;'
        };
        var escaper = function (match) {
            return escapeMap[match];
        };
        return function (string) {
            var source = "(" + Object.keys(escapeMap).join("|") + ")";
            var regexp = RegExp(source), regexpAll = RegExp(source, "g");
            return regexp.test(string) ? string.replace(regexpAll, escaper) : string;
        }
    }())
    var escapes = {
        '"': '"',
        "'": "'",
        "\\": "\\",
        '\n': 'n',
        '\r': 'r',
        '\u2028': 'u2028',
        '\u2029': 'u2029'
    }
    var escaper = /\\|'|"|\r|\n|\u2028|\u2029/g;
    var convertEscapes = function (match) {
        return "\\" + escapes[match];
    }
    var template = function (tpl, settings) {
        var templateSettings = Object.assign({}, {
            interpolate: /<%=([\s\S]+?)%>/g,
            escape: /<%-([\s\S]+?)%>/g,
            evaluate: /<%([\s\S]+?)%>/g,
        }, template.templateSettings);
        settings = Object.assign({}, settings);
        var matcher = RegExp(Object.keys(templateSettings).map(function (key) {
            return templateSettings[key].source
        }).join("|") + "|$", "g")
        var source = "", index = 0;
        tpl.replace(matcher, function (match, interpolate, escape, evaluate, offset) {
            source += "__p += '" + tpl.slice(index, offset).replace(escaper, convertEscapes) + "'\n";
            index = offset + match.length;
            if (evaluate) {
                source += evaluate + "\n"
            } else if (interpolate) {
                source += "__p += (" + interpolate + ") == null ? '' : " + interpolate + ";\n"
            } else if (escape) {
                source += "__p += (" + escape + ") == null ? '' : " + html2Entity(escape) + ";\n"
            }
            return match;
        })
        source = "var __p = '';" + source + 'return __p;'
        if (!settings.variable) source = "with(obj||{}) {\n" + source + "\n}"
        var render = new Function(settings.variable || "obj", source);
        return render
    }
    root.templateY = template
}.call(this))
複製程式碼

轉義

我們知道,在字串中有一些特殊字元是需要轉義的,比如"'", '"',不然就會和預期展示不一致,甚至是報錯,所以我們一般會用反斜槓來表示轉義,常見的轉義字元有\n, \t, \r等等。

但是這裡的convertEscapes裡面我們為什麼要多加一個反斜槓呢?

這是因為在執行new Function裡面的語句時,也需要對字元進行一次轉義,可以看一下下面這行程式碼:

var log = new Function("var a = '1\n23';console.log(a)");
log() // Uncaught SyntaxError: Invalid or unexpected token
複製程式碼

這是因為Function函式在執行的時候,裡面的內容被解析成了這樣。

var a = '1
23';console.log(a)
複製程式碼

在JS裡面是不允許字串換行出現的,只能使用轉義字元\n。

正規表示式

underscore中摒棄了用正規表示式匹配for/if/switch/{/}等語句的做法,而是使用了不同的模板語法(<%=%>和<%%>)來區分當前是變數還是JS語句,這樣雖然需要使用者自己區分語法,但是給開發者減少了很多不必要的麻煩,因為如果用正則來匹配,那麼後面就無法使用類似{# #}和{{}}的語法了。 這裡正規表示式的重點是+?,+?是惰性匹配,表示以最少的次數匹配到[\s\S],所以我們/<%=([\s\S]+?)%>/g是不會匹配到類似<%=name<%=age%>%>這種語法的,只會匹配到<%=name%>語法。

replace

這裡我們用到了replace第二個引數是函式的情況。

var pattern = /([a-z]+)\s([a-z]+)/;
var str = "hello world";
str.replace(pattern, function(match, p1, p2, offset) {
    // p1 is "hello"
    // p2 is "world"
    return match;
})
複製程式碼

在JS正規表示式中,使用()包起來的叫著捕獲性分組,而使用(?:)的叫著非捕獲性分組,在replace的第二個引數是函式時,每次匹配都會執行一次這個函式,這個函式第一個引數是pattern匹配到的字串,在這個裡面是"hello world"。

p1是第一個分組([a-z]+)匹配到的字串,p2是第二個分組([a-z]+)匹配到的字串,如果有更多的分組,那還會有更多引數p3, p4, p5等等,offset是最後一個引數,指的是在第幾個索引處匹配到了,這裡的offset是0,因為是從一開始就剛好匹配到了hello world。

字串拼接

underscore中使用+=字串拼接的方式代替了陣列push的方式,這樣是因為+=相比push的效能會更高。

setting.variable

underscore這裡使用with來改變了作用域,但是with會導致效能比較差,關於with的弊端可以參考一下這篇文章: Javascript中的with關鍵字

你還可以在variable設定裡指定一個變數名,這樣能顯著提升模板的渲染速度。不過語法也和之前有一些不同,模板裡面必須要用你指定的變數名來訪問,而不能直接用answer這種形式,這種形式下沒有使用with實現,所以效能會高很多。

_.template("Using 'with': <%= data.answer %>", {variable: 'data'})({answer: 'no'});
複製程式碼

參考連結:

  1. js正則進階
  2. JavaScript函式replace揭祕
  3. JavaScript正規表示式分組模式:捕獲性分組與非捕獲性分組及前瞻後顧
  4. underscore 系列之字元實體與 _.escape
  5. Javascript中的with關鍵字

相關文章