underScore之模板引擎(下)

转义序列

在javaScript中,字符串值是一个由零或多个 Unicode字符(字母、数字和其他字符)组成的序列。
字符串的每个字符均可由一个转义序列表示。比如字母 a , 也可以用转义序列 \u0061表示。

转义序列以反斜杠 \ 开头,它的作用是告知 javaScript解释器下一个字符是特殊字符

转义序列的语法为 \uhhhh,其中 hhhh 是四位十六进制数。
根据这个规则,我们可以算出常见字符的转义序列,以字母 m 为例:

1
2
3
4
// 1. 求出字符 m 对应的 unicode值
var unicode = 'm'.charCodeAt(0) // 109
// 2. 转成十六禁止
var result = unicode.toString(16)l //"6d"

这里是一些常用字符的转义序列以及含义:

Unicode 字符值 转义序列 含义
\u0009 \t 制表符
\u000A \n 换行
\u000D \r 回车
\u0022 " 双引号
\u0027 ' 单引号
\u005C \ 反斜杠
\u2028 行分隔符
\u2029 段落分隔符

Line Terminators

Line Terminators, 中文译文 行终结符。像空白符一样, 行终结符可用于改善源文本的可读性。
在es5中,由四个字符被认为是 行终结符,其他的折行字符都会被视为空白。
|字符编码值|名称|
|:——:|:——:|
| \u000A | 换行符 |
| \u000D | 回车符 |
| \u2028 | 行分隔符 |
| \u2029 | 段落分隔符 |

Function

试想我们写这样一段代码,能否正确运行:

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

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

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

答案是不可以,会报错 VM42263:3 Uncaught SyntaxError: Invalid or unexpected token

这是为什么呢?
这是因为Function 构造函数的实现中,首先会将函数体代码字符串进行一次 ToString 操作,这时候字符串变成了:

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

然后再检测代码字符串是否符合代码规范,在javaScript中,字符串表达式中是不允许换行的,这就导致了报错。

为了避免报错,我们需要将代码修改为:

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

不止 \n ,其他三种 行终结符,如果你在字符串表达式中直接使用,都会导致报错!
之所以讲这个问题,是因为在模板引擎的实现中,就是使用了 Function 构造函数,如果我们在模板字符串中使用了 行终结符,便有可能会出现一样的错误,所以我们必须要对这四种 行终结符进行特殊的处理。

特殊字符

除了这四种行终结符之外,我们还要对两个字符串进行处理
一个是 \
比如我们在模板内容中使用了 \

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

其实我们是想打印 ‘1\23’,但是因为把 \ 当成了特殊字符的标记进行处理,所以最终打印了 1。

同样的道理,如果我们在使用模板引擎的时候,使用了 \ 字符串,也会导致错误的处理。

所以总共我们需要对六种字符进行特殊处理,处理的方式,就是正则匹配出这些特殊字符,然后比如将 \n 替换成 \n,\ 替换成 \,’ 替换成 \‘,处理的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

var escapes = {
"'" : "'",
"\\" : "\\",
"\r" : "r",
"\n" " "n",
// 行分隔符
"\u2028" : "u2028",
// 段落分隔符
"\u2029" : "u2029"
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;
var escapeChar = function(match) {
return '\\' + escapes[match];
}

我们测试一下:

1
2
3
4
5
6
var str = 'console.log("I am \n blue");';
var newStr = str.replace(escapeRegExp, escapeChar);

eval(newStr)
// I am
// blue

replace

这里我们来讲讲 replace函数:
语法:

1
str.replace(regextp|substr, newSubStr|function)

replace 的第一个参数,可以传一个字符串,也可以传一个正则表达式。
第二个参数,可以传一个新字符串,也可以传一个函数。

这里重点看传入一个函数的情况

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

match 表示匹配到的字符串,但函数的参数其实不止有match,下面是个更复杂的例子:

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

另外要注意的是,如果第一个参数是正则表达式,并且其为全局匹配模式,那么这个方法将被多次调用,每次匹配都会被调用

举个例子

1
2
3
4
5
6
7
8
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);
})

传入的函数会被执行两次,第一次的打印结果为:

