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

冴羽發表於2018-01-25

前言

本篇接著上篇 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
複製程式碼

其實我們是想列印 '1\23',但是因為把 \ 當成了特殊字元的標記進行處理,所以最終列印了 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 匹配一個非空白符,[\s\S]就表示匹配所有的內容,可是為什麼我們不直接使用 . 呢?

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

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 系列目錄地址:github.com/mqyqingfeng…

underscore 系列預計寫八篇左右,重點介紹 underscore 中的程式碼架構、鏈式呼叫、內部函式、模板引擎等內容,旨在幫助大家閱讀原始碼,以及寫出自己的 undercore。

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

相關文章