100来行代码, 本身动手写一个模板引擎

一张图说明Ejs模板引擎的原理html


上面一张图,已经大概把一个简单模板引擎(这里以EJS为例)的原理解释得七七八八了。本文将描述一个简单的模板引擎是怎么运做的?包含实现的关键步骤、以及其背后的思想。vue

基本上模板引擎的套路也就这样了,但这些思想是通用的,好比你在看vue的模板编译器源码、也能够套用这些思想和方法.git


基本API设计

咱们将实现一个简化版的EJS, 这个模板引擎支持这些标签:github

  • <% script %> - 脚本执行. 通常用于控制语句,不会输出值 例如正则表达式

    <% if (user) { %>
      <div>some thing</div>
    <% } %>
    复制代码
  • <%= expression %> - 输出表达式的值,可是会转义HTML:express

    <title>{%= title %}</title>
    复制代码
  • <%- expression %> - 和<%= expr %>同样,只不过不会对HTML进行转义api

  • <%%%%> - 表示标签转义, 好比<%%会输出为<%数组

  • <%# 注释 %> - 不会有内容输出babel


下面是一个完整的模板示例,下文会基于这个模板进行讲解:app

<html>
  <head><%= title %></head>
  <body>
    <%% 转义 %%>
    <%# 这里是注释 %>
    <%- before %>
    <% if (show) { %>
      <div>root</div>
    <% } %>
  </body>
</html>
复制代码

基本API设计

咱们将模板解析和渲染相关的逻辑放到一个Template类中,它的基本接口以下:

export default class Template {
  public template: string;
  private tokens: string[] = [];
  private source: string = "";
  private state?: State;
  private fn?: Function;

  public constructor(template: string) {
    this.template = template;
  }

  /** * 模板编译 */
  public compile() {
    this.parseTemplateText();
    this.transformTokens();
    this.wrapit();
  }

  /** * 渲染方法,由用户指定一个对象来渲染字符串 */
  public render(local: object) { }


  /** * token解析 * 将<% if (codintion) { %> * 解析为token数组,例如['<%', ' if (condition) { ', '%>'] */
  private parseTemplateText() {}
  /** * 将Token转换为Javascript语句 */
  private transformTokens() {}
  /** * 将上一个步骤转换出来的Javascript语句,封装成一个渲染方法 */
  private wrapit() {}
}
复制代码


token解析

第一步咱们须要将全部的开始标签(start tag)和结束标签(end tag)都解析出来,咱们指望的解析结果是这样的:

[
  "\n<html>\n <head>",
  "<%=",
  " title ",
  "%>",
  "</head>\n <body>\n ",
  "<%%",
  " 转义 ",
  "%%>",
  "\n ",
  "<%#",
  " 这里是注释 ",
  "%>",
  "\n ",
  "<%-",
  " before ",
  "%>",
  "\n ",
  "<%",
  " if (show) { ",
  "%>",
  "\n <div>root</div>\n ",
  "<%",
  " } ",
  "%>",
  "\n </body>\n</html>\n"
]
复制代码

由于咱们的模板引擎语法很是简单, 压根就不须要解析成什么抽象语法树(AST)(即省去了语法解析, 只进行词法解析). 直接经过正则表达式就能够实现将标签抽取出来。

先定义正则表达式, 用来匹配咱们全部支持的标签:

