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插件。


擴展

相關文章
相關標籤/搜索