Научиться думать как парсер JSX и построить AST

JSX - одно из наиболее часто используемых расширений синтаксиса. Первоначально JSX анализировался с помощью Facebook-форка Esprima - синтаксического анализатора JavaScript, разработанного jQuery. По мере того, как он набирал обороты, Acorn взяли дело в свои руки и решили сделать свою версию парсера, которая в итоге оказалась в 1,5–2 раза быстрее, чем Esprima-fb, и теперь официально используется Babel.

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

Сегодня мы поймем, как думает парсер JSX, реализовав собственный. В отличие от Babel, вместо компиляции мы будем оценивать узлы в AST в соответствии с их типами, что означает, что мы сможем использовать JSX во время выполнения.

Ниже приведен пример конечного продукта:

Прежде чем мы перейдем к реализации синтаксического анализатора, давайте разберемся, к чему мы стремимся. JSX просто берет HTML-подобный синтаксис и преобразует его во вложенные React.createElement() вызовы. Уникальность JSX заключается в том, что мы можем использовать строковую интерполяцию в наших HTML-шаблонах, поэтому мы можем предоставить ему данные, которые не обязательно должны быть сериализованы, например функции, массивы или объекты.

Итак, учитывая следующий код:

После компиляции с помощью Babel мы должны получить следующий результат:

Напоминаем, что скомпилированный результат должен использоваться внутри ReactDOM, чтобы различать изменения в виртуальной DOM, а затем отображать их. Это то, что специфично для React и не имеет ничего общего с JSX, поэтому на данный момент мы достигли нашей цели.

По сути, есть 3 вещи, которые мы должны выяснить при синтаксическом анализе JSX-кода:

  • Имя / компонент элемента React.
  • Свойства элемента React.
  • Дочерние элементы элемента React, для каждого этого процесса должны повторяться рекурсивно.

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

Проще говоря, вот схематическое изображение проведенного выше анализа:

Соответственно, у нас будет 3 типа узлов:

  • Узел элемента.
  • Узел реквизита.
  • Узел значения.

Давайте решим, что каждый узел имеет базовую схему со следующими свойствами:

  • node.type - который будет представлять имя типа узла, например element, props и value. Основываясь на типе узла, мы также можем определить те дополнительные свойства, которые этот узел будет нести. В нашем парсере каждый тип узла должен иметь следующие дополнительные свойства:

  • node.length - которая представляет длину подстроки в коде, который занимает узел. Это поможет нам обрезать строку кода в процессе синтаксического анализа, чтобы мы всегда могли сосредоточиться на соответствующих частях строки для текущего узла:

В функции, которую мы собираемся создать, мы воспользуемся преимуществами тегированных шаблонов ES6. Помеченные шаблоны - это строковые литералы, которые могут обрабатываться специальным обработчиком в соответствии с нашими потребностями (см. Документы MDN).

По сути, сигнатура нашей функции должна выглядеть так:

Поскольку мы будем в значительной степени полагаться на регулярное выражение, будет намного проще работать с последовательной строкой, так что мы сможем раскрыть весь потенциал регулярного выражения. А пока давайте сосредоточимся на строковой части без литерала и проанализируем обычную строку HTML. Когда у нас есть эта логика, мы можем реализовать обработку интерполяции строк поверх нее.

Начнем с ядра - парсера HTML

Как я уже упоминал, наш AST будет состоять из 3 типов узлов, а это значит, что нам нужно будет создать ENUM, который будет содержать значения element, props и value. Таким образом, типы узлов не будут жестко запрограммированы, и исправление кода может быть очень простым:

Поскольку у нас было 3 типа узлов, это означает, что для каждого из них у нас должна быть выделенная функция синтаксического анализа:

Каждая функция создает базовый тип узла и возвращает его. Обратите внимание, что в начале области видимости каждой функции я определил пару переменных:

  • let match - который будет использоваться для хранения совпадений регулярных выражений на лету.
  • let length - который будет использоваться для хранения длины совпадения, чтобы мы могли обрезать строку кода JSX сразу после и накапливать ее в node.length.

На данный момент функция parseValue() довольно проста и просто возвращает узел, который обертывает данную строку.

Мы начнем с реализации узла элемента и по мере продвижения будем переходить к другим узлам. Сначала попробуем выяснить название элемента. Если открыватель тега элемента не был найден, мы будем считать, что текущая часть кода является значением:

Далее нам нужно проанализировать реквизиты. Чтобы сделать вещи более эффективными, нам нужно сначала найти тег ближе, чтобы мы могли предоставить методу parseProps() соответствующую часть строки:

Теперь, когда мы выбрали правильную подстроку, мы можем продолжить и реализовать логику функции parseProps():

Логика довольно проста - мы перебираем строку и каждый раз пытаемся сопоставить следующую пару ключ-значение. Если пара не найдена, мы возвращаем узел с накопленными реквизитами. Обратите внимание, что предоставление только атрибута без значения также является допустимым синтаксисом, который по умолчанию установит его значение на true, таким образом, регулярное выражение / *\w+/. Давайте продолжим с того места, где мы закончили, с реализацией синтаксического анализа элементов.

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

