只有 20 行的 JavaScript 模板引擎实例详解


Posted in Javascript onMay 11, 2020

本文实例讲述了 JavaScript 模板引擎。分享给大家供大家参考,具体如下:

原文链接:JavaScript template engine in just 20 lines

(译者吐槽:只收藏不点赞都是耍流氓)

前言

我仍旧在为我的JS预处理器AbsurdJS进行开发工作。它原本是一个CSS预处理器,但之后它扩展成为了CSS/HTML预处理器,很快它将支持JS到CSS/HTML的转换。它就像一个模板引擎一样能够生成HTML代码,也就是说它能够用数据填充模板当中的标识片段。

因此,我希望去写一个可以满足我当前需求的模板引擎。AbsurdJS主要作为NodeJS的模块使用,但同时它也可以在客户端使用。为了这个目的,我无法使用市面上已经存在的模板引擎,因为它们几乎全都依赖于NodeJS,并且难以在浏览器中使用。我需要一个更小,纯JS写成的模板引擎。我浏览了这篇由John Resig写的博客,似乎这正是我需要的东西。我把当中的代码稍作修改,并且浓缩到了20行。

这段代码的运行原理非常有趣,我将在这篇文章中一步一步为大家展示John的wonderful idea。

1、提取标识片段

这是我们在开始的时候将要获得的东西:

var TemplateEngine = function(tpl, data) {
 // magic here ...
}
var template = '<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>';
console.log(TemplateEngine(template, {
 name: "Krasimir",
 age: 29
}));

一个简单的函数,传入模板数据作为参数,正如你所想象的,我们想要得到以下的结果:

<p>Hello, my name is Krasimir. I'm 29 years old.</p>

我们要做的第一件事就是获取模板中的标识片段<%...%>,然后用传入引擎中的数据去填充它们。我决定用正则表达式去完成这些功能。正则不是我的强项,所以大家将就一下,如果有更好的正则也欢迎向我提出。

var re = /<%([^%>]+)?%>/g;

我们将会匹配所有以<%开头以%>结尾的代码块,末尾的g(global)表示我们将匹配多个。有许多的方法能够用于匹配正则,但是我们只需要一个能够装载字符串的数组就够了,这正是exec所做的工作:

var re = /<%([^%>]+)?%>/g;
var match = re.exec(tpl);

在控制台console.log(match)可以看到:

[
 "<%name%>",
 " name ", 
 index: 21,
 input: 
 "<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>"
]

我们取得了正确的匹配结果,但正如你所看到的,只匹配到了一个标识片段<%name%>,所以我们需要一个while循环去取得所有的标识片段。

var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
 console.log(match);
}

运行,发现所有的标识片段已经被我们获取到了。

2、数据填充与逻辑处理

在获取了标识片段以后,我们就要对它们进行数据的填充。使用.replace方法就是最简单的方式:

var TemplateEngine = function(tpl, data) {
 var re = /<%([^%>]+)?%>/g, match;
 while(match = re.exec(tpl)) {
  tpl = tpl.replace(match[0], data[match[1]])
 }
 return tpl;
}

data = {
 name: "Krasimir Tsonev",
 age: 29
}

OK,正常运行。但很明显这并不足够,我们当前的数据结构非常简单,但实际开发中我们将面临更复杂的数据结构:

{
 name: "Krasimir Tsonev",
 profile: { age: 29 }
}

出现错误的原因,是当我们在模板中输入<%profile.age%>的时候,我们得到的data["profile.age"]是undefined的。显然.replace方法是行不通的,我们需要一些别的方法把真正的JS代码插入到<%和%>当中,就像以下栗子:

var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';

这看似不可能完成?John使用了new Function,即通过字符串去创建一个函数的方法去完成这个功能。举个栗子:

var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // 输出 3

fn是个真正的函数,它包含一个参数,其函数体为console.log(arg + 1)。以上代码等价于下列代码:

