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