博客 / 詳情

返回

underscore 系列之實現一個模板引擎(下)

前言

本篇接着上篇 underscore 系列之實現一個模板引擎(上)。

鑑於本篇涉及的知識點太多,我們先來介紹下會用到的知識點。

反斜槓的作用

var txt = "We are the so-called "Vikings" from the north."
console.log(txt);

我們的本意是想打印帶 "" 包裹的 Vikings 字符串,但是在 JavaScript 中,字符串使用單引號或者雙引號來表示起始或者結束,這段代碼會報 Unexpected identifier 錯誤。

如果我們就是想要在字符串中使用單引號或者雙引號呢?

我們可以使用反斜槓用來在文本字符串中插入省略號、換行符、引號和其他特殊字符:

var txt = "We are the so-called \"Vikings\" from the north."
console.log(txt);

現在 JavaScript 就可以輸出正確的文本字符串了。

這種由反斜槓後接字母或數字組合構成的字符組合就叫做“轉義序列”。

值得注意的是,轉義序列會被視為單個字符。

我們常見的轉義序列還有 \n 表示換行、\t 表示製表符、\r 表示回車等等。

轉義序列

在 JavaScript 中,字符串值是一個由零或多個 Unicode 字符(字母、數字和其他字符)組成的序列。

字符串中的每個字符均可由一個轉義序列表示。比如字母 a,也可以用轉義序列 \u0061 表示。

轉義序列以反斜槓 \ 開頭,它的作用是告知 JavaScript 解釋器下一個字符是特殊字符。

轉義序列的語法為 \uhhhh,其中 hhhh 是四位十六進制數。

根據這個規則,我們可以算出常見字符的轉義序列,以字母 m 為例:

// 1. 求出字符 `m` 對應的 unicode 值
var unicode = 'm'.charCodeAt(0) // 109
// 2. 轉成十六進制
var result = unicode.toString(16); // "6d"

我們就可以使用 \u006d 表示 m,不信你可以直接在瀏覽器命令行中直接輸入字符串 '\u006d',看下打印結果。

值得注意的是: \n 雖然也是一種轉義序列,但是也可以使用上面的方式:

var unicode = '\n'.charCodeAt(0) // 10
var result = unicode.toString(16); // "a"

所以我們可以用 \u000A 來表示換行符 \n,比如在瀏覽器命令行中直接輸入 'a \n b''a \u000A b' 效果是一樣的。

講了這麼多,我們來看看一些常用字符的轉義序列以及含義:

Unicode 字符值 轉義序列 含義
u0009 t 製表符
u000A n 換行
u000D r 回車
u0022 " 雙引號
u0027 ' 單引號
u005C \ 反斜槓
u2028 行分隔符
u2029 段落分隔符

Line Terminators

Line Terminators,中文譯文行終結符。像空白字符一樣,行終結符可用於改善源文本的可讀性。

在 ES5 中,有四個字符被認為是行終結符,其他的折行字符都會被視為空白。

這四個字符如下所示:

字符編碼值 名稱
u000A 換行符
u000D 回車符
u2028 行分隔符
u2029 段落分隔符

Function

試想我們寫這樣一段代碼,能否正確運行:

var log = new Function("var a = '1\t23';console.log(a)");
log()

答案是可以,那下面這段呢:

var log = new Function("var a = '1\n23';console.log(a)");
log()

答案是不可以,會報錯 Uncaught SyntaxError: Invalid or unexpected token

這是為什麼呢?

這是因為在 Function 構造函數的實現中,首先會將函數體代碼字符串進行一次 ToString 操作,這時候字符串變成了:

var a = '1
23';console.log(a)

然後再檢測代碼字符串是否符合代碼規範,在 JavaScript 中,字符串表達式中是不允許換行的,這就導致了報錯。

為了避免這個問題,我們需要將代碼修改為:

var log = new Function("var a = '1\\n23';console.log(a)");
log()

其實不止 \n,其他三種 行終結符,如果你在字符串表達式中直接使用,都會導致報錯!

