Конвертация React в Angular с использованием универсального абстрактного дерева. Proof of Concept

habr.png

Вступление

Доброго времени суток, меня зовут Владимир Миленко, я Frontend-разработчик в компании Lightspeed, и сегодня мы поговорим о проблеме отсутствия компонентов в том или ином фреймворке и попытках автоматически конвертировать их.


Предыстория

Исторически сложилось, что и в eCommerce, и в Retail продуктах для админ-панелей мы используем React.JS в качестве основного фреймворка, однако платформа для ресторанов использует Angular, что не позволяет им использовать нашу библиотеку компонентов. Перед моим отпуском эта проблема стала острее, ввиду необходимости приведения UI/UX к одному виду. Мною было принято решение провести небольшое исследование на тему миграции компонентов, сделать Proof of Concept и поделиться ощущениями. Об этом и будет данный пост.


Немного теории

Для понимания дальнейшего необходимо знать следующие обозначения:


AST — абстрактное синтаксическое дерево, это представление кода в виде дерева, в нем отсутствуют скобки и т.д. Пример частого использования AST — babel, он строит AST с помощью парсеров, а затем происходит транспиляция листьев с типами, введенными в es6 — в es5-поддерживаемые.


https://ru.m.wikipedia.org/wiki/Абстрактное_синтаксическое_дерево


Подход к решению проблемы

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


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


Процесс будет выглядеть примерно так:


  1. Парсинг js в AST
  2. Разбор синтаксических конструкций парсерами
  3. Генерация UST (универсальное абстрактное дерево), исходя из полученных верхнеуровневых конструкций
  4. Генерация TypeScript AST + Angular Template html из UST


Основные принципы, на которых устроен мир (зачеркнуть), это парсер. Я пришел к мысли, что самое лучшее — построить все это дело на матчерах и предикатах.


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


Матчер возвращает true/false в зависимости от ноды, переданной в неё. Он делает проверки на ноде, тот ли это парсер, который необходим, или нет.


Если матчер вернул true, мы будем вызывать уже функцию парсинга. Функция-парсер должна вернуть UST ноду — абстрактное описание того, что происходило в разбираемой AST-ноде.


Основная концепция парсинга — каждый парсер должен иметь возможность вызвать парсинг нод, не влияя не результирующее дерево. Это нам пригодится как в матчинге, так и в парсинге, поскольку мы будем генерировать и child-ов, и иногда отталкиваться от child-ов.


Парсинг входных параметров компонента

Пожалуй, это одна из самых интересных задач. Как мы все знаем, React-компонент может брать параметры из множества мест: state, props, context, outer scope.


На этапе PoC мы рассматриваем только props, и даже только в определенной конструкции, но об этом позже.


Итак, в AST нет как такового трекинга переменной, т.е. при взгляде на определенную ноду вы увидите Identifier, но это не даст вам ни малейшего понятия, откуда именно эта переменная взялась.


На помощь придет AST-traversal, который подскажет текущую область видимости той или иной ноды.


Будем считать входным параметром компонента следующую конструкцию:


const {a} = this.props;


Другими словами, будем искать VariableDeclaration, где id — ObjectPattern, а init — MemberExpression, с property — обязательно 'props'.


От теории к практике

Используемые инструменты:


  1. Парсинг AST — babylon
  2. Определение типов нод AST — babel-types
  3. Проход по дереву — babel-traverse
  4. Генерация псевдо-шаблонов Angular — parse5


Интерфейс предиката для парсинга:


export interface ParserPredicate {
    matchingType:string;
    isMatching: (token:any) => boolean;
    parse: (token:any) => any;
}


Ну и сразу пример имплементации:


export class JSXExpressionMap implements ParserPredicate {
  matchingType = 'JSXExpressionContainer';

  parse(token: JSXExpressionContainer): any {
    const expression = token.expression as CallExpression;

    const callee = expression.callee as MemberExpression;
    const baseObject = (callee.object as Identifier).name;
    const arrowExpression = expression.arguments[0] as ArrowFunctionExpression;
    const renderOutput = resolverRegistry.resolve(arrowExpression.body);

    let baseItem = this.getBaseObjectName(callee);
    let newBaseItem = resolveVariable(token, baseItem);
    return {
      type: 'ForLoop',
      baseItem: {
        type: 'Identifier',
        name: newBaseItem
      },
      arguments: arrowExpression.params,
      children: renderOutput,
      mutations: this.getMutations(callee)
    }
  }

  getBaseObjectName(callee: MemberExpression) {
    let temp = callee;
    while (!isIdentifier(temp.object)) {
      temp = temp.object.callee;
    }
    return (temp.object as Identifier).name;
  }

  getMutations(callee: MemberExpression) {
    if (!isCallExpression(callee.object)) return [];
    return [callee];
  }

  isMatching(token: JSXExpressionContainer): boolean {
    if (!isCallExpression(token.expression)) return false;
    const expression = token.expression as CallExpression;

    if (!isMemberExpression(expression.callee)) return false;
    const callee = expression.callee as MemberExpression;

    if (!isIdentifier(callee.property)) return false;
    const fnToBeCalled = (callee.property as Identifier).name;

    if (fnToBeCalled === 'map') {
      return true;
    }
    return false;

  }
}