Соответственно, мы реализуем логику парсинга детей:

Парсинг детей является рекурсивным. Мы продолжаем вызывать метод parseElement() для текущей подстроки, пока совпадений не будет. После того, как мы перебрали всех дочерних элементов, мы можем завершить процесс, найдя закрывающий тег:

Часть синтаксического анализа HTML завершена! Теперь мы можем вызвать parseElement() для любой заданной строки HTML, и мы должны получить вывод JSON, который представляет AST, как показано ниже:

{
  "type": "element",
  "props": {
    "type": "props",
    "length": 20,
    "props": {
      "onclick": "onclick()"
    }
  },
  "children": [
    {
      "type": "element",
      "props": {
        "type": "props",
        "length": 15,
        "props": {
          "src": "icon.svg"
        }
      },
      "children": [],
      "length": 18,
      "name": "img"
    },
    {
      "type": "element",
      "props": {
        "type": "props",
        "length": 0,
        "props": {}
      },
      "children": [
        {
          "type": "value",
          "length": 4,
          "value": "text"
        }
      ],
      "length": 12,
      "name": "span"
    }
  ],
  "length": 74,
  "name": "div"
}

Повышение уровня - интерполяция строк

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

[
  "<__jsxPlaceholder>Hello __jsxPlaceholder</__jsxPlaceholder>",
  [MyComponent, "World", MyComponent]
]

Соответственно, мы обновим сигнатуру функций синтаксического анализа и их вызовы, а также определим константу-заполнитель:

Обратите внимание, как я использовал функцию Date.now() для определения постфикса для заполнителя. Таким образом, мы можем быть уверены, что одно и то же значение не будет указано пользователем в виде строки (возможно, очень маловероятно). Теперь мы рассмотрим каждую функцию синтаксического анализа и убедимся, что она знает, как правильно работать с заполнителями. Начнем с функции parseElement().

Мы добавим к узлу дополнительное свойство: node.tag. Свойство tag - это компонент, который будет использоваться для создания элемента React. Это может быть строка или React.Component. Если node.name является заполнителем, мы будем брать следующее значение в заданном стеке значений:

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

Следующим будет узел реквизита. Это довольно просто, мы только добавим дополнительное регулярное выражение к массиву сопоставлений, и это регулярное выражение будет проверять наличие заполнителей. Если был обнаружен заполнитель, мы заменим его следующим значением в стеке значений:

И последнее, но не менее важное: это узел значения. Это самый сложный для обработки из трех узлов, так как он требует от нас разбить входную строку и создать выделенный узел значения из каждого разбиения. Итак, теперь вместо того, чтобы возвращать значение одного узла, мы вернем их массив. Соответственно, мы также изменим имя функции с parseValue() на parseValues():

Причина, по которой я решил вернуть массив узлов, а не отдельный узел, который содержит массив значений, как и узел props, заключается в том, что он идеально соответствует сигнатуре React.createElement(). Значения будут переданы как дочерние с оператором распространения (...), и дальше вы должны увидеть в этом руководстве, насколько хорошо это подходит.

Обратите внимание, что мы также изменили способ накопления дочерних элементов в функции parseElement(). Поскольку parseValues() теперь возвращает массив, а не отдельный узел, мы сглаживаем его, используя конкатенацию пустых массивов ([].concat()), и проталкиваем только дочерние элементы, содержимое которых не пусто.

Грандиозный финал - казнь

На этом этапе у нас должна быть функция, которая может преобразовывать код JSX в AST, включая интерполяцию строк. Единственное, что осталось сделать, это создать функцию, которая будет рекурсивно создавать элементы React из узлов в дереве.

Основная функция модуля должна вызываться с помощью тега шаблона. Если вы прошли предыдущий шаг, вы должны знать, что согласованная строка имеет преимущество перед массивом разбиений строк, поскольку мы можем с легкостью раскрыть весь потенциал регулярного выражения. Соответственно, мы возьмем все заданные разбиения и соединим их константой placeholder.

['<', '> Hello ', '</', '>'] -> '<__jsxPlaceholder>Hello __jsxPlaceholder</__jsxPlaceholder>'

После присоединения к строке мы можем рекурсивно создавать элементы React:

Обратите внимание, что если выполняется итерация узла типа значения, мы просто вернем необработанную строку, в противном случае мы попытаемся обратиться к его свойству node.children, которое не существует.

Наша функция времени выполнения JSX теперь готова к использованию!

Если вам интересно, как я так хорошо структурировал этот урок с помощью шагов и красивых различий - посмотрите tortilla.academy от Uri Goldshtein.

Наконец, вы можете просмотреть исходный код в официальном репозитории Github или загрузить пакет Node.JS с помощью NPM:

$ npm install jsx-runtime