简介
模板引擎,其实就是一个根据模板和数据输出结果的一个工具。
我们要开发一个将模板文件转换成我们实际要使用的内容的工具,这个工具就是模板引擎。我们把模板文件里的内容当成字符串传入到模板引擎中,然后模板引擎根据一定语法对该字符串进行解析处理,然后返回一个函数,之后我们在执行函数时把数据传输进去,即可拿到根据模板和数据得到的新字符串。最后我们想怎么处理该字符串就看需求了,如果用于前端模板生成的话,则可以用dom的innerHTML这个属性来追加内容。
目前前端的模板引擎多得数不胜数,语法特性也花样百出,用行内的话来说,我们要实现的是一种基于字符串的模板引擎。
简要概述流程如下:
优劣
- 此模板引擎可用于任意一端,前端后端即插即用,不局限于生成内容的语法,只要生成内容为字符串文本即可。比如在合并Sprite图工具中要根据图片大小位置生成对应的css定位文件,我们也可以用该引擎生成而不需要另外再写一套引擎。
- 此模板引擎对于数据的更改,需要重新渲染一遍模板,所以在初次渲染和之后的模板更新需要耗费同样的资源。
- 应用于前端时,此模板引擎依赖于innerHTML,存在注入问题。
需求
而此次,我们希望实现一个基于字符串的模板引擎。提供的使用方式尽可能简单,比如类似如下的方式:
1
2
3
4
5
6
7
8
9
10
|
// 前端
var html = window.parse('<div>${content}</div>', {
content: 'june'
});
// 后端
const parse = require('tpl');
var html = parse('<div>${content}</div>', {
content: 'june'
});
|
并且希望至少提供以下四种语法:
条件判断
1
2
3
4
5
6
7
|
{if condition1}
// code1
{elseif condition2}
// code2
{else}
// code3
{/if}
|
数组遍历
1
2
3
4
|
{list array as item}
// code
// PS:里面注入了一个变量item_index,指向item在遍历过程中的序号
{/list}
|
变量定义
1
|
{var var1 = 1}
|
插值
1
2
3
4
5
|
// 直接插值
${var1}
// 使用过滤器插值的方式
${var1|filter1|filter2:var2, var3}
|
开工
STEP 1
按照前面定下的需求,我们先实现一个对外的接口,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
'use strict';
var __PARSE__ = (function() {
/**
* 默认的过滤器
*/
const defaultFilter = {
// some code
};
/*
* 解析模板
*/
let doParseTemplate(content, data, filter) {
// some code
};
return function(content, data, filter) {
try {
data = data||{};
filter = Object.assign({}, defaultFilter, filter);
// 解析模板生成代码生成器
let f = doParseTemplate(content, data, filter);
return f(data, filter);
} catch(ex) {
return ex.stack;
}
};
})();
if(typeof module !== 'undefined' && typeof exports === 'object') {
module.exports = __PARSE__;
} else {
window.parse = __PARSE__;
}
|
此处,f即是我们生成的函数,而生成该函数的函数我命名为doParseTemplate,接收三个参数,content是我们输入的模板文件的字符串内容,data是我们要传入的数据,而filter即为模板中可传入的过滤器。目前doParseTemplate这个函数还未实现,接下来就来实现此函数。
STEP 2
为了生成一个可用的函数,我们要通过new Function(‘DATA’, ‘FILTER’, content);这样的方法来构造一个函数,其中content即是函数体的字符串内容。
我们先设定要生成的函数f的结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function(DATA, FILTER) {
try {
var OUT = [];
// 处理变量
// some code
// 处理过滤器
// some code
// 处理内容
// other code
return OUT.join('');
} catch(e) {
throw new Error('parse template error!');
}
}
|
事实上,注释中处理变量、处理过滤器和处理内容这部分是由外部传入决定的,所以只有这部分是可变的,其余的代码都是固定的。为此我们可以使用数组来存放相关的内容,然后在可变部分留一个占位符,在解析到处理变量、处理过滤器和处理内容部分时再把语句塞进去即可。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
let doParseTemplate = function(content, data, filter) {
content = content.replace(/\\t/g, ' ').replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r');
// 初始化模板生成器结构
var struct = [
'try { var OUT = [];',
'', //放置模板生成器占位符
'return OUT.join(\\'\\'); } catch(e) { throw new Error("parse template error!"); }'
];
// some code
return new Function('DATA', 'FILTER', struct.join(''));
}
|
现在固定结构有了,接下来我们要处理模板相关的内容,即在放置生成器占位符的那个位置上追加内容。首先,我们要先把输入的变量和过滤器处理好,即在占位符的位置加入诸如var a = 1;这样的内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
doParseTemplate = function(content, data) {
content = content.replace(/\\t/g, ' ').replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r');
// 初始化模板生成器结构
let out = [];
let struct = [
'try { var OUT = [];',
'', //放置模板生成器占位符
'return OUT.join(\\'\\'); } catch(e) { throw e; }'
];
// 初始化模板变量
let vars = [];
Object.keys(data).forEach((name) => {
vars.push(`var ${name} = DATA['${name}'];`);
});
out.push(vars.join(''));
// 初始化过滤器
let filters = ['var FILTERS = {};'];
Object.keys(filter).forEach((name) => {
if(typeof filter[name] === 'function') {
filters.push(`FILTERS['${name}'] = FILTER['${name}'];`);
}
});
out.push(filters.join(''));
// some code for parse content
// 合并内容
struct[1] = out.join('');
return new Function('DATA', 'FILTER', struct.join(''));
}
|
如上,在处理变量和过滤器时需要的值直接从传入的DATA和FILTER变量里获取,需要注意的点就是过滤器我们单独存在一个FILTERS对象里面去,主要是为了防止传入的FILTER对象变化带来的一些不必要的影响。之后我们要对模板内容进行解析,鉴于代码越来越长,接下来直接贴上面注释some code for parse content里面的内容,其他部分暂且省略。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
// 解析模板内容
let beg = 0; // 解析文段起始位置
let stmbeg = 0; // 表达式起始位置
let stmend = 0; // 表达式结束位置
let len = content.length;
let preCode = ''; // 表达式前的代码
let endCode = ''; // 最后一段代码
let stmJs = ''; // 表达式
while(beg < len) {
/* 开始符 */
stmbeg = content.indexOf('{', beg);
while(content.charAt(stmbeg - 1) === '\\\\') {
// 遇到转义的情况
stmbeg = content.indexOf('{', stmbeg + 1);
}
if(stmbeg === -1) {
// 到达最后一段代码
endCode = content.substr(beg);
out.push('OUT.push(\\'' + endCode + '\\');');
break;
}
/* 结束符 */
stmend = content.indexOf('}', stmbeg);
while(content.charAt(stmend - 1) === '\\\\') {
// 遇到转义的情况
stmend = content.indexOf('}', stmend + 1);
}
if(stmend === -1) {
// 没有结束符
break;
}
// 开始符之前代码
preCode = content.substring(beg, stmbeg);
if(content.charAt(stmbeg - 1) === '$') {
// 针对变量取值
out.push(`OUT.push(\\'${preCode.substr(0, preCode.length-1)}\\');`);
stmJs = content.substring(stmbeg + 1, stmend);
// 处理过滤器
let tmp = '';
stmJs.split('|').forEach((item, index) => {
if(index === 0) {
// 变量,强制转码
tmp = item;
} else {
// 过滤器
let farr = item.split(':');
tmp = `FILTERS['${farr[0]}'](${tmp}`;
if(farr[1]) {
// 带变量的过滤器
farr[1].split(',').forEach((fitem) => {
tmp = `${tmp}, ${fitem}`;
});
}
tmp = `${tmp})`; // 追加结尾
}
});
out.push(`OUT.push((${tmp}).toString());`);
} else {
// 针对js语句
out.push(`OUT.push(\\'${preCode}\\');`);
stmJs = content.substring(stmbeg + 1, stmend);
out.push(transStm(stmJs));
}
beg = stmend + 1;
}
|
对于模板内容的解析,因为语法相对简单,此处直接使用while循环遍历。在我们上面定义的语法中,有关结构相关的语法都用{和}来包围,插值则是${和},因此针对这两种语法需要分开处理。整个流程的判断如下:
- 搜索语句开始符{;
- 判断{前面是否有转义符\;
- 搜索语句结束符};
- 判断}前面是否有转义符\;
- 判断{前面是否带有取值符号$;
- 提取语句内容,即{和}里面的内容;
- 将语句之前,即{或${之前未放入缓存区的内容放入缓存区;
- 解析语句,并把解析结果放入缓存区;
- 循环上述1-8的过程,直到搜索不到语句开始符{,则判断为结尾,把剩下的内容放入缓存区;
- 把目前缓存区的的内容存到需要输出的数组中。
以上提到的缓存区,即是上面代码中的out数组。当遍历完模板内容后,把缓存区合并成一个字符串,然后追加到占位符末尾。其中关于语句的解析用到的函数transStm目前接下来将要实现。
STEP 3
transStm函数实现比较简单,因为我们需求中设定的语法也不复杂。代码如下:
1
2
3
4
5
6
7
8
9
10
11
|
/*
* 转换模板语句
*/
let transStm = function(stmJs) {
stmJs = stmJs.trim();
for(let item of regmap) {
if(item.reg.test(stmJs)) {
return (typeof item.val === 'function') ? stmJs.replace(item.reg, item.val) : item.val;
}
}
};
|
如上,其实只是把语句中的内容逐一用正则去匹配,当匹配到属于某种规则的语句,则针对性处理并返回结果。比如我有一个语句{if a > 1},然后正则去匹配,会匹配出是条件判断中的if语句,然后会处理成js代码if(a > 1) {并返回。而语句{/if}则会处理成}并返回。因此如下代码:
1
|
{if a > 1}.css{margin: 0;}{/if}
|
会处理成:
1
2
3
|
if(a > 1) {
out.push('.css{margin: 0;}'); // 此处是输出模板内容
}
|
其中关于语法匹配的正则和返回处理如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
/*
* 语法正则
*/
const regmap = [
// if语句开始
{reg: /^if\\s+(.+)/i, val: (all, condition) => {return `if(${condition}) {`;}},
// elseif 语句开始
{reg: /^elseif\\s+(.+)/i, val: (all, condition) => {return `} else if(${condition}) {`}},
// else语句结束
{reg: /^else/i, val: '} else {'},
// if语句结束
{reg: /^\\/\\s*if/i, val: '}'},
// list语句开始
{reg: /^list\\s+([\\S]+)\\s+as\\s+([\\S]+)/i, val: (all, arr, item) => {return `for(var __INDEX__=0;__INDEX__<${arr}.length;__INDEX__++) {var ${item}=${arr}[__INDEX__];var ${item}_index=__INDEX__;`;}},
// list语句结束
{reg: /^\\/\\s*list/i, val: '}'},
// var 语句
{reg: /^var\\s+(.+)/i, val: (all, expr) => {return `var ${expr};`;}}
];
|
其中reg字段是正则表达式,若匹配成功,则执行或直接返回val字段的值。
STEP 4
如果有仔细看前面贴出来的代码,发现上面有用到一个变量defaultFilter,这是用来定义模板引擎需要自带的过滤器的。常用ejs的朋友们估计就会清楚,ejs里就自带了很多很实用的过滤器,我在下面例子就贴出一个常用的过滤器方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/**
* 默认的过滤器
*/
const defaultFilter = {
// 防注入用
escape: (str) => {
// 防注入转码映射表
var escapeMap = {
'<': '<',
'>': '>',
'&': '&',
' ': ' ',
'"': '"',
"'": ''',
'\\n': '<br/>',
'\\r': ''
};
return str.replace(/\\<|\\>|\\&|\\r|\\n|\\s|\\'|\\"/g, (one) => {
return escapeMap[one];
});
}
};
|
用法很简单,当我们有一个变量a,内容为<div style=”color: red;”>red</div>时,因为我们经常将模板引擎生成的内容直接用innerHTML塞进节点之中,而假如我们像${a}这种方式直接使用这个变量的时候,在页面中就只会显示一个红色的red。
为了防止此类注入的情况发生,我在上面实现了一个叫escape的过滤器,将使用方式改为${a|escape}就可以进行特殊符号的转义,在页面上直接显示变量a的内容<div style=”color: red;”>red</div>。
尾声
至此,一个完整的基于字符串的模板引擎就完成了,上面的代码使用了es6语法的部分特性来编写,如果需要兼容的话可以使用babel来将代码转成es5语法,在做一下压缩混淆的话,实际的代码不足3k。
前面也提到过,基于字符串的模板引擎最大的好处在于语法自由,你可以做到完全不需要关心模板的类型,你可以写一个css文件的模板,也可以写一个html文件的模板,只要有对应的模板就会有相应的输出,并且前后端可以共用。
如果你想要看完整的代码的话,请戳这里。