用一百行代码实现一个模板编译器
为啥想做这么个东西,因为有需要做模板方面的需求,但是有点嫌弃哪些给HTML做的模板引擎
其实需要的功能还是蛮简单的
允许逻辑控制,简单来说就是可以写 for 循环,可以写 if 语句
可以填充内容就好了
其实这个还是挺多的,随便一个给HTML做的引擎都可以实现,但是前文说了,有点嫌弃它
首先,我不用html元素,所以类似jade之类的排除,然后我不用html的话,就没必要做什么转码,对于ejs之类的转码不转码还有两种语法,还自带layout,太麻烦了,看语法都找不到重点
那么我的思路是参考ejs,当然我没看过ejs的代码是不是这么实现的,反正我是这么实现的,语法上参考了一下
从实现上来说,应该更偏向于jsp
允许直接在模板里头写 js 语句和 js 表达式
最后会编译成一个 render 函数,调用render 函数传入context来编译这个模板,得到最终的结果。
约定: <% sentence %> 中间可以直接写 js 语句, <%= expression %> 中间写表达式
模板最终长大概下面这个样子:
<% if (a > 0) {%>
这里还有几个乱七八糟的句子这里是 a>0
<%} else {%>
这里还有一些五六七八的汉字这里是 else
<% } %>
<% for (let b of arr){ %>
<%= b %>
<% } %>
<% if ( a > 2) { %>
<%= a*2 %>
<% } %>
数据输入 { a: 4, arr: [1, 2, 4] } 的话,编译的结果是
做一个简单的模板程序
这里还有几个乱七八糟的句子这里是 a>0
1
2
4
8
那么这里编译出来的函数应该是大概这个样子的
function render(a,arr){
let contentArr = []
contentArr.push("做一个简单的模板程序")
if (a > 0) {
contentArr.push(" 这里还有几个乱七八糟的句子这里是 a>0")
} else {
contentArr.push(" 这里还有一些五六七八的汉字这里是 else ")
}
for (let b of arr){
contentArr.push( b)
}
if ( a > 2) {
contentArr.push( a*2 )
}
return contentArr.join('\n')
}
最后在传入参数调用就可以了
那么可以开始写我们的 compiler 了
async function compile(file, context) {
let tplContent = await readText(file);
let renderBody = createRenderBody(tplContent);
return renderBody;
}
这里因为要读取文件,所以我们写成异步的 async 函数
下面接着写createRenderBody 函数
function createRenderBody(content) {
let contents = content.replace(/\r/g, '').split('\n');
let body = [];
body.push(`let contentArr = []`);
let isSentence = false;
for (let line of contents) {
isSentence = processLine(line, isSentence, body);
}
body.push(`return contentArr.join('\\n')`);
return body.join('\n');
}
这里是对模板进行处理,每一行都交给 processLine 这个方法来处理, isSentence 当前是否处在某个语句中间,防止某个<%%>标签在中间换行了
processLine 的代码如下,暂时还没有对 <%=%> 进行处理
- splitIn2是将一个字符串按照第二个字符串来截成两段
- pushContent 是来处理输出字符串,会过滤掉空串,省得在这里写太多的判断
- pushSentence 用来处理语句,处理方式与输出字符串有区别,同样会过滤空串
- 如果当前处理在语句中,那么将判断在哪里结束,如果没有在语句中,那么将判断是否是语句的开始
- 如果在语句中,并且当前行有语句结束标记,那么将语句结束标记前面的部分当做语句处理,后面的当成另外一行进行处理
- 如果在语句中,没有遇到语句结束,则直接将当前行当做语句处理
- 如果当前没有在语句中,那么判断当前行是否有语句开始标记
- 如果遇到语句开始标记,那么将语句开始标记前的当做内容处理,将语句开始标记后的部分当做另一行进行处理
递归以上过程直到当前行处理完毕。
function processLine(line, isSentence, body) {
if (isSentence) {
if (line.includes('%>')) {
let parts = splitIn2(line, '%>');
pushSentence(parts[0], body);
isSentence = processLine(parts[1], false, body);
} else {
pushSentence(line, body);
}
} else {
if (line.includes('<%')) {
let parts = splitIn2(line, '<%');
pushContent(parts[0], body);
isSentence = processLine(parts[1], true, body);
} else {
pushContent(line, body);
}
}
return isSentence;
}
基于以上代码,其实已经可以处理分支和循环逻辑处理了,我们的模板里面已经可以写逻辑了
比如
做一个简单的模板程序
<% if (a > 0) {%>
这里还有几个乱七八糟的句子这里是 a>0
<%} else {%>
这里还有一些五六七八的汉字这里是 else
<%}%>
但是如果我们需要写for循环,一般来说,for循环里面势必要用到循环变量的,不然写循环干啥?循环出来都是写死的模板内容及没意义了
也就是我们需要来处理一个<%=%>标签
<%for (let b of arr){%>
<%= b%>
<%}%>
这东西应该在哪里处理?当然首先,我们约定这个标签是不能嵌套的
就是不能写
<% <%=%> %>
如果不能这么写的话,就不会出现我们正在处理一个语句呢,发现出现一个表达式的情况
得到我们应该在 if(!isSentence) 中进行处理
这里为了避免像语句那样需要多一个变量来判断是否在表达式中,我们再做一个约定,表达式语句必须在一行中写完:不允许表达式语句换行!
function processLine(line, isSentence, body) {
if (isSentence) {
//...other code
} else {
if (line.includes('<%=')) {
let parts = splitIn2(line, '<%=');
pushContent(parts[0], body);
parts = splitIn2(parts[1], '%>');
pushExpression(parts[0], body);
processLine(parts[1], false, body);
} else if (line.includes('<%')) {
//...
} else {
//...
}
}
return isSentence;
}
至此,我们需要的功能已经基本完成了。
回到最初的问题,我们现在编译出了函数体,还差一个调用,其实这里很简单
async function compile(file, context) {
let tplContent = await readText(file);
let renderBody = createRenderBody(tplContent);
let keys = Object.keys(context);
// return renderBody;
let render = new Function(...keys, renderBody);
return render(...keys.map(key => context[key]));
}
我们创建一个函数 render ,参数列表为context的所有key,这样我们编译出的代码里头所有引用的变量就都有了来源。
然后在调用的时候传入的参数顺序与参数列表的顺序完全一致即可。
得到最终的代码
这里使用了一个mz 库,将异步回调转成了 promise 可以用在异步函数里头,如果不想用,完全可以自己用系统提供的fs模块重写一份
const fs = require('mz/fs');
async function compile(file, context) {
let tplContent = await readText(file);
let renderBody = createRenderBody(tplContent);
let keys = Object.keys(context);
// return renderBody;
let render = new Function(...keys, renderBody);
return render(...keys.map(key => context[key]));
}
function createRenderBody(content) {
let contents = content.replace(/\r/g, '').split('\n');
let body = [];
body.push(`let contentArr = []`);
let isSentence = false;
for (let line of contents) {
isSentence = processLine(line, isSentence, body);
}
body.push(`return contentArr.join('\\n')`);
return body.join('\n');
}
function processLine(line, isSentence, body) {
if (isSentence) {
if (line.includes('%>')) {
let parts = splitIn2(line, '%>');
pushSentence(parts[0], body);
isSentence = processLine(parts[1], false, body);
} else {
pushSentence(line, body);
}
} else {
if (line.includes('<%=')) {
let parts = splitIn2(line, '<%=');
pushContent(parts[0], body);
parts = splitIn2(parts[1], '%>');
pushExpression(parts[0], body);
processLine(parts[1], false, body);
} else if (line.includes('<%')) {
let parts = splitIn2(line, '<%');
pushContent(parts[0], body);
isSentence = processLine(parts[1], true, body);
} else {
pushContent(line, body);
}
}
return isSentence;
}
function splitIn2(line, separate) {
let index = line.indexOf(separate);
let first = line.substring(0, index);
let second = line.substring(index + separate.length);
return [first, second];
}
function pushSentence(content, arr) {
if (content) {
arr.push(content);
}
}
function pushExpression(content, arr) {
if (content) {
arr.push(`contentArr.push(${content})`);
}
}
function pushContent(content, arr) {
if (content) {
arr.push(`contentArr.push(${JSON.stringify(content)})`);
}
}
function readText(...files) {//这个方法对于这个程序来说实现的有点复杂了,我从别的代码里拷贝过来的,看不懂忽略就好了
if (files.length === 0) {
return Promise.resolve(null);
}
if (files.length === 1) {
let filename = files[0];
return fs.readFile(filename, 'utf8');
}
return Promise.all(files.map(filename => fs.readFile(filename, 'utf8')));
}
以上,应该不到一百行代码,实现了一个模板编译器。功能不是很强,但是够用了。这里没有用到正则表达式,因为正则这个东西不是很好理解,语法比较奇怪。
没有经过很仔细的测试,大概测试了一下最上面给的那个例子,可以正确编译。这里也不是为了写出个多好的东西来,就是想说,这东西,也没那么难。
JSP的编译也大概就是这么回事,在外面套一个servlet的壳,将其中的html代码原模原样作为response的输出,将java代码输出到servlet中html的对应位置,得到一份java文件,然后编译这份java文件,运行。道理都是相通的。
技术也不是那么重要,用这么烂的字符串处理,也可以写出来。
最后,用了一点ES6的语法简化程序编写,如果有看不懂的,可以去参考以下阮一峰老师的《ECMAScript 6 入门》