var fn = function(arg) {
 console.log(arg + 1);
}
fn(2); // 输出 3

通过new Function,我们得以通过字符串去创建一个函数,这正是我们所需要的。在创建这么一个函数之前,我们需要去构造这个它的函数体。该函数体应当返回一个最终拼接好了的模板。沿用前文的模板字符串,想象一下这个函数应当返回的结果:

return 
"<p>Hello, my name is " + 
this.name + 
". I\'m " + 
this.profile.age + 
" years old.</p>";

显然,我们把模板分成了文本和JS代码。正如上述代码,我们使用了简单的字符串拼接的方式去获取最终结果,但是这个方法无法100%实现我们的需求,因为之后我们还要处理诸如循环之类的JS逻辑,像这样:

var template = 
'My skills:' + 
'<%for(var index in this.skills) {%>' + 
'<a href=""><%this.skills[index]%></a>' +
'<%}%>';

如果使用字符串拼接,结果将会变成这样:

return
'My skills:' + 
for(var index in this.skills) { +
'<a href="">' + 
this.skills[index] +
'</a>' +
}

理所当然这会报错。这也是我决定参照John的文章去写逻辑的原因——我把所有的字符串都push到一个数组中,在最后才把它们拼接起来:

var r = [];
r.push('My skills:'); 
for(var index in this.skills) {
r.push('<a href="">');
r.push(this.skills[index]);
r.push('</a>');
}
return r.join('');

下一步逻辑就是整理得到的每一行代码以便生成函数。我们已经从模板中提取出了一些信息,知道了标识片段的内容和位置,所以我们可以通过一个指针变量(cursor)去帮助我们取得最终的结果:

var TemplateEngine = function(tpl, data) {
 var re = /<%([^%>]+)?%>/g,
  code = 'var r=[];\n',
  cursor = 0, match;
 var add = function(line) {
  code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
 }
 while(match = re.exec(tpl)) {
  add(tpl.slice(cursor, match.index));
  add(match[1]);
  cursor = match.index + match[0].length;
 }
 add(tpl.substr(cursor, tpl.length - cursor));
 code += 'return r.join("");'; // <-- return the result
 console.log(code);
 return tpl;
}
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
console.log(TemplateEngine(template, {
 name: "Krasimir Tsonev",
 profile: { age: 29 }
}));

变量code以声明一个数组为开头,作为整个函数的函数体。正如我所说的,指针变量cursor表示我们正处于模板的哪个位置,我们需要它去遍历所有的字符串,跳过填充数据的片段。另外,add函数的任务是把字符串插入到code变量中,作为构建函数体的过程方法。这里有一个棘手的地方,我们需要跳过标识符<%%>,否则当中的JS脚本将会失效。如果我们直接运行上述代码,结果将会是下面的情况:

var r=[];
r.push("<p>Hello, my name is ");
r.push("this.name");
r.push(". I'm ");
r.push("this.profile.age");
return r.join("");

呃……这不是我们想要的。this.namethis.profile.age不应该带引号。我们改进一下add函数:

var add = function(line, js) {
 js? code += 'r.push(' + line + ');\n' :
  code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
var match;
while(match = re.exec(tpl)) {
 add(tpl.slice(cursor, match.index));
 add(match[1], true); // <-- say that this is actually valid js
 cursor = match.index + match[0].length;
}

标识片段中的内容将通过一个boolean值进行控制。现在我们得到了一个正确的函数体:

var r=[];
r.push("<p>Hello, my name is ");
r.push(this.name);
r.push(". I'm ");
r.push(this.profile.age);
return r.join("");

接下来我们要做的就是生成这个函数并且运行它。在这个模板引擎的末尾,我们用以下代码去代替直接返回一个tpl对象:

return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);

我们甚至不需要向函数传递任何的参数,因为apply方法已经为我们完整了这一步工作。它自动设置了作用域,这也是为什么this.name可以运行,this指向了我们的data。

3、代码优化

