🧊 模板解析器轻考古
上回我借着解决 CSS 嵌套解析问题聊到 micro-app 中实现的一个非常精简的 CSS 解析器,还没过瘾。今天整理笔记的时候发现了角落里的这个模板解析器,那就赶紧擦擦灰尘拉出来聊一聊。这个模板解析器可是有年头了,它是 John Resig 佬 2008 年写的,叫 micro-templating
,距今十五年哩。(John Resig 就是写《JavaScript 忍者秘籍》那位)
令人惊叹的地方是,micro-templating
的代码和它的名字一样 micro,它能用仅仅 30 行代码完成对模板的处理,并且还有读取 html 模板、缓存、柯里化等额外的高级功能!
可能有些佬没接触过模板解析器,这里给出一个简单的示例。
parse(`
<% if (shouldCount) { %>
<% for (var i = 1; i <= 3; i++) { %>
<div>hello - <%= i + count %></div>
<% } %>
<% } else { %>
<div>noop</div>
<% } %>
`, {
shouldCount: true,
count: 1
})
给 parse
函数传入模板字符串和数据后,自然也是输出字符串啦:
<!-- 以下经过格式化 -->
<div>hello - 2</div>
<div>hello - 3</div>
<div>hello - 4</div>
相比生态里现在动则 30kb 甚至 300kb 的库,仅 30 行代码完成这个功能,简直不可思议。
完整实现
因为代码真的非常短,先直接看一遍,这是带注释版本的。如果你能在第一遍 review 这段代码后脑袋还保持清醒的话,留下评论,我拜你为大哥。
// Simple JavaScript Templating
// John Resig - https://johnresig.com/ - MIT Licensed
(function () {
var cache = {};
this.tmpl = function tmpl(str, data) {
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str)
? (cache[str] =
cache[str] || tmpl(document.getElementById(str).innerHTML))
: // Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function(
"obj",
"var p=[],print=function(){p.push.apply(p,arguments);};" +
// Introduce the data as local variables using with(){}
"with(obj){p.push('" +
// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%")
.join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t")
.join("');")
.split("%>")
.join("p.push('")
.split("\r")
.join("\\'") +
"');}return p.join('');"
);
// Provide some basic currying to the user
return data ? fn(data) : fn;
};
})();
代码看完了,我知道你没看懂,因为曾经我也是。好了,留下三连,解析开始。
解析
首先,代码最外层是一个 IIFE,给 this 对象挂载了 tmpl 函数,直接执行的话就是 window。内部通过局部变量保存了模板解析的缓存:
(function () {
var cache = {};
this.tmpl = function tmpl(str, data) {};
})();
str 参数有两种两种用途,/\W/.test(str)
用来判断字符串中包不包含模板分隔符,如果不含分隔符,即普通字符串,那就当作 ID,从页面对应 ID 的节点中获取内容作为模板。比如,如果 str 为 'user_tmpl',那么模板函数将会读取页面的以下脚本的内容作为输入:
<!-- type="text/html",所以浏览器并不会执行这串脚本。它只能由 tmpl 来读取... -->
<script type="text/html" id="item_tmpl">
<div id="<%=id%>" class="<%=(i % 2 == 1 ? " even" : "")%>">
<div class="grid_1 alpha right">
<img class="righted" src="<%=profile_image_url%>"/>
</div>
<div class="grid_6 omega contents">
<p><b><a href="/<%=from_user%>"><%=from_user%></a>:</b> <%=text%></p>
</div>
</div>
</script>
(function () {
var cache = {};
this.tmpl = function tmpl(str, data) {
return !/\W/.test(str)
? // 缓存会将读取脚本内容到模板解析这个过程的解析结果缓存下来
(cache[str] =
cache[str] ||
// 读取脚本内容
tmpl(document.getElementById(str).innerHTML))
: new Function();
};
})();
new Function()
即模板解析的核心内容,原理是用一个数组,保存所有解析内容,再拼接回字符串,作为 JS 执行。类似 eval 函数。
new Function(
"obj",
"var p=[];" +
// + '...'
"return p.join('')"
);
要解析什么内容呢?无非是 <%=hello%>
这种模板字符串。执行过程中,通过 with 语句,将内部变量的求值(hello)转移到参数对象(data.hello)。这个玩意儿在 VueJS 的模板编译中也用到过:
/* VueJS */
with (vm) {
return createVNode("p", { attrs: { hi: hello /* vm.hello */ } }, [
createTextNode("Hello World"),
]);
}
那如何解析呢?
我们知道曾写过经典字符串模板的佬应该知道,模板字符串一般包含:字符串、变量及逻辑。
- 字符串
<html>Lionad</html>
- 带变量
<html><%=name%></html>
- 带逻辑
<% if(name){ %><html><%=name%></html><% } %>
。
所以解析模板,无非就是把这三种字符串替换成纯粹的 JS 逻辑,作为 new Function 的参数。其中所有中间变量,可以用一个数组来储存:
- 将普通字符串直接推入作为结果的内容数组,比如
<html>Lionad</html>
应该被解析为p.push('<html>Lionad</html>')
。 - 如果碰到变量,则将变量拆分出来推入数组,如
<html><%=name%></html>
应该被解析为p.push('<html>');p.push(name);p.push('</html>')
。 - 带逻辑的字符串,由于逻辑本身就是 JS 代码,所以不需要解析为字符串,只需要把两侧的
<% %>
这种模板标志去掉就好了。如<% if(name){ %><html><%=name%></html><% } %>
应该被解析为:if(name){ p.push('<html>');p.push(name);p.push('</html>') }
。
以下面这段模板为例,我们看看字符串具体的替换规则。
str = `<% if (true) { %>
<li><%=users[i]%></li>
<% } %>`;
// with 的使用方法都知道了,with(obj = { a }),那么
// 就能在内部直接读取 a,不需要写 obj.a
"with(obj){p.push('" +
str
// 将换行等空白字符转换为空格,这样所有代码都在一行了
// '<% if (true) { %> <li><%=users[i]%></li> <% } %>'
.replace(/[\r\t\n]/g, " ")
// ['', 'if (true) { %><li>', '=users[i]%></li>', ' } %>']
.split("<%")
// 因为 \t 刚才已经被替换成空格了,所以这里可以使用 \t 用来将不同行的代码字符串“相加”,
// 一会儿再拆分时,不会和原先字符串里的字符混淆
.join("\t")
// 去掉模板标志两端的引号,在这个例子里不会用到
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
// 以下三句最重要,将所有变量推入数组,生成结果形如 `push('xxx',x,'xxx')` 的字符串
// 首先,刚才匹配完了 <%,所以 \t= 开头后边接的就是变量,这里让它转换成 ',x,' 的形式
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t")
// 在刚刚跟 split('<%') 的位置用括号还原,得到了 ',x,'xxx'); 的形式
.join("');")
// 在 \t=xxx 那次匹配,已经把变量模板对应的结束标志 %> 匹配完了,
// 所以这是处理剩余语句的结束标志,用 split 拆分行后相当于直接把结束标记舍弃了
.split("%>")
// 最终在每行开头增加 push 方法,形成了 push('xxx',x,'xxx'); 的形式
.join("p.push('")
// 处理剩余的换行符
.split("\r")
.join("\\'") +
"');}";
有一个小细节,字符串替换时,所有逻辑语句的模板标志开头和结尾(<%
、%>
)分别被替换为 ');
、push('
,但是开头和结尾的那个标志,并没有对应的匹配。所以在代码中,能看 with(obj){p.push('
和 ');
这种手动补全。
最终生成的字符串如下(已格式化处理):
`with(obj){
p.push('');
if (true) {
p.push(' <li>',users[i],'</li> ');
}
p.push('');
}`;
为什么 with 语句里有一行空的 push('')
呢?
还记得原始的模板吗?一开的模板是以“<%”开头的,而“<%”在替换过程被右括号取代了,此时正好和初始字符串结合形成了空 push。如果原始字符串开头是 "123<% if (true)",那这行空 push 会转换成“p.push('123');”,也是正确的代码。
最后,micro-templating 需要返回这个新函数。如果不传参数 data 给模板函数,则会返回一个部分应用的函数等待用户输入 data 参数再执行模板解析;如果已经传了 data 那就会立即调用返回模板解析的结果。
(function () {
var cache = {};
this.tmpl = function tmpl(str, data) {
return data ? fn(data) : fn;
};
})();
终于结束了!这个函数不长,但一口气看下来还是蛮费力气的。如果一遍写测试用例以便看代码,很快能把这个函数跑通,如果你是纯靠脑力跑,还没被那个长长的字符串替换链给吓住,在此我赐你称号:“Micro Templating 人肉编译器 - 十五周年限定版”。
额外提一句,Function 构造器和 eval 函数比,除了能传参数意外,还有一些细节不同,比如说声明变量的作用域不同,这个需要注意。这个编译器没有错误处理等代码完善,因此不可以上生产。此外,由于 with 语句非常容易出错,且不利于静态优化,不应该在业务函数里使用^with-mdn。最后,阅读源码需要技巧,这几点希望能帮助到你:
- 从注释、测试用例,先搞清楚要看的代码的作用
- 使用编辑器的折叠代码,一次只看一部分源码
- 在 Git 提交记录中搜索,从简单的版本看起
- 复杂代码应该从高层结构看起,比如使用 IDE 各种插件或是 Madge 等代码分析工具