想必前端的同学都接触过 HTML 模板语法,大多数可能都是以 {{
的形式(Mustache 风格)去表示的,比如 Vue 的模板语法,Vue 通过对模板字符串的遍历解析,最终生成了 HTML:
<span>Message: {{ msg }}</span>
除了上面这种类型,还有一个叫做 ERB-style 的模板标记语法,也非常的常见,它就是我们接下来要实现的这一种。
虽然我们这次实现的是 ERB 风格,但是这也只是一个标记,如果您读懂了本文的内容,您可以换成任意喜欢的标记方法,比如,如果想使用 {{
的方式,也完全没问题。
不过,本文还是以 ERB 风格为例。
它的语法也比较简单,主要有两种表示:
<% ... %>
可以包裹一个 JavaScript 语句:
<%for ( let i = 0; i < 10; i++ ) { %>
<% console.log(i) %>
<% } %>
<%= ... %>
可以获取当前执行环境下的变量:
假设我们写好了模板函数,就叫 template
。
我们的使用方法会是:
const render = template('<div><%= data.name %></div>');
console.log(render({name: 'hi'})) // <div>hi</div>
我们再举一个使用 <%= ... %>
的例子,那就是在 webpack 中的一个使用场景:
// @filename: webpack.config.js
plugins: [
new HtmlWebpackPlugin({
title: 'Custom template',
// Load a custom template (lodash by default)
template: 'index.html'
})
]
// @filename: index.html
<!DOCTYPE html>
<html> <head> <meta charset="utf-8"/> <title><%= htmlWebpackPlugin.options.title %></title> </head> <body> </body> </html>
经过上面的举例,想必大家都很清楚 ERB 风格的模板是什么了吧?
除了上面提到的两种标签语法,还有其他的标签,比如 <%- ... %>
,其实它的转换原理和 <%= ... %>
是一样的,只不过额外转义了内部的 HTML 字符串的,但是本文不会讲解如何转义 HTML 字符串,所以那种记法就略过了。想了解原理的同学推荐阅读 这篇文章
接下来我们就来实现 ERB 风格的模板引擎。
ps: 下面讲解的代码其实就是 underscorejs 的 _.template
的思路,只不过略过了对一些边界情况的兼容。
我们有一个 index.html 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<script id="templ" type="text/html"> <%for ( var i = 0; i < data.list.length; i++ ) { %> <li> <a data-id="<%= data.list[i].id %>"> <%=data.list[i].name %> </a> </li> <% } %> </script>
<script src="./template.js"></script>
</body>
</html>
首先,我们先获取这段模板:
let content = document.querySelector('#templ').innerHTML
我们的模板引擎最核心的原理是什么呢?是对 new Funtion
的使用。事实上,我们可以通过如下方法构造一个函数:
const print = new Function('str', 'console.log(str)')
print('hello world') // hello world
它就相当于:
const print = function (str) {console.log(str)}
有了这个神奇的特性,我们就在想,如果我们把上述模板转化为合法的 JavaScript 代码的字符串,记作字符串 x 。
那我们是不是就可以做一个模板引擎了呢?
new Funtion('data', x);
答案是:是的,我们就是要这么去做。
现在问题的关键就是我们怎么把 content
的值转换为 JavaScript 代码的字符串。
<%for ( var i = 0; i < data.list.length; i++ ) { %>
<li> <a data-id="<%= data.list[i].id %>"> <%=data.list[i].name %> </a> </li>
<% } %>
我们可以:
- 使用正则
/<%=([\s\S]+?)%>/g
匹配到<%= ... %>
格式的字符串 - 使用正则
/<%([\s\S]+?)%>/g
匹配到<% ... %>
格式的字符串
注意,第二个正则是包含第一个的,所以,我们在正则替换的时候一定要先替换第一个。
如果我们匹配到了 <%= ... %>
,我们会把它变为:'+\n ... +\n'
content = content.replace(/<%=([\s\S]+?)%>/g, function(_, evaluate) {
return "'+\n" + evaluate + "+\n'"
})
嗯… 结果有点奇怪?没关系,先看下去。
接下来,我们匹配 <% ... %>
。
把它变为:';\n ... \n_p +='
。
content = content.replace(/<%([\s\S]+?)%>/g, function(match,interpolate) {
return "';\n" + interpolate + "\n_p +='";
})
现在是不是有点像样了呢?不过这个还不是合法的 JavaScript 代码。
我们还需要在它的头尾加点东西。
在头部加上 let _p = '';\nwith (data){\n_p+='
,在尾部加上 '}return _p
,再来看一下效果:
这样才是差不多像样了,但是还是有个问题,请看上图的第五行,因为行的最后有个 \n
字符,所以在 '
之后换行了。
但是在 JavaScript 中 '
是不允许换行的,如果我们把这段代码拷贝到控制台执行,还是会报错。
我们可以考虑把 '
换成 ES6 的模板字符串语法,也可以考虑对此类特殊字符进行处理,我们选择特殊处理一下。
如果我们用编辑器在某个 JS 文件中写两行代码:
const a = 1;
const b = 2;
它其实是真正存储在文件是更像这样子的:const a = 1;\nconst b = 2;
。而我们要在字符串里保留 \n
的原始模样,就要它做一层转义,当我们在字符串写 ‘const a = 1;\\nconst b = 2;’ 才真正表示了上面真正的存储结果。
和 \n
一样的还有下面几个,列一个统一的表:
转义字符 | 要转化为 |
---|---|
‘ | \` |
\ | \\ |
\r | \\r |
\n | \\n |
\u2028 | \\u2028 |
\u2029 | \\u2029 |
到代码层面的话,就会是下面这样子:
var escapes = {
"'": "'",
'\\': '\\',
'\r': 'r',
'\n': 'n',
'\u2028': 'u2028',
'\u2029': 'u2029'
};
var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;
function escapeChar(match) {
if (match === "'") {
return "\'"
}
return '\\' + escapes[match];
}
注意看 escapeChar
函数,我们特别兼容了一下单引号,因为它和其他的不同,对比我们列的表,它的转化的结果前面只有一个 \
,但是我们也可以去掉这个,那就是用单引号表示。因为 "\'"
等于 '\\''
,所以代码就可以去掉那个 if
语句,写成:
function escapeChar(match) {
return '\\' + escapes[match];
}
鉴于 \
的作为转译序列的特殊性,我们的 escapes
对象的第二项其实代表的是一个\
,而转换后的结果其实代表的两个 \
:
byte[] byteArray1 = "\\".getBytes();
byte[] byteArray2 = "\\\\".getBytes();
System.out.println(byteArray1) // [92]
System.out.println(byteArray2) // [92, 92]
我们在最开始获取到 content 后,加上这段处理转译序列的逻辑后,再看一下最后的结果:
content = content.replace(escapeRegExp, function(match) {
return escapeChar(match);
}
这样就没什么问题了,我们就可以放心的把它传给 new Function
的第二个参数了。
const render = new Function('data', content);
后面调用我们的 render
函数就可以这样:
render({
list: {name: 'Bob', id: 1}
})
我们可以得到下面这样的结果:
完美,逻辑我们终于讲完了。
underscore 的思路也是这样子,只不过,它做的更简洁。
我们的思路是先把 content 的特殊字符处理掉,再把 <%= ... %>
处理掉,再把 <% ... %>
处理掉,然后再把代码的头部尾部完善一下。
而它呢,它使用的正则和我们不一样,它是 /<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g
,关键点就在这里。
underscore 只需要遍历一遍,碰到 <%= ... %>
或者 <% ... %>
后,先把上一次匹配结果的结束到这次匹配结果之前的特殊字符处理掉,然后再判断当前匹配到的模板语法怎么处理,依次迭代,直到匹配到字符串结尾。
有些同学可能会好奇,这样能匹配到最后嘛?如果我们的模板最后面是一些纯字符串,而不是 <%= ... %>
或者 <% ... %>
,正则岂不是匹配不到最后了?这也就是 underscore 为了把正则最后加了 |$
的原因,保证可以匹配到最后,这样就能把这一段的特殊字符也处理掉。
另外,underscore 在处理模板语法 <%= ... %>
的时候加了对 null
和 undefined
判断,如果是这两者,我们最开始的写法会直接输出字符串 ‘undefined’ 或者 ‘null’。但是 underscore 则让这些情况输出空字符串。
var interpolate = '123';
var __t;
(__t= (interpolate)) == null ? '' : __t
写得人性化一点,就等同于:
interpolate == null ? interpolate : ''
明白了上面这些点之后,再去看 _.template
的源码应该会轻松一些了。
但是,思路都是一样的,相信明白了最开始我们分析过程的同学,一定也能明白 underscore 的 _.template
函数的原理。比起直接讲解 _.template
的实现,拆解开来应该更容易理解吧 :)
为了方便各位调试,我把可执行代码都放在下面,需要的同学自取~
谢谢各位的阅读,撒花 ~
参考链接
完整代码
<!-- @filename: index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<script id="templ" type="text/html"> <%for ( var i = 0; i < data.list.length; i++ ) { %> <li> <a data-id="<%= data.list[i].id %>"> <%=data.list[i].name %> </a> </li> <% } %> </script>
<script src="./index.js"></script>
</body>
</html>
// @filename: main.js
let content = document.querySelector('#templ').innerHTML
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;
function escapeChar(match) {
if (match === "'") {
return '\\`'
}
return '\\' + escapes[match];
}
function template(text) {
var matcher = RegExp([
(settings.interpolate || noMatch).source,
(settings.evaluate || noMatch).source
].join('|') + '|$', 'g');
var index = 0;
var source = "__p+='";
text.replace(matcher, function (match, interpolate, evaluate, offset) {
source += text.slice(index, offset).replace(escapeRegExp, escapeChar);
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";
var argument = 'data';
source = 'with('+ argument + '||{}){\n' + source + '}\n';
source = "var __t,__p='';" +
source + 'return __p;\n';
var render;
try {
render = new Function(argument, source);
} catch (e) {
e.source = source;
throw e;
}
var template = function (data) {
return render.call(this, data);
};
return template;
}
const render = template(content);
var list = [
{name: 'Bob', id: 1},
{name: 'Jack', id: 2},
]
console.log(render({
list
}))
今天的文章手把手实现一个HTML模板引擎分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/21768.html