Исходя из кода выше станет понятно, что данный предикат ждет на вход JSXExpressionContainer, далее идут различные проверки, чтобы определить, действительно ли этот парсер нужен для входной ноды.
Данный предикат сработает на следующую JSX-конструкцию:


{
    items.map(x=>(
  • {x}
  • ) }


    Парсинг


    Функция парсинга разбирает конструкцию на кусочки, она так же позволяет найти мутации исходного параметра,


    {
        items.filter(x=>x>5).filter(x=>x>10).map//etc
    }


    Далее идет процесс определения переменной, за это отвечает функция resolveVariable. Она служит для определения области видимости и поиска определения данной переменной:


    export const resolveVariable = (token:any, identifier:string) => {
      let newIdentifier = identifier;
      traverse(resolverRegistry.ast, {
        enter: (path) => {
          if (path.node !== token) return;
          if (path.scope.bindings[identifier]) {
            const binding = path.scope.bindings[identifier];
            const declaratorNode = binding.path.node as VariableDeclarator;
            if (isObjectPattern(declaratorNode.id) && isMemberExpression(declaratorNode.init)) {
              const init = declaratorNode.init as MemberExpression;
              if (isThisExpression(init.object) && isIdentifier(init.property)) {
                newIdentifier = resolverRegistry.registerVariable(identifier, init.property.name === 'props' ? 'Input' : 'Local');
              }
            }
          }
        }
      });
      return newIdentifier;
    };


    В данном коде мы фиксированно ищем const {varName} = this.props. Поскольку это PoC, этого вполне достаточно. Возвращает эта функция uuid с идентификатором переменной, чтобы в процессе построения шаблона и AST класса компонента.


    На выходе из парсера мы получим UST-ноду, с типом ForLoop.


    Генерация шаблона и класса нового компонента

    В данном случае мы начинаем использовать parse5 и babel-types. Генераторы работают по принципу матчеров, но без предиката, в данном случае генератор ответственнен за полное генерирование определенного типа.


    export class ForLoopGenerator implements Generator {
      matchingType = 'ForLoop';
    
      generate(node: any):any {
        const children = node.children;
        let key;
        const attrs:Array = getAttributes(children.attributes.filter((x:any)=>x.name !== 'key'));
        const originalName = resolverRegistry.vars.get(node.baseItem.name);
        attrs.push({
          name:'*ngFor',
          value: `let ${node.arguments[0].name} of ${originalName && originalName.name}`
        });
        const htmlNode = {
          tagName:children.identifier.value,
          nodeName:children.identifier.value,
          attrs: attrs,
          childNodes: new Array(),
        };
        let keyAttribute = children.attributes.find((x:any) => x.name === 'key');
    
        if (keyAttribute) {
          if (isMemberExpression(keyAttribute.value)) {
            const {value} = keyAttribute;
            key = `${value.object.name}.${value.property.name}`;
          }
        }
        for (let child of children.children) {
          htmlNode.childNodes.push(angularGenerator.generate(child));
        }
        return htmlNode;
      }
    }


    Далее результирующие html-ноды будут переведены в html c помощью parse5.


    Генерация класса компонента


    На данный момент генератор класса работает очень просто, он собирает входные параметры из переменных и добавляет их к классу.


    export class AngularComponentGenerator {
      generateInputProps():Array {
        const declarations:Array = [];
        resolverRegistry.vars.forEach((value, key, map1) => {
          switch (value.type) {
            case 'Input':
              declarations.push(
                b.classProperty(
                  b.identifier(value.name),
                  undefined,
                  undefined,
                  [
                    b.decorator(b.identifier('Input'))
                  ]
                )
              );
          }
        });
        return declarations;
      }
      generate() {
          const src = b.file(
            b.program(
            [
              b.exportNamedDeclaration(
                b.classDeclaration(b.identifier('MyComponent'), undefined, b.classBody(
                  [
                    ...this.generateInputProps(),
                  ]
                ),
                  [
                  b.decorator(b.callExpression(
                    b.identifier('Component'),
                    [
                      b.objectExpression( [
                        b.objectProperty(b.identifier('selector'),b.stringLiteral('my-component'),false,false,[]),
                        b.objectProperty(b.identifier('templateUrl'),b.stringLiteral('./my-component.component.html'),false,false,[]),
                        ]
                      )
                    ]
                  ))
                ]),
                [],
                undefined,
              )
            ]
          ));
    
          return generator(src).code;
    
      }
    }


    Результаты и выводы

    При входном компоненте:


    import React from 'react';
    class MyComponent extends React.Component {
        render() {
            const {a} = this.props;
            const {b} = this.props;
            return (

    Title

    { a } { b }
      { a.map(asd => (
    • {asd}asd
    • )) }
    { children }

    And here we go

    ) } }


    Получается вот такой шаблон:


    Title

    {{a}} {{b}}
    • {{asd}}asd

    And here we go


    И такой класс:


    @Component({
      selector: "my-component",
      templateUrl: "./my-component.component.html"
    })
    export class MyComponent {
      @Input a;
      @Input b;
    }


    Общие выводы


    1. На данный момент есть ощущение, что конвертер возможен
    2. Нужен статический анализатор предикатов для предотвращения пересечений
    3. Работы предстоит много


    Спасибо за внимание.


    Ссылка на репозиторий, в котором идет работа. Там еще очень много костылей, но это PoC, так что можно.

    © Habrahabr.ru