underscore中template模板引擎源码解读

最近有个同事在写业务的时候,发现往模板引擎里面传数据,怎么传都报错(变量找不到之类的)。我们用的模板引擎是baiduTemplate。而之前并没有出现类似的情况,经过一番排查,发现是使用了use strict,baiduTemplate源码使用了eval实现,严格模式下是不能使用eval的。经过了这么大的坑后,打算好好研究下前端模板引擎,就从熟悉的undscore开始吧。

underscore是个非常成熟的前端类库了。现在已经更新到1.83版本了。关于unserscore的api可以参考其官网文档。本次我们的主题是分析其template模板引擎部分的实现。

##template实现思路

首先我们定义一个字符串:

1
var tpl = "<div>{name}</div><div>{age}</div><div>{home}</div><hr/>"

模板引擎说的简单点,就是用已知的数据(data)去替换tpl中相应的变量,然后生成一份新的字符串。

怎么替换呢?
这里应该比较明显,用正则去匹配。

var reg = /\{[A-Za-z]*\}/;//匹配{}
var para = reg.exec(tpl);

匹配到第一个{name}后,标志name,并保存其之前的html标签<div>,然后继续匹配剩余的tpl。
最后,将模板中的标识变为data的数据即可,得到新的html。
大概的思路是这样的,这里面还要考虑如何直接输出js代码,如何过滤等。下面来看看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
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// 定义模板的界定符号,在template方法中使用
_.templateSettings = {
evaluate : /<%([\s\S]+?)%>/g,// js可执行代码的界定符
interpolate : /<%=([\s\S]+?)%>/g,// 直接输出变量的界定符
escape : /<%-([\s\S]+?)%>/g // 需要将HTML输出为字符串(将特殊符号转换为字符串形式)的界定符
};
// 自定义模板界定符号时使用
var noMatch = /(.)^/;
// escapes对象记录了需要进行相互换转的特殊符号与字符串形式的对应关系,在两者进行相互转换时作为索引使用
var escapes = {
"'": "'",
'\\': '\\',
'\r': 'r',
'\n': 'n',
'\u2028': 'u2028',
'\u2029': 'u2029'
};
// 定义模板中需要替换的特殊符号,包含反斜杠, 单引号, 回车符, 换行符, 制表符, 行分隔符, 段落分隔符
// 在将字符串中的特殊符号转换为字符串形式时使用
var escaper = /\\|'|\r|\n|\u2028|\u2029/g;
var escapeChar = function(match) {
// 将特殊符号转义为字符串形式,否则会直接起作用
return '\\' + escapes[match];
};
/*
Underscore模板解析方法,用于将数据填充到一个模板字符串中
模板解析流程:
1. 将模板中的特殊符号转换为字符串
2. 解析escape形式标签,将内容解析为HTML实体
3. 解析interpolate形式标签,输出变量
4. 解析evaluate形式标签,创建可执行的JavaScript代码
5. 生成处理函数,该函数在得到数据后可直接填充到模板并返回填充后的字符串
6. 根据参数返回填充后的字符串或处理函数的句柄
*/
_.template = function(text, settings, oldSettings) {
// 模板配置:如果没有指定配置项,则使用templateSettings中指定的配置项
if (!settings && oldSettings) settings = oldSettings;
settings = _.defaults({}, settings, _.templateSettings);
// Combine delimiters into one regular expression via alternation.
// 通过'|'操作符将正则规则合并为一个表达式
// /<%-([\s\S]+?)%>|<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g
// 这个正则会由左向右的顺序匹配,如果成功了就不找后面的了
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) {
// offset: 匹配的位置
// 将匹配字符串之前的字符串保存起来 注意做了字符串转义处理
source += text.slice(index, offset).replace(escaper, escapeChar);
// 更新index,继续处理后续的
index = offset + match.length;
//针对不同的情况构造处理语句
if (escape) {
/*
html转义输出
"__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)+
'\n sex:'+
((__t=(sex ))==null?'':_.escape(__t))+
'"
*/
source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'";
} else if (interpolate) {
/*
直接输出
"__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)+
'"
--->转为js
__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)
*/
source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
} else if (evaluate) {
/*
js代码
"__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)+
'\n sex:'+
((__t=(sex ))==null?'':_.escape(__t))+
'\n ';
print(sex)
__p+='"
*/
source += "';\n" + evaluate + "\n__p+='";
}
// Adobe VMs need the match returned to produce the correct offest.
return match;
});
source += "';\n";//换行
/*
无预定义属性名,利用with构建局部作用域,并把代码填充进去
with(obj||{}){
__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)+
'\n sex:'+
((__t=(sex ))==null?'':_.escape(__t))+
'\n ';
print(sex)
__p+='\n ';
_.each(list, function(item) {
__p+='\n <p>'+
((__t=(item.name))==null?'':__t)+
'</p>\n ';
});
__p+='\n\n';
}
"
如果设置了variable,不走with逻辑
*/
if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n';
//定义变量 __t 存放data的临时变量 __p 存放拼接的字符串 print 输出变量
/*
"var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};
with(obj||{}){
__p+=' \n\n \n name:'+
((__t=(age ))==null?'':__t)+
'\n sex:'+
((__t=(sex ))==null?'':_.escape(__t))+
'\n ';
print(sex)
__p+='\n ';
_.each(list, function(item) {
__p+='\n <p>'+
((__t=(item.name))==null?'':__t)+
'</p>\n ';
});
__p+='\n\n';
}
return __p;
"
*/
source = "var __t,__p='',__j=Array.prototype.join," +
"print=function(){__p+=__j.call(arguments,'');};\n" +
source + 'return __p;\n';
try {
/*
用new Function解析,创建一个函数,将源码作为函数执行体,将obj和Underscore作为参数传递给该函数
function anonymous(obj, _ ,) {}
如果设置variable,则直接传variable
*/
var render = new Function(settings.variable || 'obj', '_', source);
} catch (e) {
e.source = source;
throw e;
}
/*
如果没有指定填充数据,则返回一个函数,用于将接收到的数据替换到模板
如果在程序中会多次填充相同模板,那么在第一次调用时建议不指定填充数据,在获得处理函数的引用后,再直接调用会提高运行效率
传参 data _
运行预编译模板
*/
var template = function(data) {
return render.call(this, data, _);
};
// 将创建的源码字符串添加到函数对象中,一般用于调试和测试
var argument = settings.variable || 'obj';
template.source = 'function(' + argument + '){\n' + source + '}';
return template;
};

这里不得不提两点:

  1. 这段代码太经典了,太精彩了,太销魂了。即可以细嚼慢咽,又让人回味无穷。
  2. underscore1.8之前版本的实现跟当前版本有些出入,导致api也不尽相同。最新版已经不支持预编译与解析一句话同时传参搞定了,这本身也是不合理的。现在是先预编译模板得到处理函数,然后再去填充数据。