大致上已经完成了。最后一件事情,我们需要支持更多复杂的表达式,像if/else表达式和循环等。让我们用同样的例子去尝试运行下列代码:

var template = 
'My skills:' + 
'<%for(var index in this.skills) {%>' + 
'<a href="#"><%this.skills[index]%></a>' +
'<%}%>';
console.log(TemplateEngine(template, {
 skills: ["js", "html", "css"]
}));

结果将会报错,错误为Uncaught SyntaxError: Unexpected token for。仔细观察,通过code变量我们可以找出问题所在:

var r=[];
r.push("My skills:");
r.push(for(var index in this.skills) {);
r.push("<a href=\"\">");
r.push(this.skills[index]);
r.push("</a>");
r.push(});
r.push("");
return r.join("");

包含着for循环的代码不应该被push到数组当中,而是直接放在脚本里面。为了解决这个问题,在把代码push到code变量之前我们需要多一步的判断:

var re = /<%([^%>]+)?%>/g,
 reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
 code = 'var r=[];\n',
 cursor = 0;
var add = function(line, js) {
 js? code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n' :
  code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}

我们添加了一个新的正则。这个正则的作用是,如果一段JS代码以if, for, else, switch, case, break, |开头,那它们将会直接添加到函数体中;如果不是,则会被push到code变量中。下面是修改后的结果:

var r=[];
r.push("My skills:");
for(var index in this.skills) {
r.push("<a href=\"#\">");
r.push(this.skills[index]);
r.push("</a>");
}
r.push("");
return r.join("");

理所当然的正确执行啦:

My skills:<a href="#" >js</a><a href="#">html</a><a href="#">css</a>

接下来的修改会给予我们更强大的功能。我们可能会有更加复杂的逻辑会放进模板中,像这样:

var template = 
'My skills:' + 
'<%if(this.showSkills) {%>' +
 '<%for(var index in this.skills) {%>' + 
 '<a href="#"><%this.skills[index]%></a>' +
 '<%}%>' +
'<%} else {%>' +
 '<p>none</p>' +
'<%}%>';
console.log(TemplateEngine(template, {
 skills: ["js", "html", "css"],
 showSkills: true
}));

进行过一些细微的优化之后,最终的版本如下:

