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

用一百行代码实现一个模板编译器
为啥想做这么个东西,因为有需要做模板方面的需求,但是有点嫌弃哪些给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 用来处理语句,处理方式与输出字符串有区别,同样会过滤空串
  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 入门》

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容