理解模板引擎

阅读 1161
收藏 77
2018-06-10

无论是前端的 MVVM 框架, 还是像我们常用的如 ejs 这样的模板引擎, 都是为了组件化工作的, 是为了减少重复编写某些代码而出现的.实际上模板引擎是为了实现模板文件与业务数据的结合, 实现界面与数据的分离.
为了能够深刻理解模板引擎的工作原理, 这里我通过实现一个简单的模板引擎来达到目的.

占位符

如果你用过像 ejs 这样的模板引擎, 你就会发现在模板引擎中会使用一个占位符来表示这个部分的数据是动态的, 并在生成真正的 HTML 字符串真正的数据替换进去. 比如像 ejs 就会使用 <% … %> 来包裹变量. 其实不仅是变量, 只要是属于 js 语言的语法就应该使用占位符来包裹住.

<script id="template">
    this is <% name %>
</script>

但是本次在造一个简单的模板引擎的过程中, 选择的是双花括号这样的占位符.

使用正则进行简单替换

我们在前面选择了我们的占位符, 接下来为了实现一个简单的模板引擎, 我们需要将模板中的占位符替换成对应的真实的数据, 所以我们这里使用正则表达式来匹配:
比如我们需要创建一个人的详细信息页面, 个人数据如下:

{
    name: "shawn cheung",
    job: "frontend"
}

然后我们的页面模板如下:

<p>my name is {{ name }}</p>
<p>my job is {{ job }}</p>

数据与模板如上, 接下来我们需要创建一个函数, 来将两个结合到一起, 生成真正的 HTML 字符串. 我们的首要任务就是使用正则来匹配出占位符, 即匹配出 之间的变量:

const re = /{{(.+?)}}/g;

替换的逻辑如下:

const TemplateEngine = (tpl, data) => {
    const re = /{{(.+?)}}/g; // 匹配占位符的正则表达式
    let result = [];  // 结果
    let cursor = 0;// 遍历游标
    // 遍历替换, 直到 tpl 中没有找到占位符为止
    while (match = re.exec(tpl)) { 
        let [placeholder, prop] = match;
        // 将匹配到的占位符前的字符加入到 result 数组中
        result.push(tpl.slice(cursor, match.index));
        // 根据匹配到的占位符中的变量名到传入的 data 对象中取出真正的值
        result.push(data[prop.trim()]);
        // 改变游标值
        cursor = match.index + placeholder.length;
    }
    // 最后将剩下的字符串加到 result 数组中
    result.push(tpl.slice(cursor, tpl.length));
    // 返回结果 HTML 字符串
    return result.join('');
}

可以看到, 上面我们使用了正则表达式的 exec 方法, 对传入的模板字符串进行匹配, 详细的函数结果请查阅 exec 函数的用法, 这里不再展开, 而对于具体的逻辑概括起来就是我们将模板字符串进行了”重建”, 我们将非占位符的字符加入到结果数组中, 然后将占位符表示的字符使用真正的数据集 data 中根据键名找到的值加入到结果数组中, 这样就可以形成了模板文件与数据的拼接, 从而达到根据数据来动态生成不同的 HTML 字符串的目的.以上的代码测试的结果如下:

console.log(TemplateEngine('<p>my name is {{ name }}</p><p>my job is {{ job }}</p>', {
  name: 'shawn cheung',
  job: 'frontend'
}));
// <p>my name is shawn cheung</p><p>my job is frontend</p>

使用函数构建模板

像上面给出的例子中, 我们已经得到了一个非常简单的模板引擎函数, 但是还有有不少的缺点的:

  1. 无法递归读取对象的值, 因为我们实际使用面对的数据很有可能是复杂的, 对象中包含另一个对象的情况, 上面的例子并不能满足这样的场景
  2. 无法遍历数组结构的数据, 我们在 ejs 中都会有体会, 我们有时需要得到一个列表的数据界面, 而列表数据往往都是以数组的形式进行存储了, 那么这就意味着我们模板文件中需要使用一个 for 循环的逻辑将实际的 HTML 字符串进行生成.
  3. 无法根据一些条件对一些内容的生成控制, 比如根据有无某个 flag 值, 是否生成某段文字.

面对上面所说的这些缺陷, 我们需要提出解决方案, 而这里采用的就是函数来解决, 当然这里不是指简单的函数, 而是指能够动态生成函数体的函数, 也就是 new Function():

new Function('arg', 'console.log(arg + 1);');

通过上面的代码, 实际上我们新建了一个匿名函数:

function (arg) {
    console.log(arg + 1);
}

那么我们进行上面这个函数的调用方法就是:

new Function('arg', 'console.log(arg + 1);').call(this, 1); // 2

那么这个方法怎么能解决我们上面提及的几个问题呢?

读取对象的值

面对第一个问题, 我们知道通过 new 出来的函数, 其函数作用域是全局对象, 但是我们可以通过 call 或者 apply 改变其 this 的指向, 加入该函数 this 指向就是我们的数据集本身, 那么我们就可以在函数内部直接使用平时取变量数据的方法进行读取了, 也即是说可以通过下面这样来写模板文件:

