什么是模板引擎
按照维基百科的定义,模板引擎是将模板与数据模型相结合以生成结果文档的工具。
这四者的关系大概如下图所示:
本文将从一个空白文件夹开始,通过渐进式的迭代开发,尝试完成一个基于 Express 的、具备基本功能的模板引擎。
准备工作
我们首选搭建一个简单的服务器,为后续的开发做准备。
新建一个文件夹 viewEngine
,进入该文件夹,在终端执行下列命令创建 package.json
配置文件
BASH$ npm init
添加 Express 模块到依赖项
BASH$ npm install --save express
在当前目录下手动添加 app.js
文件,作为 node 服务器的启动文件。
现在,viewEngine
的目录结构大概是这样的:
目录结构viewEngine/ --> 项目根目录 ├──node_modules/ --> 存放项目依赖的模块 ├──app.js --> 启动文件 └──package.json --> npm配置文件
我们给 app.js
加一点代码,创建一个最简单的基于 Express 的服务器
JAVASCRIPTconst express = require('express');
const app = express();
app.get('/', (req, res) => {
res.end('Hello World!');
});
app.listen(8080);
请使用 v4.0 以上版本的 node.js,以支持基本的 es2015 语法。
打开终端,执行
BASH$ node app.js
访问 http://localhost:8080,如果看到页面显示 Hello World! ,就说明准备工作已就绪。
迭代开发
第1次迭代
通过查阅官方文档,按照示例,我们对之前的项目做一些修改。
首先添加 /views/
目录,用来存放默认的模板文件,并添加 index.tpl
作为首页模板,再在根目录下添加 tpl.js
脚本,即模板引擎文件。
目录结构viewEngine/ --> 项目根目录 ├──node_modules/ --> 存放项目依赖的模块 ├──views/ --> 存放模板文件 │ └──index.tpl --> 首页模板文件 ├──app.js --> 启动文件 ├──tpl.js --> 自定义模板引擎文件 └──package.json --> npm配置文件
在 app.js
中,通过 app.engine
方法注册一个模板引擎,该模板引擎就是 tpl.js
脚本文件导出的模块,然后通过 app.set('view engine', 'tpl')
来启用刚刚注册的模板引擎,这样,Express
就会将 res.render
传入的模板路径和数据模型,交给 tpl
模板引擎去处理。
JAVASCRIPTconst express = require('express');
const app = express();
const tpl = require('./tpl');
app.engine('tpl', tpl);
app.set('view engine', 'tpl');
app.get('/', (req, res) => {
res.render('index', {
title: 'This is title',
message: 'Hi there!'
});
});
app.listen(8080);
接着修改模板文件,我们约定 {{
}}
之间的是后端脚本,其余都是普通的 HTML 标记。
views/index.tpl
:
HTML<h2>{{title}}</h2>
<p>{{message}}</p>
Express 模板引擎模块必须导出一个函数,该函数的入参包括模板文件路径、数据模型和回调,按照约定俗成,回调的第一个参数是错误对象,第二个参数是最终处理好的 HTML 字符串。
仿照示例,我们先直接用数据模型中的 title
、message
的值去替换模板文件中的 {{title}}
、{{message}}
。
tpl.js
:
JAVASCRIPTconst fs = require('fs');
module.exports = function(filePath, options, callback) {
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
var template = content.toString()
.replace('{{title}}', options.title)
.replace('{{message}}', options.message)
return callback(null, template);
});
}
重启服务器,如果一切顺利,页面应该展示成这样:
第2次迭代
在第一次迭代,对于模板文件中的动态数据,我们采用的是硬编码 —— 很显然,这在实际项目中是没法用的。
我们首先想到的办法,就是利用正则表达式来替换 {{
}}
中的变量,匹配到的子表达式对应数据模型对象的属性名。
JAVASCRIPTconst fs = require('fs');
module.exports = function(filePath, options, callback) {
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
var template = content.toString()
.replace(/{{(.+?)}}/g, (s, s1) => {
return options[s1];
});
return callback(null, template);
});
}
这里使用了+?将贪婪匹配转换为了懒惰匹配,对于字符串'{{a}}test{{b}}c',就能匹配到'{{a}}'和'{{b}}',而不是最长的结果'{{a}}test{{b}}'
现在,我们考虑一点更多的情况,将 app.js
修改为
JAVASCRIPTres.render('index', {
title: 'This is title',
data: {
message: 'Hi there!'
}
});
views/index.tpl
修改为
HTML<h2>{{title}}</h2>
<p>{{data.message}}</p>
此时的输出结果是:
这是因为 obj["data.message"]
的属性名称字符串包含了多层级,语法上已经不支持了,无法正确获取到我们期望的 obj.data.message
的值。
第3次迭代
仔细观察发现,其实我们最终想要的,就是将模板文件的内容处理成这样:
JAVASCRIPT'<h2>' + title + '</h2>\n<p>' + data.message + '</p>'
稍微优化下,多字符串的拼接我们可以使用数组来完成:
JAVASCRIPTvar r = [];
r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>');
r.join('');
我们将上述代码封装到一个函数中
JAVASCRIPTvar fn = function(model) {
var r = [];
r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>');
return r.join('');
}
按照预期,这个函数应该接收一个数据模型 model
,即调用 res.render
传入的第2个参数
JAVASCRIPT{
title: 'This is title',
data: {
message: 'Hi there!'
}
}
但 push 到数据中的变量,写的是 title
,data.message
,而不是 model.title
,model.data.message
,这儿又该如何处理呢?
我的一款开源项目 Saker 中采用的解决方案是动态定义变量(对应源码见这里),另一种常见的方式就是使用 with 来扩展作用域链(但性能较差)。简单起见,这里我们使用 with
来实现
JAVASCRIPTvar fn = function(model) {
with(model) {
var r = [];
r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>');
return r.join('');
}
}
这样,title
,data.message
就会在对象 model
的作用域中去寻找。我们试着调用函数 fn
,将数据模型传入,结果与预期一致。
输出<h2>This is title</h2> <p>Hi there!</p>
看上去似乎很不错,但仔细想想似乎不对——我们根本没有这个函数,因为函数内部的这行代码
JAVASCRIPTr.push('<h2>', title, '</h2>\n<p>', data.message, '</p>');
是动态的,它是 JavaScript 语句,而我们仅仅能获取到模板文件的内容(字符串)。那有没有办法将字符串解析成 JavaScript 语句呢?
幸运的是,Function 构造函数提供了这一功能(类似的还有 eval)。我们只需在创建实例时,将参数 model
和函数 fn
内部的代码都作为字符串传入
JAVASCRIPTvar newFn = new Function('model', `
with(model) {
var r = [];
r.push('<h2>', title, \`</h2>\n<p>\`, data.message, '</p>');
return r.join('');
}
`);
普通字符串内部不允许有换行,所以这里用模板字符串 `</h2>\n<p>` 替换了之前的 '</h2>\n<p>'
调用函数 newFn
,传入用户数据,结果与之前的直接声明一个函数并调用完全相同。
现在就只剩下最后一个问题了,如何将模板文件的内容(字符串)
HTML<h2>{{title}}</h2>
<p>{{data.message}}</p>
处理成为下面这样的字符串?
JAVASCRIPTwith(model) {
var r = [];
r.push('<h2>', title, \`</h2>\n<p>\`, data.message, '</p>');
return r.join('');
}
观察后我们发现,真正需要处理的,只是 r.push('...')
中的内容,别的字符串都是固定不变的。
再进一步对比,其实只需要将字符串 {{xxx}}
变成 , xxx,
就可以了。
修改 tpl.js
JAVASCRIPTconst fs = require('fs');
module.exports = function(filePath, options, callback) {
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
var template = content.toString()
.replace(/{{(.+?)}}/g, '`, $1, `');
var code = `
with(model) {
var r = [];
r.push(\`${template}\`);
return r.join('');
}
`;
console.log(code);
var fn = new Function('model', code);
return callback(null, fn(options));
});
}
我们通过 console.log
打印一下将要作为函数体的字符串:
输出with(model) { var r = []; r.push(`<h2>`, title, `</h2> <p>`, data.message, `</p>`); return r.join(''); }
打印结果与我们期望一致。
第4次迭代
到目前为止,我们仅仅在模板文件中使用了变量,还没有使用一些常用语句,比如条件语句、循环语句等等。
将 app.js
修改为
JAVASCRIPTres.render('index', {
title: 'This is title',
data: {
code: 1,
message: 'Hi there!'
}
});
views/index.tpl
修改为
HTML<h2>{{title}}</h2>
{{ if data.code === 1 { }}
<p>{{data.message}}</p>
{{ } }}
模板引擎 tpl.js
的代码我们暂不做修改,直接运行,看下 console.log
的打印结果:
输出with(model) { var r = []; r.push(`<h2>`, title, `</h2> `, if data.code === 1 { , ` <p>`, data.message, `</p> `, } , ``); return r.join(''); }
第 4 行出现了明显的语法错误,if data.code === 1 {
是条件语句,并不是普通变量,不能作为参数 push 到数组中去。
我们期望的函数体代码,应该是这样子的 :
JAVASCRIPTwith(model) {
var r = [];
r.push(`<h2>`, title, `</h2>`);
if data.code === 1 {
r.push(`<p>`, data.message, `</p>`);
}
return r.join('');
}
从中可以看出,变量应该是 push 到数组中,而语句应该是直接执行,所以我们有必要将后端脚本,区分成普通变量和语句这两种情况来处理。
既然分成了两种情况,在模板文件中我们就需要用不同的标记,我们重新约定 {{
}}
之间的是语句,{{=
}}
之间的视作普通变量。
views/index.tpl
修改为
HTML<h2>{{=title}}</h2>
{{ if (data.code === 1) { }}
<p>{{=data.message}}</p>
{{ } }}
与第 3 次迭代一样,现在的问题依然是:如何将模板的内容(字符串),变成上面那段所期望的函数体代码字符串?
经过仔细观察,我们发现,普通变量的处理方式和前面类似,只需要将字符串 {{=xxx}}
变成 , xxx,
就可以了。
而一个语句,它的开头必定意味着之前的 push 方法已经结束了,它的末尾则需要一个新的 r.push
代码,也就是将字符串 {{ if (bool) { }}
变成 ); if (bool) { r.push(
即可。
修改 tpl.js
JAVASCRIPTconst fs = require('fs');
module.exports = function(filePath, options, callback) {
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
var template = content.toString()
.replace(/{{=(.+?)}}/g, '`, $1, `')
.replace(/{{(.+?)}}/g, '`); $1 r.push(`');
var code = `with(model) {
var r = [];
r.push(\`${template}\`);
return r.join('');
}
`;
console.log(code);
var fn = new Function('model', code);
return callback(null, fn(options));
});
}
再通过 console.log
打印一下作为函数体的字符串:
输出with(model) { var r = []; r.push(`<h2>`, title, `</h2> `); if (data.code === 1) { r.push(` <p>`, data.message, `</p> `); } r.push(``); return r.join(''); }
输出结果完全符合我们的期望。
第5次迭代
其实到这里,我们写的这个简单的模板引擎已经具备了基本功能,可以使用了。接下来的迭代,我们可以继续添加新的功能以进一步完善。
由于每次处理请求生成 html 字符串,都会先去读取模板文件,我们可以尝试增加缓存功能,来减少磁盘 I/O,提高性能。
实际的情况是,模板文件在服务器运行过程中是不会改变的,变化的仅仅是传给模板引擎的数据模型。所以,最佳的缓存内容,就是数据模型在被使用之前所能拿到的对象,分析模板引擎代码,显然我们可以将创建的函数 fn
作为缓存内容。
修改 tpl.js
JAVASCRIPTconst fs = require('fs');
var cache = {};
module.exports = function(filePath, options, callback) {
if (cache[filePath]) {
return callback(null, cache[filePath](options));
}
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
var template = content.toString()
.replace(/{{=(.+?)}}/g, '`, $1, `')
.replace(/{{(.+?)}}/g, '`); $1 r.push(`');
var code = `with(model) {
var r = [];
r.push(\`${template}\`);
return r.join('');
}
`;
var fn = new Function('model', code);
cache[filePath] = fn;
return callback(null, fn(options));
});
}
总结
我们通过数次的迭代开发,最终完成了一个简单的模板引擎的开发。每一次的迭代,都是对之前代码功能的进一步完善和优化。
迭代流程:
其实,我们编码的过程,就是将自然语言转化为编程语言的过程,本质上讲就是找规律的过程,这在整个迭代中都有体现;
另外,真正有点复杂的,只有第三次迭代 —— 除了转换思路,还需要熟悉 with
和 new Function
的使用,否则依然无从下手;
综上,良好的技术基础 + 善于寻找规律 + 一点悟性,是作为一名开发最重要的品质。