教你使用javascript简单写一个页面模板引擎


Posted in Javascript onMay 05, 2015

于是我又想着能不能写一些简单的代码来完善这个模板引擎,又能与其它现有的逻辑协同工作。AbsurdJS本身主要是以NodeJS的模块的形式发布的,不过它也会发布客户端版本。考虑到这些,我就不能直接使用现有的引擎了,因为它们大部分都是在NodeJS上运行的,而不能跑在浏览器上。我需要的是一个小巧的,纯粹以Javascript编写的东西,能够直接运行在浏览器上。当我某天偶然发现John Resig的这篇博客,我惊喜地发现,这不正是我苦苦寻找的东西嘛!我稍稍做了一些修改,代码行数差不多20行左右。其中的逻辑非常有意思。在这篇文章中我会一步一步重现编写这个引擎的过程,如果你能一路看下去的话,你就会明白John的这个想法是多么犀利!

最初我的想法是这样子的:

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)表示不只匹配一个,而是匹配所有符合的片段。Javascript里面有很多种使用正则表达式的方法,我们需要的是根据正则表达式输出一个数组,包含所有的字符串,这正是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>"
]

不过我们可以看见,返回的数组仅仅包含第一个匹配项。我们需要用while循环把上述逻辑包起来,这样才能得到所有的匹配项。

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

如果把上面的代码跑一遍,你就会看见<%name%> 和 <%age%>都被打印出来了。

下面,有意思的部分来了。识别出模板中的匹配项后,我们要把他们替换成传递给函数的实际数据。最简单的办法就是使用replace函数。我们可以像这样来写:

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

好了,这样就能跑了,但是还不够好。这里我们以data["property"]的方式使用了一个简单对象来传递数据,但是实际情况下我们很可能需要更复杂的嵌套对象。所以我们稍微修改了一下data对象:

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

不过直接这样子写的话还不能跑,因为在模板中使用<%profile.age%>的话,代码会被替换成data[‘profile.age'],结果是undefined。这样我们就不能简单地用replace函数,而是要用别的方法。如果能够在<%和%>之间直接使用Javascript代码就最好了,这样就能对传入的数据直接求值,像下面这样:

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); // outputs 3

fn可是一个货真价实的函数。它接受一个参数,函数体是console.log(arg + 1);。上述代码等价于下面的代码:

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

通过这种方法,我们可以根据字符串构造函数,包括它的参数和函数体。这不正是我们想要的嘛!不过先别急,在构造函数之前,我们先来看看函数体是什么样子的。按照之前的想法,这个模板引擎最终返回的应该是一个编译好的模板。还是用之前的模板字符串作为例子,那么返回的内容应该类似于:

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

当然啦,实际的模板引擎中,我们会把模板切分为小段的文本和有意义的Javascript代码。前面你可能看见我使用简单的字符串拼接来达到想要的效果,不过这并不是100%符合我们要求的做法。由于使用者很可能会传递更加复杂的Javascript代码,所以我们这儿需要再来一个循环,如下:

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的文章里写的逻辑,把所有的字符串放在一个数组里,在程序的最后把它们拼接起来。

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;
  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中去。有一个地方需要特别注意,那就是需要把code包含的双引号字符进行转义(escape)。否则生成的函数代码会出错。如果我们运行上面的代码,我们会在控制台里面看见如下的内容:

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.name和this.profile.age不应该有引号啊,再来改改。

var add = function(line, js) {
  js? code += 'r.push(' + line + ');\n' :
    code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
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;
}

占位符的内容和一个布尔值一起作为参数传给add函数,用作区分。这样就能生成我们想要的函数体了。

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("");

剩下来要做的就是创建函数并且执行它。因此,在模板引擎的最后,把原本返回模板字符串的语句替换成如下的内容:

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

我们甚至不需要显式地传参数给这个函数。我们使用apply方法来调用它。它会自动设定函数执行的上下文。这就是为什么我们能在函数里面使用this.name。这里this指向data对象。

模板引擎接近完成了,不过还有一点,我们需要支持更多复杂的语句,比如条件判断和循环。我们接着上面的例子继续写。

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循环的那一行不应该被直接放到数组里面,而是应该作为脚本的一部分直接运行。所以我们在把内容添加到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';
}

这里我们新增加了一个正则表达式。它会判断代码中是否包含if、for、else等等关键字。如果有的话就直接添加到脚本代码中去,否则就添加到数组中去。运行结果如下:

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;
  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行!