之所以講這個問題,是因為在模板引擎的實現中,就是使用了 Function 構造函數,如果我們在模板字符串中使用了 行終結符,便有可能會出現一樣的錯誤,所以我們必須要對這四種 行終結符 進行特殊的處理。

特殊字符

除了這四種 行終結符 之外,我們還要對兩個字符進行處理。

一個是 \

比如説我們的模板內容中使用了\:

var log = new Function("var a = '1\23';console.log(a)");
log(); // 1

其實我們是想打印 '123',但是因為把 \ 當成了特殊字符的標記進行處理,所以最終打印了 1。

同樣的道理,如果我們在使用模板引擎的時候,使用了 \ 字符串,也會導致錯誤的處理。

第二個是 '

如果我們在模板引擎中使用了 ',因為我們會拼接諸如 p.push(' ') 等字符串,因為 ' 的原因,字符串會被錯誤拼接,也會導致錯誤。

所以總共我們需要對六種字符進行特殊處理,處理的方式,就是正則匹配出這些特殊字符,然後比如將 \n 替換成 \\n\ 替換成 \\' 替換成 \\',處理的代碼為:

var escapes = {
    "'": "'",
    '\\': '\\',
    '\r': 'r',
    '\n': 'n',
    '\u2028': 'u2028',
    '\u2029': 'u2029'
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

var escapeChar = function(match) {
    return '\\' + escapes[match];
};

我們測試一下:

var str = 'console.log("I am \n Kevin");';
var newStr = str.replace(escapeRegExp, escapeChar);

eval(newStr)
// I am 
// Kevin

replace

我們來講一講字符串的 replace 函數:

語法為:

str.replace(regexp|substr, newSubStr|function)

replace 的第一個參數,可以傳一個字符串,也可以傳一個正則表達式。

第二個參數,可以傳一個新字符串,也可以傳一個函數。

我們重點看下傳入函數的情況,簡單舉一個例子:

var str = 'hello world';
var newStr = str.replace('world', function(match){
    return match + '!'
})
console.log(newStr); // hello world!

match 表示匹配到的字符串,但函數的參數其實不止有 match,我們看個更復雜的例子:

function replacer(match, p1, p2, p3, offset, string) {
    // match,表示匹配的子串 abc12345#$*%
    // p1,第 1 個括號匹配的字符串 abc
    // p2,第 2 個括號匹配的字符串 12345
    // p3,第 3 個括號匹配的字符串 #$*%
    // offset,匹配到的子字符串在原字符串中的偏移量 0
    // string,被匹配的原字符串 abc12345#$*%
    return [p1, p2, p3].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer); // abc - 12345 - #$*%

另外要注意的是,如果第一個參數是正則表達式,並且其為全局匹配模式, 那麼這個方法將被多次調用,每次匹配都會被調用。

舉個例子,如果我們要在一段字符串中匹配出 <%=xxx%> 中的值:

var str = '<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>'

str.replace(/<%=(.+?)%>/g, function(match, p1, offset, string){
    console.log(match);
    console.log(p1);
    console.log(offset);
    console.log(string);
})

傳入的函數會被執行兩次,第一次的打印結果為:

<%=www.baidu.com%>
www.baidu.com
13
<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>

第二次的打印結果為:

<%=baidu%>
'baidu'
33
<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>

正則表達式的創建

當我們要建立一個正則表達式的時候,我們可以直接創建:

var reg = /ab+c/i;

也可以使用構造函數的方式:

new RegExp('ab+c', 'i');

值得一提的是:每個正則表達式對象都有一個 source 屬性,返回當前正則表達式對象的模式文本的字符串:

var regex = /fooBar/ig;
console.log(regex.source); // "fooBar",不包含 /.../ 和 "ig"。

正則表達式的特殊字符

正則表達式中有一些特殊字符,比如 \d 就表示了匹配一個數字,等價於 [0-9]。

在上節,我們使用 /<%=(.+?)%>/g 來匹配 <%=xxx%>,然而在 underscore 的實現中,用的卻是 /<%=([\s\S]+?)%>/g

我們知道 s 表示匹配一個空白符,包括空格、製表符、換頁符、換行符和其他 Unicode 空格,S
匹配一個非空白符,[sS]就表示匹配所有的內容,可是為什麼我們不直接使用 . 呢?

我們可能以為 . 匹配任意單個字符,實際上,並不是如此, .匹配除行終結符之外的任何單個字符,不信我們做個試驗:

var str = '<%=hello world%>'

str.replace(/<%=(.+?)%>/g, function(match){
    console.log(match); // <%=hello world%>
})

但是如果我們在 hello world 之間加上一個行終結符,比如説 'u2029':

var str = '<%=hello \u2029 world%>'

str.replace(/<%=(.+?)%>/g, function(match){
    console.log(match);
})

因為匹配不到,所以也不會執行 console.log 函數。

但是改成 /<%=([\s\S]+?)%>/g 就可以正常匹配:

var str = '<%=hello \u2029 world%>'

str.replace(/<%=([\s\S]+?)%>/g, function(match){
    console.log(match); // <%=hello 
 world%>
})

惰性匹配

仔細看 /<%=([\s\S]+?)%>/g 這個正則表達式,我們知道 x+ 表示匹配 x 1 次或多次。x?表示匹配 x 0 次或 1 次,但是 +? 是個什麼鬼?

實際上,如果在數量詞 *、+、? 或 {}, 任意一個後面緊跟該符號(?),會使數量詞變為非貪婪( non-greedy) ,即匹配次數最小化。反之,默認情況下,是貪婪的(greedy),即匹配次數最大化。

舉個例子:

console.log("aaabc".replace(/a+/g, "d")); // dbc

console.log("aaabc".replace(/a+?/g, "d")); // dddbc

在這裏我們應該使用非惰性匹配,舉個例子:

var str = '<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>'

str.replace(/<%=(.+?)%>/g, function(match){
    console.log(match);
})

// <%=www.baidu.com%>
// <%=baidu%>

如果我們使用惰性匹配:

var str = '<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>'

str.replace(/<%=(.+)%>/g, function(match){
    console.log(match);
})

// <%=www.baidu.com%>"><%=baidu%>

template

講完需要的知識點,我們開始講 underscore 模板引擎的實現。

與我們上篇使用數組的 push ,最後再 join 的方法不同,underscore 使用的是字符串拼接的方式。

比如下面這樣一段模板字符串:

<%for ( var i = 0; i < users.length; i++ ) { %>
    <li>
        <a href="<%=users[i].url%>">
            <%=users[i].name%>
        </a>
    </li>
<% } %>

我們先將 <%=xxx%> 替換成 '+ xxx +',再將 <%xxx%> 替換成 '; xxx __p+=':

';for ( var i = 0; i < users.length; i++ ) { __p+='
    <li>
        <a href="'+ users[i].url + '">
            '+ users[i].name +'
        </a>
    </li>
';  } __p+='

這段代碼肯定會運行錯誤的,所以我們再添加些頭尾代碼,然後組成一個完整的代碼字符串:

var __p='';
with(obj){
__p+='

';for ( var i = 0; i < users.length; i++ ) { __p+='
    <li>
        <a href="'+ users[i].url + '">
            '+ users[i].name +'
        </a>
    </li>
';  } __p+='

';
};
return __p;

整理下代碼就是:

var __p='';
with(obj){
    __p+='';
    for ( var i = 0; i < users.length; i++ ) { 
        __p+='<li><a href="'+ users[i].url + '"> '+ users[i].name +'</a></li>';
    }
    __p+='';
};
return __p

然後我們將 __p 這段代碼字符串傳入 Function 構造函數中:

var render = new Function(data, __p)

我們執行這個 render 函數,傳入需要的 data 數據,就可以返回一段 HTML 字符串:

render(data)

第五版 - 特殊字符的處理

我們接着上篇的第四版進行書寫,不過加入對特殊字符的轉義以及使用字符串拼接的方式:

// 第五版
var settings = {
    // 求值
    evaluate: /<%([\s\S]+?)%>/g,
    // 插入
    interpolate: /<%=([\s\S]+?)%>/g,
};

var escapes = {
    "'": "'",
    '\\': '\\',
    '\r': 'r',
    '\n': 'n',
    '\u2028': 'u2028',
    '\u2029': 'u2029'
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

var template = function(text) {

    var source = "var __p='';\n";
    source = source + "with(obj){\n"
    source = source + "__p+='";

    var main = text
    .replace(escapeRegExp, function(match) {
        return '\\' + escapes[match];
    })
    .replace(settings.interpolate, function(match, interpolate){
        return "'+\n" + interpolate + "+\n'"
    })
    .replace(settings.evaluate, function(match, evaluate){
        return "';\n " + evaluate + "\n__p+='"
    })

    source = source + main + "';\n }; \n return __p;";

    console.log(source)

    var render = new Function('obj',  source);

    return render;
};

完整的使用代碼可以參考 template 示例五。

第六版 - 特殊值的處理

不過有一點需要注意的是:

如果數據中 users[i].url 不存在怎麼辦?此時取值的結果為 undefined,我們知道:

'1' + undefined // "1undefined"

就相當於拼接了 undefined 字符串,這肯定不是我們想要的。我們可以在代碼中加入一點判斷:

.replace(settings.interpolate, function(match, interpolate){
    return "'+\n" + (interpolate == null ? '' : interpolate) + "+\n'"
})

但是吧,我就是不喜歡寫兩遍 interpolate …… 嗯?那就這樣吧:

var source = "var __t, __p='';\n";

...

.replace(settings.interpolate, function(match, interpolate){
    return "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'"
})

其實就相當於:

var __t;

var result = (__t = interpolate) == null ? '' : __t;

完整的使用代碼可以參考 template 示例六。

第七版

現在我們使用的方式是將模板字符串進行多次替換,然而在 underscore 的實現中,只進行了一次替換,我們來看看 underscore 是怎麼實現的:

var template = function(text) {
    var matcher = RegExp([
        (settings.interpolate).source,
        (settings.evaluate).source
    ].join('|') + '|$', 'g');

    var index = 0;
    var source = "__p+='";

    text.replace(matcher, function(match, interpolate, evaluate, offset) {
        source += text.slice(index, offset).replace(escapeRegExp, function(match) {
            return '\\' + escapes[match];
        });

        index = offset + match.length;

        if (interpolate) {
            source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
        } else if (evaluate) {
            source += "';\n" + evaluate + "\n__p+='";
        }

        return match;
    });

    source += "';\n";

    source = 'with(obj||{}){\n' + source + '}\n'

    source = "var __t, __p='';" +
        source + 'return __p;\n';

    var render = new Function('obj', source);

    return render;
};

其實原理也很簡單,就是在執行多次匹配函數的時候,不斷複製字符串,處理字符串,拼接字符串,最後拼接首尾代碼,得到最終的代碼字符串。

不過值得一提的是:在這段代碼裏,matcher 的表達式最後為:/<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g

問題是為什麼還要加個 |$ 呢?我們來看下 $:

var str = "abc";
str.replace(/$/g, function(match, offset){
    console.log(typeof match) // 空字符串
    console.log(offset) // 3
    return match
})

我們之所以匹配 $,是為了獲取最後一個字符串的位置,這樣當我們 text.slice(index, offset)的時候,就可以截取到最後一個字符。

完整的使用代碼可以參考 template 示例七。

最終版

其實代碼寫到這裏,就已經跟 underscore 的實現很接近了,只是 underscore 加入了更多細節的處理,比如:

  1. 對數據的轉義功能
  2. 可傳入配置項
  3. 對錯誤的處理
  4. 添加 source 屬性,以方便查看代碼字符串
  5. 添加了方便調試的 print 函數
  6. ...

但是這些內容都還算簡單,就不一版一版寫了,最後的版本在 template 示例八,如果對其中有疑問,歡迎留言討論。

underscore 系列

underscore 系列目錄地址:https://github.com/mqyqingfeng/Blog。

underscore 系列預計寫八篇左右,重點介紹 underscore 中的代碼架構、鏈式調用、內部函數、模板引擎等內容,旨在幫助大家閲讀源碼,以及寫出自己的 undercore。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啓發,歡迎 star,對作者也是一種鼓勵。

user avatar yuer_daily 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.