微信号:FrontDev

介绍:分享 Web 前端相关的技术文章、工具资源、精选课程、热点资讯

实现一个简单的模板引擎

2016-11-20 20:13 前端大全

(点击上方公众号,可快速关注)


作者:AlloyTeam

链接:www.alloyteam.com/2016/10/implement-a-simple-template-engine/


简介

模板引擎,其实就是一个根据模板和数据输出结果的一个工具。

我们要开发一个将模板文件转换成我们实际要使用的内容的工具,这个工具就是模板引擎。我们把模板文件里的内容当成字符串传入到模板引擎中,然后模板引擎根据一定语法对该字符串进行解析处理,然后返回一个函数,之后我们在执行函数时把数据传输进去,即可拿到根据模板和数据得到的新字符串。最后我们想怎么处理该字符串就看需求了,如果用于前端模板生成的话,则可以用dom的innerHTML这个属性来追加内容。

目前前端的模板引擎多得数不胜数,语法特性也花样百出,用行内的话来说,我们要实现的是一种基于字符串的模板引擎。

简要概述流程如下:

优劣

  • 此模板引擎可用于任意一端,前端后端即插即用,不局限于生成内容的语法,只要生成内容为字符串文本即可。比如在合并Sprite图工具中要根据图片大小位置生成对应的css定位文件,我们也可以用该引擎生成而不需要另外再写一套引擎。

  • 此模板引擎对于数据的更改,需要重新渲染一遍模板,所以在初次渲染和之后的模板更新需要耗费同样的资源。

  • 应用于前端时,此模板引擎依赖于innerHTML,存在注入问题。

需求

而此次,我们希望实现一个基于字符串的模板引擎。提供的使用方式尽可能简单,比如类似如下的方式:

// 前端 

var html = window.parse('<div>${content}</div>', { 

  content: 'june' 

}); 

 

// 后端 

const parse = require('tpl'); 

var html = parse('<div>${content}</div>', { 

  content: 'june' 

});


并且希望至少提供以下四种语法:

条件判断

{if condition1}

  // code1

{elseif condition2}

  // code2

{else}

  // code3

{/if}


数组遍历

{list array as item}

  // code

  // PS:里面注入了一个变量item_index,指向item在遍历过程中的序号

{/list}


变量定义

{var var1 = 1}

插值

// 直接插值

${var1}

 

// 使用过滤器插值的方式

${var1|filter1|filter2:var2, var3}


开工

STEP 1

按照前面定下的需求,我们先实现一个对外的接口,代码如下:

'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的结构如下:

function(DATA, FILTER) { 

  try { 

    var OUT = []; 

    // 处理变量 

    // some code 

    // 处理过滤器 

    // some code 

    // 处理内容 

    // other code 

    return OUT.join(''); 

  } catch(e) { 

    throw new Error('parse template error!'); 

  } 

}


事实上,注释中处理变量、处理过滤器和处理内容这部分是由外部传入决定的,所以只有这部分是可变的,其余的代码都是固定的。为此我们可以使用数组来存放相关的内容,然后在可变部分留一个占位符,在解析到处理变量、处理过滤器和处理内容部分时再把语句塞进去即可。代码如下:

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;这样的内容:

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里面的内容,其他部分暂且省略。

// 解析模板内容 

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. 搜索语句开始符{;

  2. 判断{前面是否有转义符\;

  3. 搜索语句结束符};

  4. 判断}前面是否有转义符\;

  5. 判断{前面是否带有取值符号$;

  6. 提取语句内容,即{和}里面的内容;

  7. 将语句之前,即{或${之前未放入缓存区的内容放入缓存区;

  8. 解析语句,并把解析结果放入缓存区;

  9. 循环上述1-8的过程,直到搜索不到语句开始符{,则判断为结尾,把剩下的内容放入缓存区;

  10. 把目前缓存区的的内容存到需要输出的数组中。

以上提到的缓存区,即是上面代码中的out数组。当遍历完模板内容后,把缓存区合并成一个字符串,然后追加到占位符末尾。其中关于语句的解析用到的函数transStm目前接下来将要实现。

STEP 3

transStm函数实现比较简单,因为我们需求中设定的语法也不复杂。代码如下:

/* 

 * 转换模板语句 

 */ 

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}则会处理成}并返回。因此如下代码:

{if a > 1}.css{margin: 0;}{/if}

会处理成:

if(a > 1) { 

  out.push('.css{margin: 0;}'); // 此处是输出模板内容 

}


其中关于语法匹配的正则和返回处理如下:

/* 

 * 语法正则 

 */ 

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里就自带了很多很实用的过滤器,我在下面例子就贴出一个常用的过滤器方法:

/** 

 * 默认的过滤器 

 */ 

const defaultFilter = { 

  // 防注入用 

  escape: (str) => { 

    // 防注入转码映射表 

    var escapeMap = { 

      '<': '&lt;', 

      '>': '&gt;', 

      '&': '&amp;', 

      ' ': '&nbsp;', 

      '"': '&quot;', 

      "'": ''', 

      '\\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文件的模板,只要有对应的模板就会有相应的输出,并且前后端可以共用。

如果你想要看完整的代码的话,请戳这里(https://github.com/JuneAndGreen/demos/blob/master/template_engine/string_base/src/tpl.js)。


关注「前端大全」

看更多精选前端技术文章

↓↓↓

 
前端大全 更多文章 聊聊响应式图片 浅谈JavaScript中的接口 漫画:写些烂代码,拯救广大同胞 问答 | 哪里可以找到前端开发的最新资讯? 微软想哭!IE 用户被 Chrome 抢完了:Edge 更惨
猜您喜欢 从贝叶斯的角度看正则项 代码的语义正确真的就够了吗? 腾讯WeTest为Unity开发者打造免费自动化测试框架——20个体验资格免费发放 《奇舞周刊》第91期——不要自称为程序员 手快活更好!不到最后,怎知分晓!这些“不可告人”的秘密,你发现了吗?!