前言
本篇接着上篇 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 加入了更多細節的處理,比如:
- 對數據的轉義功能
- 可傳入配置項
- 對錯誤的處理
- 添加 source 屬性,以方便查看代碼字符串
- 添加了方便調試的 print 函數
- ...
但是這些內容都還算簡單,就不一版一版寫了,最後的版本在 template 示例八,如果對其中有疑問,歡迎留言討論。
underscore 系列
underscore 系列目錄地址:https://github.com/mqyqingfeng/Blog。
underscore 系列預計寫八篇左右,重點介紹 underscore 中的代碼架構、鏈式調用、內部函數、模板引擎等內容,旨在幫助大家閲讀源碼,以及寫出自己的 undercore。
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啓發,歡迎 star,對作者也是一種鼓勵。