Javascript 相关文章推荐
jquery 获取json数据实现代码
Apr 27 Javascript
jQuery 性能优化指南 (1)
May 21 Javascript
JS 树形递归实例代码
May 18 Javascript
Extjs gridpanel 出现横向滚动条问题的解决方法
Jul 04 Javascript
jQuery中fadeIn、fadeOut、fadeTo的使用方法(图片显示与隐藏)
May 08 Javascript
关于js内存泄露的一个好例子
Dec 09 Javascript
JavaScript Length 属性的总结
Nov 02 Javascript
js获取对象、数组的实际长度,元素实际个数的实现代码
Jun 08 Javascript
一文让你彻底搞清楚javascript中的require、import与export
Sep 24 Javascript
Vue实现滑动拼图验证码功能
Sep 15 Javascript
精读《Vue3.0 Function API》
May 20 Javascript
JavaScript实现简单图片切换
Apr 29 Javascript
关于延迟加载JavaScript
May 05 #Javascript
Javascript闭包(Closure)详解
May 05 #Javascript
javascript实现仿IE顶部的可关闭警告条
May 05 #Javascript
JS实现点击按钮后框架内载入不同网页的方法
May 05 #Javascript
JS实现随机乱撞彩色圆球特效的方法
May 05 #Javascript
jquery实现图片随机排列的方法
May 04 #Javascript
jquery实现的美女拼图游戏实例
May 04 #Javascript
You might like
yii2利用自带UploadedFile实现上传图片的示例
2017/02/16 PHP
thinkphp项目如何自定义微信分享描述内容
2017/02/20 PHP
PHP ADODB生成下拉列表框功能示例
2018/05/29 PHP
ExtJS中设置下拉列表框不可编辑的方法
2014/05/07 Javascript
用js模拟struts2的多action调用示例
2014/05/19 Javascript
Node.js中HTTP模块与事件模块详解
2014/11/14 Javascript
Javascript中字符串和数字的操作方法整理
2017/01/22 Javascript
基于jquery实现二级联动效果
2017/03/30 jQuery
微信小程序 数据绑定及运算的简单实例
2017/09/20 Javascript
Layui数据表格判断编辑输入的值,是否为我需要的类型详解
2019/10/26 Javascript
jQuery高级编程之js对象、json与ajax用法实例分析
2019/11/01 jQuery
JS实现压缩上传图片base64长度功能
2019/12/03 Javascript
解决Ant Design Modal内嵌Form表单initialValue值不动态更新问题
2020/10/29 Javascript
[36:29]2018DOTA2亚洲邀请赛 4.1 小组赛 A组加赛 LGD vs TNC
2018/04/02 DOTA
python实现基于SVM手写数字识别功能
2020/05/27 Python
Python 实现「食行生鲜」签到领积分功能
2018/09/26 Python
Python3实现的反转单链表算法示例
2019/03/08 Python
Python脚本利用adb进行手机控制的方法
2019/07/08 Python
Python爬虫 urllib2的使用方法详解
2019/09/23 Python
详解使用django-mama-cas快速搭建CAS服务的实现
2019/10/30 Python
python对Excel按条件进行内容补充(推荐)
2019/11/24 Python
python中的selenium安装的步骤(浏览器自动化测试框架)
2020/03/17 Python
CSS3 简写animation
2012/05/10 HTML / CSS
波兰在线运动商店:YesSport
2020/07/23 全球购物
TCP/IP模型的分界线
2012/12/01 面试题
学生学习总结的自我评价
2013/10/22 职场文书
大学生实习证明范本
2014/01/15 职场文书
工程造价专业大学生职业规划范文
2014/03/09 职场文书
心理学专业求职信
2014/06/16 职场文书
检查机关党的群众路线个人整改措施
2014/10/04 职场文书
小学生勤俭节约倡议书
2015/04/29 职场文书
餐饮行业关注的9大营销策略
2019/08/26 职场文书
goland 设置project gopath的操作
2021/05/06 Golang
Flask搭建一个API服务器的步骤
2021/05/28 Python
浅谈MySql整型索引和字符串索引失效或隐式转换问题
2021/11/20 MySQL
十大最强岩石系宝可梦,怪颚龙实力最强,第七破坏力很强
2022/03/18 日漫