Вступление


Доброго времени суток, меня зовут Владимир Миленко, я 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, где idObjectPattern, а initMemberExpression, с 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=>(<li>{x}</li>)
}

Парсинг


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


{
    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<any> = 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<any>(),
    };
    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<any> {
    const declarations:Array<any> = [];
    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 (<div className="asd">
            <h1>Title</h1>
            {
                a
            }
      {
        b
      }
            <ul>
                {
                    a.map(asd => (<li key={asd.key}>{asd}<text>asd</text></li>))
                }
            </ul>
            {
                children
            }
            <h3>And here we go</h3>
    </div>)
    }
}

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


<div class="asd">
  <h1>Title</h1>
  {{a}}
  {{b}}
  <ul>
    <li *ngFor="let asd of a">{{asd}}<text>asd</text></li>
  </ul>
  <ng-content></ng-content>
  <h3>And here we go</h3>
<div>

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


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

Общие выводы


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

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


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

Комментарии (32)


  1. vintage
    09.03.2018 13:01

    Вы правда считаете нормальным тьюринг-полный язык пытаться транслировать в тьюринг-неполный? Проще и надёжнее действовать в обратном направлении.


    1. AsTex Автор
      09.03.2018 13:04
      +3

      Это вы сейчас про JSX -> Angular Template?
      Так вот, операции над входными параметрами, которые проводились в JSX будут переведены в TS.
      Так, например, мутации перед итерации с массивом элементов — изначально выдернуты и не отображаются в результируюзем html-шаблоне. Эти операции будут вынесены отдельно в TS.
      Над этим я прямо сейчас работаю


      1. Druu
        10.03.2018 09:28

        Ну смотрите, допустим конструкции вида?: из jsx вы еще сконвертируете в *ngIf. но например вывести список компонент в реакте можно десятком различных способов, вы будете все описывать? А может там вместо списка будет просто произвольная функция. Определить, что она возвращает — задача алгоритмически неразрешимая.

        Проблема тут именно в различии самого способа рендера в реакте и в ангуляре.

        Лучше уж подождать нового рендера и попробовать генерить уже скомпиленные темплейты, а не сами ангуляровские. Это должно быть сильно проще.


        1. AsTex Автор
          10.03.2018 13:55

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


          1. Druu
            11.03.2018 09:31

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

            В самом рендере архитектура очень близка как раз, просто темплейты от этой архитектуры эффективно пользователя изолируют. Но для текущего рендера руками генеить код малореально, а вот в ivy будет в принципе вполне ок.


    1. babylon
      09.03.2018 13:29

      Дмитрий, Вы меня ожидаемо опередили с комментарием:)


  1. Druu
    09.03.2018 13:42
    +1

    А как НОС'и транслировать?


    1. AsTex Автор
      09.03.2018 13:43

      HOC'и — отдельная история, как и React.cloneElement.
      Возможно, конвертировать HOC'и в сервисы.
      Посмотрим


      1. nsinreal
        09.03.2018 23:23

        HOC много мощнее чем все что есть в Angular.
        Скорее всего оптимальным решением будет написание под каждый специфичный HOC специфичный конвертер.


        1. AsTex Автор
          09.03.2018 23:51

          В конечном счете, можно прямо-таки переносить функционал HOC'а в сам компонент.


          1. nsinreal
            10.03.2018 00:00

            В AoT-режиме ангуляра с выкинутым компилятором шаблонов у вас не получится такой трюк. Просто потому что HoC может трансформировать vDOM таким образом, что вам придется генерировать компоненты в рантайме.


            1. AsTex Автор
              10.03.2018 00:02

              Почему же? Я имею ввиду, что одним из вариантов будет по сути клонирование функционала HOC напрямую в генерируемый компонент.


              1. nsinreal
                10.03.2018 00:05

                Уточню. Универсальное клонирование функционала или специфичное под каждую HoC-трансформацию?


                1. AsTex Автор
                  10.03.2018 00:07

                  И так, и так. Абсолютно все кейсы покрыть невозможно.
                  Можно же определить генератор для определенного HOC'а.
                  Вы не ограничены ничем :)


                  1. nsinreal
                    10.03.2018 00:10

                    В таком случае может выгореть. Успехов в реализации :-)


  1. Boyd_Rice
    09.03.2018 13:52
    +3

    Воу, это выглядит как очень интересная задача в техническом плане!
    Так же, это может быть крайне полезно при миграции с одного фреймворка на другой.


  1. nsinreal
    09.03.2018 23:22

    и в Retail продуктах для админ-панелей мы используем React.JS в качестве основного фреймворка, однако платформа для ресторанов использует Angular, что не позволяет им использовать нашу библиотеку компонентов
    А вы не задумывались о том, чтобы использовать React-компоненты в Angular? Будут сложности с трансклюдом правда. Но мне почему-то кажется, что это вполне реально.

    <li *ngFor="let asd of a">{{asd}}</li>
    По хорошему, нужно генерировать так же trackBy функцию (соответствует key-аттрибуту в реакте), чтобы не огребать на ровном месте.

    А вам нужна единоразовая генерация или мультиразовая?


    1. AsTex Автор
      09.03.2018 23:45

      Использовать react-компоненты в ангуляре — нести с собой реакт, что не очень. К тому же, цель — иметь нативные компоненты.

      Про ключи — да, я выдергиваю это, в целом, trackByFn — это ReturnStatement с содержимым аттрибута key.

      Конечно же мультиразовая :)


      1. nsinreal
        10.03.2018 00:03

        Конечно же мультиразовая :)
        Грустно, на самом деле. Придется либо постоянно дописывать конвертер, либо очень лимитировать разработчиков. Частный случай решить проще. Как задача интересно, но поддерживать это — я бы на такое не подписался.


        1. AsTex Автор
          10.03.2018 00:06

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


          1. nsinreal
            10.03.2018 00:10

            Написал новую конструкцию за 2 минуты — пишешь предикат — половина тестов сломалась — копаешься полдня — плюешь — пишешь обычную конструкцию.


            1. AsTex Автор
              10.03.2018 00:13

              Именно поэтому я и написал про статический анализатор предметов, чтобы в процессе написания нового видеть возможные пересечения. В конце-концов всегда можно переопределить другой или написать более специфичный предикат


              1. nsinreal
                10.03.2018 00:32

                Хм. Не подкопаешься :-)
                А как планируете различать Input и Output?


                1. AsTex Автор
                  10.03.2018 01:12

                  А вот это куда более интересно.
                  Output не используется в рендере, грубо говоря, он ничего не возвращает.
                  Более того, мы знаем, что самое частое использование аутпута —
                  this.props.*(someData?)
                  Т.е., это функция, результат которой не используется в JSXExpressionContainer'е как таковом(не является чайлдом)


                  1. nsinreal
                    10.03.2018 01:40

                    Не совсем верно. См.:
                    <input onChange={e => props.setName(e.target.value))}></input>

                    Или вы про то, что для событий явно не будет такого?
                    <custom strangAttr={props.getSmth()}></custom>

                    Возможны ситуации с пробросом обработчиков событий через 2-4 компонента.

                    <custom-button onClick={props.showPopup}></custom-button>

                    И еще один важный момент. В реакте обработчики кастомных событий могут спокойно возвращать какой-то результат (правда хз зачем, но можно). А в ангуляре это невозможно — EventEmitter не даст вам этого сделать.

                    С другой стороны в некотором реактовском коде можно также встретить передачу функции как параметризацию компонента (что можно перенести напрямую в ангуляровский код). Это пример 2 из этого комментария.


                    1. AsTex Автор
                      10.03.2018 02:04

                      Вот вы привели пример:
                      onChange={e => callExpression}
                      setName — параметр из props, мы изначально можем сказать, что это функция — Output.
                      Проброс — так же весьма интересный вариант. Дело в том, что проброс — это не вызов :) А значит — инпут :)


                      1. nsinreal
                        10.03.2018 02:18

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


                        1. AsTex Автор
                          10.03.2018 02:20
                          +1

                          Думаю, вариант сделать это есть. Достаточно определить сам факт того, что это проброс. А дальше уже следовать best-practices


      1. vintage
        10.03.2018 10:26

        Носите preact или любую другую легковесную реализацию. На реакте свет клином не сошёлся.


        1. AsTex Автор
          10.03.2018 14:34

          Это не решает проблемы. Нативность лучше, чем тащить кучу зависимостей


          1. vintage
            10.03.2018 18:42

            Лучше простое, надёжное и одинаково работающее решение, а не такое, которое нужно, чтобы кто-то постоянно допиливал, а результат всё-равно работал немного по разному.


  1. ganqqwerty
    10.03.2018 12:36

    Слегка не по теме вопрос — я увидел что вы генерируете типы, и подумал — давно хотелось генерировать рандомные объекты по их ts-интерфейсам и использовать их для упрощения юнит-тестирования, но нет никакой возможности получить доступ к свойствам интерфейсов — на этапе компиляции ts в js типы растворяются. Не знаете ли вы, можно ли использовать один из этапов ts-компиляции для того чтобы схватить список свойств интерфейса?