自己动手开发 Node.js 模板引擎

什么是模板引擎

按照维基百科的定义,模板引擎是将模板与数据模型相结合以生成结果文档的工具。

这四者的关系大概如下图所示:

image

本文将从一个空白文件夹开始,通过渐进式的迭代开发,尝试完成一个基于 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 的服务器

JAVASCRIPT
const 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 模板引擎去处理。

JAVASCRIPT
const 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 字符串。

仿照示例,我们先直接用数据模型中的 titlemessage 的值去替换模板文件中的 {{title}}{{message}}

tpl.js:

JAVASCRIPT
const 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); }); }

重启服务器,如果一切顺利,页面应该展示成这样:
image

第2次迭代

在第一次迭代,对于模板文件中的动态数据,我们采用的是硬编码 —— 很显然,这在实际项目中是没法用的。

我们首先想到的办法,就是利用正则表达式来替换 {{ }} 中的变量,匹配到的子表达式对应数据模型对象的属性名。

JAVASCRIPT
const 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 修改为

JAVASCRIPT
res.render('index', { title: 'This is title', data: { message: 'Hi there!' } });

views/index.tpl 修改为

HTML
<h2>{{title}}</h2> <p>{{data.message}}</p>

此时的输出结果是:

image

这是因为 obj["data.message"] 的属性名称字符串包含了多层级,语法上已经不支持了,无法正确获取到我们期望的 obj.data.message 的值。

第3次迭代

仔细观察发现,其实我们最终想要的,就是将模板文件的内容处理成这样:

JAVASCRIPT
'<h2>' + title + '</h2>\n<p>' + data.message + '</p>'

稍微优化下,多字符串的拼接我们可以使用数组来完成:

JAVASCRIPT
var r = []; r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>'); r.join('');

我们将上述代码封装到一个函数中

JAVASCRIPT
var 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 到数据中的变量,写的是 titledata.message,而不是 model.titlemodel.data.message,这儿又该如何处理呢?

我的一款开源项目 Saker 中采用的解决方案是动态定义变量(对应源码见这里),另一种常见的方式就是使用 with 来扩展作用域链(但性能较差)。简单起见,这里我们使用 with 来实现

JAVASCRIPT
var fn = function(model) { with(model) { var r = []; r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>'); return r.join(''); } }

这样,titledata.message 就会在对象 model 的作用域中去寻找。我们试着调用函数 fn,将数据模型传入,结果与预期一致。

输出
<h2>This is title</h2> <p>Hi there!</p>

看上去似乎很不错,但仔细想想似乎不对——我们根本没有这个函数,因为函数内部的这行代码

JAVASCRIPT
r.push('<h2>', title, '</h2>\n<p>', data.message, '</p>');

是动态的,它是 JavaScript 语句,而我们仅仅能获取到模板文件的内容(字符串)。那有没有办法将字符串解析成 JavaScript 语句呢?

幸运的是,Function 构造函数提供了这一功能(类似的还有 eval)。我们只需在创建实例时,将参数 model 和函数 fn 内部的代码都作为字符串传入

JAVASCRIPT
var 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>

处理成为下面这样的字符串?

JAVASCRIPT
with(model) { var r = []; r.push('<h2>', title, \`</h2>\n<p>\`, data.message, '</p>'); return r.join(''); }

观察后我们发现,真正需要处理的,只是 r.push('...') 中的内容,别的字符串都是固定不变的。

再进一步对比,其实只需要将字符串 {{xxx}} 变成 , xxx, 就可以了。

修改 tpl.js

JAVASCRIPT
const 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 修改为

JAVASCRIPT
res.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 到数组中去。

我们期望的函数体代码,应该是这样子的 :

JAVASCRIPT
with(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

JAVASCRIPT
const 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

JAVASCRIPT
const 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)); }); }

总结

我们通过数次的迭代开发,最终完成了一个简单的模板引擎的开发。每一次的迭代,都是对之前代码功能的进一步完善和优化。

迭代流程:
image

其实,我们编码的过程,就是将自然语言转化为编程语言的过程,本质上讲就是找规律的过程,这在整个迭代中都有体现;

另外,真正有点复杂的,只有第三次迭代 —— 除了转换思路,还需要熟悉 withnew Function 的使用,否则依然无从下手;

综上,良好的技术基础 + 善于寻找规律 + 一点悟性,是作为一名开发最重要的品质。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 iBlog

阅读 608 | 发布于 2017-06-06
暂无评论