<p>
    my name is {{ person.name }}
</p>
<p>
    my age is {{ person.age }}
</p>

而在函数内部即是:

new Function('console.log(this.person.name);').call({ person: { name: 'shawn cheung' } }); // shawn cheung

那么根据这个, 我们需要改进一下我们原有的代码, 由于我们需要构建的是 new Function 中的函数体, 所以:

const TemplateEngine = (tpl, data) => {
    const re = /{{(.+?)}}/g; // 匹配占位符的正则表达式
    let cursor = 0;// 遍历游标
    let code = ['let template = []; \n']; // 存储构建的函数体
    let codeEnd = 'return template.join("");\n'; // 生成的函数体的最后一步, 即将函数体的数组以字符串的形式输出
    // 遍历替换, 直到 tpl 中没有找到占位符为止
    while (match = re.exec(tpl)) { 
        let [placeholder, prop] = match;
        // 将匹配到的占位符前的字符加入到 result 数组中
        // 和上面不同的在于, 这里插入的字符串变成函数体内部的函数语句
        // 对于的模板字符串数据也应该插入到一开始头部定义的 template 数组中去
        code.push(`template.push(" ${ tpl.slice(cursor, match.index) } ");\n`);
        // 根据匹配到的占位符中的变量名到传入的 data 对象中取出真正的值
        // 这里也是类似的, 转化为函数体内部语句
        // 前面加上 this 保证数据的读取
        code.push(`template.push(this.${ prop.trim() });\n`);
        // 改变游标值
        cursor = match.index + placeholder.length;
    }
    // 处理函数体最后步骤
    code.push(`template.push(" ${tpl.slice(cursor, tpl.length)} ");\n`);
    code.push(codeEnd);
    // 返回结果 HTML 字符串
    return new Function(code.join('')).call(data);
}

测试如下:

console.log(TemplateEngine('<p>{{ person.name }}</p><p>{{ person.sex }}</p>', { 
  person: { 
    name: 'shawn cheung', 
    sex: 'man' 
  }
}));
// <p> shawn cheung </p><p> man </p>

这样就可以直接在模板文件里面通过 点 操作符进行对象属性的读取了.

模板中的 js 逻辑判断

面对第二与第三个问题, 其实归根结底就是对 js 代码的判断问题. 比如第二个问题, 我们需要在函数体内部生成并执行 for 语句, 并且 for 语句里面的逻辑是将某个可遍历的数据集中的数据与模板文件中规定的结构进行 HTML 字符串的拼接. 而对于第三个问题, 也就是在谈论我们面对 switch 语句, if 语句等等的 js 语法的判断, 并且在函数内部执行.
对于这样的语句, 我们需要使用一个正则来匹配, 即如果发现是 if, else, switch, break, for, while 等等这些语句的关键词, 那么就直接将这个语句通过字符串的形式插入到需要生成的函数体中, 而不是读取值.
我们需要的正则表达式如下:

const sentRe = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g;

因为在模板引擎中, js 相关的语句是需要包裹在占位符里面的, 所以我们需要在替换占位符为真正的数据或者 js 语句的时候, 需要使用上面的正则进行判断后再做对应的操作.即:

...
if (prop.match(sentRe)) {
    code.push(`${prop}\n`);
} else {
    code.push(`template.push(this.${prop});\n`);
}
...

总结

下面这里提供一个完整版的代码, 相对于上面的代码, 完整版做了一个对于代码中空格回车等字符的处理:

const TemplateEngine = function (tpl, data) {
  const re = /{{(.+?)}}/g;
  const { addLine, insert, transform } = templateHandler(tpl);
  while (match = re.exec(tpl)) {
    let [placeholder, prop] = match;

    addLine({ index: match.index });
    insert({ 
      startIndex: match.index,
      placeholder,
      prop: prop.trim()
    });
  }
  
  let code = transform();
  return new Function(code.replace(/[\r\t\n]/g, '')).call(data);

  function templateHandler (template) {
    let code = ['let template = [];\n'];
    let codeEnd = 'return template.join("");\n';
    let cursor = 0;
    const sentRe = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g;
    return {
      addLine: ({index}) => {
        let line = template.slice(cursor, index);
        code.push(`template.push("${line.replace(/('|")/g, '\\$1')}");\n`);
      },
      insert: ({startIndex, placeholder, prop}) => {
        prop = prop.replace(/(^\s+)|(\s+$)/g, '');
        if (prop.match(sentRe)) {
          code.push(`${prop}\n`);
        } else {
          code.push(`template.push(this.${prop});\n`);
        }
        cursor = startIndex + placeholder.length;
      },
      transform: () => {
        code.push(`template.push("${template.substr(cursor, tpl.length - cursor)}");\n`);
        code.push(codeEnd);
        return code.join('');
      }
    }
  }
};

module.exports = TemplateEngine;

在创建一个基于正则表达式的模板引擎的时候, 我们需要对正则的相关知识熟悉, 这样才能比较好地写出匹配的正则表达式与模板引擎.

评论
说说你的看法