100 строк кода, напиши шаблонизатор сам

JavaScript
100 строк кода, напиши шаблонизатор сам

Диаграмма, иллюстрирующая принцип работы шаблонизатора Ejs


На картинке выше примерно изображен простой движок шаблонов (здесь сEJSНапример) принцип был объяснен семью или восемьюдесятью восемью способами. В этой статье будет описано, как работает простой механизм шаблонов? Содержит ключевые этапы реализации и идеи, лежащие в ее основе.

По сути, процедура механизма шаблонов похожа на эту, но эти идеи являются общими.Например, если вы смотрите на исходный код компилятора шаблонов Vue, вы также можете применить эти идеи и методы.


Базовый дизайн API

Мы будем реализовывать упрощенную версию EJS, этот механизм шаблонов поддерживает следующие теги:

  • <% script %>- Выполнение скрипта.Обычно используется для операторов управления, значение не выводится, например

    <% if (user) { %>
      <div>some thing</div>
    <% } %>
    
  • <%= expression %>- Вывести значение выражения, но экранировать HTML:

    <title>{%= title %}</title>
    
  • <%- expression %>- а также<%= expr %>То же самое, за исключением того, что HTML не экранируется

  • <%%а также%%>- Обозначает экранирование меток, например<%%будет выводиться как<%

  • <%# 注释 %>- нет вывода контента


Ниже приведен полный пример шаблона, который будет объяснен ниже на основе этого шаблона:

<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() {}
}


парсинг токена

На первом этапе нам нужно проанализировать все начальные и конечные теги.Наши ожидаемые результаты анализа следующие:

[
  "\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);
    }
  }


Простая проверка грамматики

Хорошо, когда теги проанализированы, пришло время подготовиться к преобразованию их в функцию «рендеринга».

Сделай это первымПростая проверка грамматики, чтобы проверить, является ли метказакрытие:

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} 未找到对应的闭合标签`);
  }
}


конвертировать

Теперь начните обход токена. Мы можем использовать конечный автомат (FSM) для описания логики перехода.

状态机это указатьМатематическая модель конечного числа состояний и поведения, такого как переходы и действия между этими состояниями.. Проще говоря, конечный автомат состоит из набора состояний, начального состояния, входных данных и функции перехода, которая переходит в следующее состояние на основе входных данных и существующего состояния. Он имеет три характеристики:

  • Общее количество штатов ограничено.
  • В любой момент времени существует только одно состояние.
  • При определенных условиях переход из одного состояния в другое

После небольшого анализа диаграмма перехода состояний нашего шаблонизатора выглядит следующим образом:


Следующие состояния могут быть извлечены из приведенного выше рисунка:

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

Хорошо, теперь начните обход токена:

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оператор, чтобы обернуть приведенный выше преобразованный код, чтобы можно было опустить префикс квалификации доступа к локальному объекту.

Метод рендеринга очень прост, напрямую вызовите функцию, обернутую выше:

  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Вдохновленный, реализован минималистский механизм шаблонов.На самом деле механизм шаблонов по сути является компилятором.Из вышеизложенного мы видим, что для компиляции механизма шаблонов нужно выполнить три шага:

  1. РазобратьРазобрать код шаблона в абстрактное представление. Сложные компиляторы будут иметь词法解析(Lexical Analysis)а также语法解析(Syntactic Analysis)

    Лексический анализ, Процесс разбора содержимого шаблона на токены выше можно рассматривать как «лексический анализ», который разбивает исходный код на массив токенов, а токен — это небольшая единица, представляющая независимый «фрагмент грамматики».

    разбор, синтаксический анализатор получает массив токенов и переформатирует их в абстрактное синтаксическое дерево (AST), которое можно использовать для описания синтаксических единиц и отношений между единицами. На этапе разбора грамматики могут быть обнаружены грамматические проблемы.

    (Источник изображения:Entersland Foods IV AK.com/Ross Bass-part…)

    Механизм шаблонов, представленный в этой статье, не нуждается в промежуточном представлении AST, поскольку его синтаксис слишком прост. Конвертируйте напрямую в токены

  2. конвертироватьПреобразуйте абстрактное представление предыдущего шага в то, что хочет компилятор. Например, механизм шаблонов выше преобразует его в оператор на соответствующем языке. Сложные компиляторы будут «преобразовывать» на основе AST, то есть «добавлять, удалять, проверять и изменять» AST. Обычно он будет сотрудничать с режимом «Посетители» для обхода/доступа к узлам AST.

  3. генерация кодаПреобразуйте преобразованное абстрактное представление в новый код. Например, последний шаг шаблонизатора будет инкапсулирован как функция рендеринга, а сложный компилятор преобразует AST в объектный код.

Вещи, связанные с компилятором, действительно интересны, и позже у меня будет возможность поговорить о том, как писать плагины для Babel.


расширять