前言
现在开发现代前端应用时,我们已经摒弃了原始的 HTML 编写方式,转而使用“模板”的方式去编写。所谓的模板就是 React 的 jsx,Vue2 中的 template 等,然后依靠对应的模板引擎转化为常规的 HTML 文档,以便浏览器正常解析。
文本将探讨如何实现一个简单的模板引擎,能将模板转化为常规 HTML 片段。
模板与模板引擎
所谓的模板指的是使用特定的语法去编写一段“伪 HTML 代码”,然后对应的模板引擎(或称作模板解析器)就会将这段“伪 HTML 代码”生成常规的 HTML 代码。
假设我们现在需要编写一个列表,使用传统做法就是需要手动编写每一个 li(当然我们也可以使用 js 编码生成多个 li 元素,并插入至 ul 中。这样虽然省事了,但会使 UI 和 JS 逻辑耦合了,不利于理解页面元素关系)。传统编码方式如下所示:
<ul>
<li>小红</li>
<li>小明</li>
<li>小刚</li>
</ul>
接下来我们将演示 ejs 模板的方式编写
const users = ['小红', '小明', '小刚']
<ul>
<% for (var i = 0; i < users.length; i++) { %>
<li>
<%= users[i] %>
</li>
<% } %>
</ul>
相信不熟悉 ejs 模板语法的同学也能猜出最终解析生成的 HTML 片段是怎样的。
ejs 模板通过在传统的 HTML 代码中,以特定符号插入 JS 语句(如上面展示的循环)和变量,在减少重复编码的同时,还能使 UI 界面与数据相关联。阅读代码时,很容易就知道最终 HTML 文档的结构,以及 JS 中定义的变量会用在何处。
而后来的 React 使用 jsx 模板,Vue2 使用 template 模板,它们的核心思想都是使用模板语法简化传统的 HTML 代码编写,只不过使用的模板语法和对应的模板引擎不同而已。
接下来我们将尝试实现一个简易的模板引擎,用于解析 ejs 风格的字符串模板。
实现模板引擎
模板风格
我们将尝试编写一个模板解析函数,用于解析风格如下的模板:
const data = {
show: true,
users: [
{
name: "小明",
url: "url1",
},
{
name: "小红",
url: "url2",
},
],
};
const str = `
<ul>
<% if (data.show) { %>
<% for (var i = 0; i < data.users.length; i++) { %>
<li>
<a href="<%= data.users[i].url %>">
<%= data.users[i].name %>
</a>
</li>
<% } %>
<% } else { %>
<p>不展示列表</p>
<% } %>
</ul>
`;
此风格的字符串模板支持以下三种表达式:
- 插值表达式,如上例:
<%= xxx %>,其中 xxx 为 JS 表达式 - 分支表达式,如上例
<% if(data.show) { %> .... <% } else { %> - 循环表达式,如上例
<% for (var i = 0; i < data.users.length; i++) { %>
具体实现
首先分析这个字符串模板,包含了 JS 逻辑和 HTML 部分,其实主要目的就是字符串拼接,需要运行其中的 JS 代码,决定哪些字符串需要拼接,最终拼接得到的字符串就是 HTML 形式的字符串,可以作为 innerHTML 插入到页面中。
所以我们的模板解析函数的主要工作就是将模板的中的特殊字符给替换掉,让他变成一段可以运行的代码,代码运行之后就会自动拼接字符串。
我们的目标就是将字符串模板转换为如下形式的代码,最终得到的 html 字符串就可作为 innerHTML 的值。
html = "";
html += "<ul>";
if (data.show) {
for (var i = 0; i < data.users.length; i++) {
html += '<li><a href="';
html += data.users[i].url;
html += '">';
html += data.users[i].name;
html += "</a></li>";
}
} else {
html += "<p>不展示列表</p>";
}
html += "</ul>";
console.log(html); // '<ul><li><a href="url1">小明</a></li><li><a href="url2">小红</a></li></ul>'
// 将html作为innerHTML插入,生成真实DOM
container.innerHTML = html;
将模板与上述代码片段对比,可得出如下替换规则:
- 将
%>替换成html+=' - 将
<%替换成'; - 将
<%=xxx%>替换成';html+=xxx;html+=' - 字符串头部添加
let html = ""; html += ' - 字符串尾部添加
';
替换完成之后可得到如下字符串,其实这和我们上面提到的目标代码片段基本一样了(除了多出的html+='',但拼接空串并不影响最终效果),功能上是一致的。
const str = `
let html = '';
html += '<ul>
'; if (data.show) { html+='
'; for (var i = 0; i < data.users.length; i++) { html+='
<li>
<a href=" ';html+= data.users[i].url ;html+='">
';html+= data.users[i].name ;html+='
</a>
</li>
'; } html+='
'; } else { html+='
<p>不展示列表</p>
'; } html+='
</ul>
';
`;
值得注意的是,上述字符串中的换行是需要去掉的,否则转换成代码时会报错。但这里为了与原字符串模版作对比,才没有将换行去掉。后续操作中将会把字符串中的换行替换掉。
但问题是:应该如何将模板中的特殊字符进行替换呢?
这就需要请出 String.prototype.replace 和正则表达式了,将它们结合起来,即可实现替换功能。
具体使用到的正则表达式如下:
// 匹配出全部的换行并替换为空字符串
str.replace(/[\r\t\n]/g, "");
// 匹配出所有形如:<% xxx %> 的子串,并替换为 ';xxx;html+='
str.replace(/<%=(.*?)%>/g, "';html+=$1;html+='");
// 匹配出所有形如:<% 的子串,并替换为 ';
str.replace(/<%/g, "';");
// 匹配出所有形如:%> 的子串,并替换为 html+='
str.replace(/%>/g, "html+='");
然后我们需要将“代码字符串”变成真正的代码,让它运行起来,它才能帮我们生成对应的 innerHTML 文本。 字符串转代码,我们可以使用 Function 构造函数,构造一个函数来执行这些语句。
具体代码如下所示:
function templateParse(str, data) {
// 添加函数头部语句
str = "let html = '';" + "html += '" + str;
// 去掉换行符
str = str.replace(/[\r\t\n]/g, "");
// 所有 <% xxx %> 替换为 ';xxx;html+='
str = str.replace(/<%=(.*?)%>/g, "';html+=$1;html+='");
// 所有 <% 替换为 ';
str = str.replace(/<%/g, "';");
// 所有 %> 替换为 html+='
str = str.replace(/%>/g, "html+='");
// 尾部添加引号
str += "';";
// 添加函数尾部返回值
str += "return html;";
// 使用“字符串代码”生成可执行函数
const fn = new Function("data", str);
return fn(data);
}
测试
const data = {
show: true,
users: [
{
name: "小明",
url: "url1",
},
{
name: "小红",
url: "url2",
},
],
};
const str = `
<ul>
<% if (data.show) { %>
<% for (var i = 0; i < data.users.length; i++) { %>
<li>
<a href="<%= data.users[i].url %>">
<%= data.users[i].name %>
</a>
</li>
<% } %>
<% } else { %>
<p>不展示列表</p>
<% } %>
</ul>
`;
function templateParse(str, data) {
str = "let html = '';" + "html += '" + str;
str = str.replace(/[\r\t\n]/g, "");
str = str.replace(/<%=(.*?)%>/g, "';html+=$1;html+='");
str = str.replace(/<%/g, "';");
str = str.replace(/%>/g, "html+='");
str += "';";
str += "return html;";
const fn = new Function("data", str);
return fn(data);
}
const html = templateParse(str, data);
console.log(html); // <ul><li><a href="url1">小明</a></li><li><a href="url2">小红</a></li></ul>
总结
本文实现模板引擎的方案可以理解为:使用模板替换构造出函数,执行函数后生成 HTML 文本字符串。
还有更加强大的方案是使用抽象语法树(AST)进行转换,像React和Vue等库的模板引擎都是使用AST进行转换的,但因为AST方法较为复杂,所以本文暂不涉及。有兴趣的同学可去了解其原理。