美文网首页Web前端之路
用一百行 JS 代码写一个模板编译器

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

作者: 为什么一定要起昵称 | 来源:发表于2018-04-25 18:50 被阅读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 的代码如下,暂时还没有对 <%=%> 进行处理

    • 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 入门》

    相关文章

      网友评论

        本文标题:用一百行 JS 代码写一个模板编译器

        本文链接:https://www.haomeiwen.com/subject/jppylftx.html