// <%% %%> 用于转义
// <% 脚本
// <%= 输出脚本值
// <%- 输出脚本值,unescape
// <%# 注释
// %> 结束标签
const REGEXP = /(<%%|%%>|<%=|<%-|<%#|<%|%>)/;
复制代码

使用正则表达式逐个进行匹配,将字符串拆分出来. 代码也很简单:

parseTemplateText() {
    let str = this.template;
    const arr = this.tokens;
    // 经过exec方法能够获取匹配的位置, 若是匹配失败则返回null
    let res = REGEXP.exec(str);
    let index;

    while (res) {
      index = res.index;
      // 前置字符串
      if (index !== 0) {
        arr.push(str.substring(0, index));
        str = str.slice(index);
      }

      arr.push(res[0]);
      // 截断字符串,继续匹配
      str = str.slice(res[0].length);
      res = REGEXP.exec(str);
    }

    if (str) {
      arr.push(str);
    }
  }
复制代码


简单的语法检查

Ok,将标签解析出来后,就能够开始准备将它们转换称为‘渲染’函数了.

首先进行一下简单的语法检查,检查标签是否闭合

const start = "<%";           // 开始标签
const end = "%>";             // 结束标签
const escpStart = "<%%";      // 开始标签转义
const escpEnd = "%%>";        // 结束标签转义
const escpoutStart = "<%=";   // 转义的表达式输出
const unescpoutStart = "<%-"; // 不转义的表达式输出
const comtStart = "<%#";      // 注释

if (tok.includes(start) && !tok.includes(escpStart)) {
  closing = this.tokens[idx + 2];
  if (closing == null || !closing.includes(end)) {
    throw new Error(`${tok} 未找到对应的闭合标签`);
  }
}
复制代码


转换

如今开始遍历token。咱们可使用一个有限的状态机(Finite-state machine, FSM)来描述转换的逻辑.

状态机是表示有限个状态以及在这些状态之间的转移和动做等行为的数学模型。简单而言,有限状态机由一组状态、一个初始状态、输入和根据输入及现有状态转换为下一个状态的转换函数组成。它有三个特征:

  • 状态总数是有限的。
  • 任一时刻,只处在一种状态之中。
  • 某种条件下,会从一种状态转变到另外一种状态

稍微分析一下,咱们模板引擎的状态转换图以下:


经过上图能够抽取出如下状态:

enum State {
  EVAL,    // 脚本执行
  ESCAPED, // 表达式输出
  RAW,     // 表达式输出不转义
  COMMENT, // 注释
  LITERAL  // 字面量,直接输出
}
复制代码

Ok, 如今开始遍历token:

this.tokens.forEach((tok, idx) => {
  // ...
  switch (tok) {

    /** * 标签识别 */

    case start:
      // 脚本开始
      this.state = State.EVAL;
      break;
    case escpoutStart:
      // 转义输出
      this.state = State.ESCAPED;
      break;
    case unescpoutStart:
      // 非转义输出
      this.state = State.RAW;
      break;
    case comtStart:
      // 注释
      this.state = State.COMMENT;
      break;
    case escpStart:
      // 标签转义
      this.state = State.LITERAL;
      this.source += `;__append('<%');\n`;
      break;
    case escpEnd:
      this.state = State.LITERAL;
      this.source += `;__append('%>');\n`;
      break;
    case end:
      // 恢复初始状态
      this.state = undefined;
      break;
    default:

      /** * 转换输出 */

      if (this.state != null) {
        switch (this.state) {
          case State.EVAL:
            // 代码
            this.source += `;${tok}\n`;
            break;
          case State.ESCAPED:
            // stripSemi 将多余的分号移除
            this.source += `;__append(escapeFn(${stripSemi(tok)}));\n`;
            break;
          case State.RAW:
            this.source += `;__append(${stripSemi(tok)});\n`;
            break;
          case State.LITERAL:
            // 由于咱们把字符串放到单引号中,因此transformString将tok中的单引号、换行符、转义符进行转移
            this.source += `;__append('${transformString(tok)}');\n`;
            break;
          case State.COMMENT:
            // 什么都不作
            break;
        }
      } else {
        // 字面量
        this.source += `;__append('${transformString(tok)}');\n`;
      }
  }
});
复制代码

通过上面的转换,咱们能够获得这样的结果:

;__append('\n<html>\n <head>');
;__append(escapeFn( title ));
;__append('</head>\n <body>\n ');
;__append('<%');
;__append(' 转义 ');
;__append('%>');
;__append('\n ');
;__append('\n ');
;__append( before );
;__append('\n ');
; if (show) {
;__append('\n <div>root</div>\n ');
; }
;__append('\n </body>\n</html>\n');
复制代码


最后一步,生成函数

如今咱们把转换结果包裹成函数:

wrapit() {
    this.source = `\ const __out = []; const __append = __out.push.bind(__out); with(local||{}) { ${this.source} } return __out.join('');\ `;
    this.fn = new Function("local", "escapeFn", this.source);
  }
复制代码

这里使用到了with语句,来包裹上面转换的代码,这样能够免去local对象访问限定前缀。

渲染方法就很简单了,直接调用上面包裹的函数:

render(local: object) {
    return this.fn.call(null, local, escape);
  }
复制代码

跑起来

const temp = new Template(` <html> <head><%= title %></head> <body> <%% 转义 %%> <%# 这里是注释 %> <%- before %> <% if (show) { %> <div>root</div> <% } %> </body> </html> `);

temp.compile();
temp.render({ show: true, title: "hello", before: "<div>xx</div>" })
// <html>
// <head>hello</head>
// <body>
// <% 转义 %>
//
// <div>xx</div>
//
// <div>root</div>
//
// </body>
// </html>
复制代码

你能够在CodeSandbox运行完整的代码:

Edit ejs


总结

本文其实受到了the-super-tiny-compiler启发,实现了一个极简的模板引擎,其实模板引擎本质上也是一个Compiler,经过上文能够了解到一个模板引擎编译有三个步骤:

  1. 解析 将模板代码解析成抽象的表示形式。复杂的编译器会有词法解析(Lexical Analysis)语法解析(Syntactic Analysis)

    词法解析, 上文咱们将模板内容解析成token的过程就能够认为是‘词法解析’,它会将源代码拆分称为token数组,token是一个小单元,表示独立的‘语法片断’。

    语法解析,语法解析器接收token数组,将它们从新格式化称为抽象语法树(Abstract Syntax Tree, AST), 抽象语法树能够用于描述语法单元, 以及单元之间的关系。 语法解析阶段能够发现语法问题。

    (图片来源: ruslanspivak.com/lsbasi-part…)

    本文介绍的模板引擎,由于语法太简单了,因此不须要AST这个中间表示形式。直接在tokens上进行转换

  2. 转换 将上个步骤抽象的表示形式,转换成为编译器想要的。好比上文模板引擎会转换为对应语言的语句。复杂的编译器会基于AST进行‘转换’,也就是对AST进行‘增删查改’. 一般会配合Visitors模式来遍历/访问AST的节点

  3. 代码生成 将转换后的抽象表示转换为新的代码。 好比模板引擎最后一步会封装成为一个渲染函数. 复杂的编译器会将AST转换为目标代码

编译器相关的东西确实颇有趣,后续有机会能够讲讲怎么编写babel插件。


扩展

相关文章
相关标签/搜索