var TemplateEngine = function(html, options) {
 var re = /<%([^%>]+)?%>/g, reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g, code = 'var r=[];\n', cursor = 0, match;
 var add = function(line, js) {
  js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
   (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
  return add;
 }
 while(match = re.exec(html)) {
  add(html.slice(cursor, match.index))(match[1], true);
  cursor = match.index + match[0].length;
 }
 add(html.substr(cursor, html.length - cursor));
 code += 'return r.join("");';
 return new Function(code.replace(/[\r\t\n]/g, '')).apply(options);
}

优化后的代码甚至少于15行。

后记(译者注)

这是我第一次完整地翻译文章,语句多有错漏还请多多谅解,今后将继续努力,争取把更多优质的文章翻译分享。

由于对前端的框架、模板引擎一类的工具特别感兴趣,非常希望能够学习当中的原理,于是乎找了个相对简单的模板引擎开刀进行研究,google后看到了这篇文章觉得非常优秀,一步步讲解生动且深入,代码经过本人测试均能正确得到文章描述的结果。

模板引擎有多种设计思路,本文仅仅为其中的一种,其性能等参数还有待测试和提高,仅供学习使用。
谢谢大家~

感兴趣的朋友可以使用在线HTML/CSS/JavaScript代码运行工具:http://tools.3water.com/code/HtmlJsRun测试上述代码运行效果。

希望本文所述对大家JavaScript程序设计有所帮助。

Javascript 相关文章推荐
tagName的使用,留一笔
Jun 26 Javascript
AJAX跨域请求json数据的实现方法
Nov 11 Javascript
js确认删除对话框适用于a标签及submit
Jul 10 Javascript
JQuery 实现在同一页面锚点链接之间的平滑滚动
Oct 29 Javascript
浅谈JavaScript 的执行顺序
Aug 07 Javascript
JS实现仿FLASH效果的竖排导航代码
Sep 15 Javascript
JavaScript数组方法总结分析
May 06 Javascript
EasyUI中的dataGrid的行内编辑
Jun 22 Javascript
基于axios 解决跨域cookie丢失的问题
Sep 26 Javascript
开发用到的js封装方法(20种)
Oct 12 Javascript
Vue数据双向绑定的深入探究
Nov 27 Javascript
JS解惑之Object中的key是有序的么
May 06 Javascript
ES6使用新特性Proxy实现的数据绑定功能实例
May 11 #Javascript
JavaScript异步操作的几种常见处理方法实例总结
May 11 #Javascript
Nuxt默认模板、默认布局和自定义错误页面的实现
May 11 #Javascript
Vue.js获取手机系统型号、版本、浏览器类型的示例代码
May 10 #Javascript
vue总线机制(bus)知识点详解
May 10 #Javascript
vue路由跳转传递参数的方式总结
May 10 #Javascript
javascript单张多张图无缝滚动实例代码
May 10 #Javascript
You might like
国产动画《伍六七》原声大碟大卖,啊哈娱乐引领音乐赋能IP的新尝试
2020/03/08 国漫
PHP的开合式多级菜单程序
2006/10/09 PHP
php中的抽象方法和抽象类
2017/02/14 PHP
php array_map()函数实例用法
2021/03/03 PHP
javascript 动态调整图片尺寸实现代码
2009/12/28 Javascript
dojo随手记 gird组件引用
2011/02/24 Javascript
jQuery中的jQuery()方法用法分析
2014/12/27 Javascript
jquery移动节点实例
2015/01/14 Javascript
jquery-tips悬浮提示插件分享
2015/07/31 Javascript
原生js实现数字字母混合验证码的简单实例
2015/12/10 Javascript
超实用的JavaScript表单代码段
2016/02/26 Javascript
jQuery实现6位数字密码输入框
2016/12/29 Javascript
js实现带缓动动画的导航栏效果
2017/01/16 Javascript
浅谈VUE-CLI脚手架热更新太慢的原因和解决方法
2018/09/28 Javascript
详解JS实现简单的时分秒倒计时代码
2019/04/25 Javascript
详解nuxt 微信公众号支付遇到的问题与解决
2019/08/26 Javascript
基于vue+element实现全局loading过程详解
2020/07/10 Javascript
[47:22]Mineski vs Winstrike 2018国际邀请赛小组赛BO2 第二场 8.16
2018/08/17 DOTA
python中 chr unichr ord函数的实例详解
2017/08/06 Python
Python使用回溯法子集树模板获取最长公共子序列(LCS)的方法
2017/09/08 Python
解决Python网页爬虫之中文乱码问题
2018/05/11 Python
Django 浅谈根据配置生成SQL语句的问题
2018/05/29 Python
Python制作微信好友背景墙教程(附完整代码)
2019/07/17 Python
python实现按关键字筛选日志文件
2019/12/24 Python
Python *args和**kwargs用法实例解析
2020/03/02 Python
详解django使用include无法跳转的解决方法
2020/03/19 Python
python+opencv3.4.0 实现HOG+SVM行人检测的示例代码
2021/01/28 Python
基于CSS3实现的漂亮Menu菜单效果代码
2015/09/10 HTML / CSS
企业员工培训感言
2014/02/26 职场文书
继承公证书
2014/04/09 职场文书
运动会广播稿50字-100字
2014/10/11 职场文书
2015年大学生党员承诺书
2015/04/27 职场文书
工作收入证明模板
2015/06/12 职场文书
仓库管理制度范本
2015/08/04 职场文书
排查并解决MySQL生产库内存使用率高的报警
2022/04/11 MySQL
oracle delete误删除表数据后如何恢复
2022/06/28 Oracle