Web前端之路

用一百行 JS 代码写一个模板编译器

2018-04-25  本文已影响14人  为什么一定要起昵称

用一百行代码实现一个模板编译器
为啥想做这么个东西,因为有需要做模板方面的需求,但是有点嫌弃哪些给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 的代码如下,暂时还没有对 <%=%> 进行处理

  1. 如果当前处理在语句中,那么将判断在哪里结束,如果没有在语句中,那么将判断是否是语句的开始
  2. 如果在语句中,并且当前行有语句结束标记,那么将语句结束标记前面的部分当做语句处理,后面的当成另外一行进行处理
  3. 如果在语句中,没有遇到语句结束,则直接将当前行当做语句处理
  4. 如果当前没有在语句中,那么判断当前行是否有语句开始标记
  5. 如果遇到语句开始标记,那么将语句开始标记前的当做内容处理,将语句开始标记后的部分当做另一行进行处理
    递归以上过程直到当前行处理完毕。
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 入门》

上一篇下一篇

猜你喜欢

热点阅读