1
2
3
4
<%= www.baidu.com %>
www.baidu.com
13
<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>

第二次打印结果为:

1
2
3
4
<%=baidu%>
'baidu'
33
<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>

正则表达式的创建

当我们要建立一个正则表达式的时候,我们可以直接创建:

1
var reg = /ab+c/i;

也可以使用构造函数的方式:

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

值得一提的是:每个正则表达式对象都有一个source属性,返回当前正则表达式对象的模式文本的字符串:

1
2
3
4
var regex = /fooBar/ig
// source 属性用于返回模式匹配所用的文本。
console.log(regex.source); // fooBar
console.log(typeof regex.source) // string

正则表达式的特殊字符

在underscore的实现中,匹配 <%=xxx%>使用的是/<%=([\s\S]+?)%>/g, \s 表示匹配一个空白符,包括空格,制表符,换页符,换行符,和其他 Unicode空格,\S匹配一个非空白符,[\s\S]就表示匹配所有的内容,可是为什么我们不直接使用 . 呢?

我们可能以为 . 匹配任意单个字符,实际上,并不是如此,.匹配除 行终结符之外的任何单个字符

1
2
3
4
var str = '<%=hello world%>'
str.replace(/<%=(.+?)%>/g, function(match){
console.log(match) // <%=hello world%>
})

但是如果我们在 hello world 之间加上一个行终结符,比如说\u2029:

1
2
3
4
var str = '<%=hello \u2029 world%>'
str.replace(/<%=(.+?)%>/g, function(match){
console.log(match)
})

因为匹配不到,所以不会执行console.log函数

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

1
2
3
4
5
var str = '<%=hello \u2029 world%>'

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

惰性匹配

仔细看 /<%=([\s\S]+?)%>/g 这个正则表达式,我们知道 x+ 表示匹配 x 1次或多次,x?表示匹配 x 0次或1次,但是 +? 是什么鬼?

实际上,如果在数量词 *、+、? 或者 {} 任意一个后面紧跟该符号 ?,会使数量词变为非贪婪(non-greedy)|惰性,即匹配次数最小化。反之,默认情况下,是贪婪的(greedy)|非惰性,即匹配次数最大化。

1
2
3
4
//非惰性
console.log("aaabc".replace(/a+/g, "d")); // dbc
// 惰性
console.log("aaabc".replace(/a+?/g, "d") // dddbc

在这里我们使用惰性匹配

1
2
3
4
5
6
7
8
var str = '<li><a href="<%=www.baidu.com%>"><%=baidu%></a></li>'

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

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

如果我们使用非惰性匹配

1
2
3
4
5
6
7
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使用的是字符串拼接的方式。

比如下面这样一段模板字符串:

1
2
3
4
5
6
7
<%for ( var i = 0; i < users.length; i++ ) { %>
<li>
<a href="<%=users[i].url%>">
<%=users[i].name%>
</a>
</li>
<% } %>

我们先将 <%=xxx%>替换成 '+ xxx +',在将 <%xxx%>替换成 '; xxx _p+='

1
2
3
4
5
6
7
';for ( var i = 0; i < users.length; i++ ) { __p+='
<li>
<a href="'+ users[i].url + '">
'+ users[i].name +'
</a>
</li>
'; } __p+='

这段代码还不完整,还需要添加头尾代码,组合成一个完整的代码字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;

整理下代码就是:

1
2
3
4
5
6
7
8
9
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 构造函数中:

1
var render = new Function(data, _p);

我们执行这个render函数,传入需要的data数据,就可以返回一段HTML字符串:

1
render(data);

第五版 - 特殊字符的处理

这里接着上篇的第四版进行书写,不过加入对特殊字符的转义以及使用字符串拼接的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 第五版
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;
};

第六版 - 特殊值的处理

如果数据中 users[i].url不存在怎么办?此时取值的结果为undefined,我们知道

1
'1' + undefined // "1undefined"

就相当于拼接了undefined字符串,这肯定不是我们想要的。我们可以在代码中加入一点判断:

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

这里的 interpolate 有点多

1
2
3
4
5
6
7
    var source = "var __t, __p='';\n";

...

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

其实就相当于:

1
2
var _t;
var result = (_t = interpolate) == null ? '' : _t

第七版

现在我们使用的方式是将模板字符串进行多次替换,然而underscore的实现中,只进行了一次替换,我们来看看underscore是怎么实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

var settings = {
// 求值
evaluate: /<%([\s\S]+?)%>/g,
// 插入
interpolate: /<%=([\s\S]+?)%>/g,
};
var template = function(text) {
var matcher = RegExp([
(settings.interpolate).source,
(settings.evaluate).source
].join('|') + '|$', 'g');

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

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

text.replace(matcher, function(match, interpolate, evaluate, offset) {
// offset 是匹配模式在字符串中距离首个字符中间的偏移字符个数
// evaluate 是 settings.evaluate 匹配到的字符串
// interpolate 是 settings.evaluate 匹配到的字符串
// match 表示每次匹配到的字符串
// 第一次 <%=www.baidu.com%>
// 第二次 <%baidu%>

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;
};
var str = '<li><a href="<%=www.baidu.com%>"><%baidu%></a></li>'
console.log(template(str))

其实原理也很简单,就是在执行多次匹配函数的时候,不断复制字符串,处理字符串,拼接字符串,最后拼接首尾代码,得到最终的代码字符串。

不过值得一提的是:在这段代码里,matcher 的表达式最后为:/<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g

问题是为什么还要加个 |$ 呢?我们来看下 $:

1
2
3
4
5
6
7

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

我们之所以匹配 $,是为了获取最后一个字符串的位置,这样当我们 text.slice(index, offset)的时候,就可以截取到最后一个字符。

最终版

其实代码写到这里,就已经跟 underscore 的实现很接近了,只是 underscore 加入了更多细节的处理,比如:

  1. 对数据的转义功能
  2. 可传入配置项
  3. 对错误的处理
  4. 添加 source 属性,以方便查看代码字符串
  5. 添加了方便调试的 print 函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    // -----  html 代码
    <div id="container"></div>
    <script type="text/html" id="user_tmpl">
    <%for ( var i = 0; i < users.length; i++ ) { %>
    <li>
    <a href="<%=users[i].url%>">
    <%=users[i].name%>
    </a>
    </li>
    <% } %>
    </script>


    //---------js代码
    /**
    * 模板引擎第八版
    */
    var _ = {};

    _.templateSettings = {
    // 求值
    evaluate: /<%([\s\S]+?)%>/g,
    // 插入
    interpolate: /<%=([\s\S]+?)%>/g,
    // 转义
    escape: /<%-([\s\S]+?)%>/g
    };

    var noMatch = /(.)^/;

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

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

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

    _.template = function(text, settings) {

    settings = Object.assign({}, _.templateSettings, settings);

    var matcher = RegExp([
    (settings.escape || noMatch).source,
    (settings.interpolate || noMatch).source,
    (settings.evaluate || noMatch).source
    ].join('|') + '|$', 'g');

    var index = 0;
    var source = "__p+='";
    text.replace(matcher, function(match, escape, interpolate, evaluate, offset) {

    source += text.slice(index, offset).replace(escapeRegExp, escapeChar);

    index = offset + match.length;

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

    return match;
    });
    source += "';\n";

    if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n';

    source = "var __t,__p='',__j=Array.prototype.join," +
    "print=function(){__p+=__j.call(arguments,'');};\n" +
    source + 'return __p;\n';

    var render;
    try {
    render = new Function(settings.variable || 'obj', '_', source);
    } catch (e) {
    e.source = source;
    throw e;
    }

    var template = function(data) {
    return render.call(this, data, _);
    };

    var argument = settings.variable || 'obj';
    template.source = 'function(' + argument + '){\n' + source + '}';

    return template;
    };

    var results = document.getElementById("container");

    var data = {
    users: [
    { "name": "Byron", "url": "http://localhost" },
    { "name": "Casper", "url": "http://localhost" },
    { "name": "Frank", "url": "http://localhost" }
    ]
    }

    var text = document.getElementById("user_tmpl").innerHTML
    var compiled = _.template(text);

    console.log(compiled.source)
    results.innerHTML = compiled(data);