写于 2016.06.13
前言
我仍旧在为我的 JS 预处理器进行开发工作. 它原本是一个 CSS 预处理器, 但之后它扩展成为了 CSS/html 预处理器, 很快它将支持 JS 到 CSS/HTML 的转换. 它就像一个模板引擎一样能够生成 HTML 代码, 也就是说它能够用数据填充模板当中的标识片段.
因此, 我希望去写一个可以满足我当前需求的模板引擎. 主要作为 Node.JS 的模块使用, 但同时它也可以在客户端使用. 为了这个目的, 我无法使用市面上已经存在的模板引擎, 因为它们几乎全都依赖于 Node.JS, 并且难以在浏览器中使用. 我需要一个更小, 纯 JS 写成的模板引擎. 我浏览了这篇由 John Resig 写的博客 http://ejohn.org/blog/javascript-micro-templating/ , 似乎这正是我需要的东西. 我把当中的代码稍作修改, 并且浓缩到了 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)表示我们将匹配多个. 有许多的方法能够用于匹配正则, 但是我们只需要一个能够装载字符串的数组就够了, 这正是所做的工作:
- 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.name 和 this.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 后看到了这篇文章觉得非常优秀, 一步步讲解生动且深入, 代码经过本人测试均能正确得到文章描述的结果.
模板引擎有多种设计思路, 本文仅仅为其中的一种, 其性能等参数还有待测试和提高, 仅供学习使用. 谢谢大家~
来源: https://juejin.im/post/5c0e5042f265da61524d3a43