Переход от Angular к React, не начиная с нуля

Хотя Angular — отличный фреймворк, инженерные усилия, приложенные командой и сообществом React, не имеют себе равных в мире фронтенда. Настолько, что мы недавно начали переход с Angular на React.

Некоторые различия действительно важны для нас:

  • В мире React происходят новейшие и величайшие события.
  • Доступно больше компонентов сообщества
  • Более дружественный к разработчикам API
  • Обнаружение изменений гораздо легче понять.
  • Меньшая поверхность API, а значит меньше путаницы
  • Лучший выбор примитивов (компоненты как переменные против шаблонов с ng-template и ng-content)
  • Четкий путь к SSR
  • Интеграция React Three Fiber

Однако у нас есть отличный сайт, который мы не хотим выбрасывать. Поэтому мы решили написать библиотеку, которая позволит нам использовать React в Angular и Angular в React.

Это может показаться довольно сложной задачей, но мы нашли способ сделать это довольно легко (в основном, просто используя ReactDOM.render).

Я приведу несколько выдержек из нашего исходного кода, чтобы вы могли увидеть, как мы это делаем. Если вы хотите сразу погрузиться в процесс, посмотрите библиотеку на github.com/bubblydoo/angular-react.

Основная идея заключается в том, что есть компоненты-обертки:

<!-- Using a React component in Angular: -->
<react-wrapper
  [component]="Button"
  [props]="{ children: 'Hello world!' }">
</react-wrapper>
Вход в полноэкранный режим Выход из полноэкранного режима
// Using an Angular component in React:
function Text(props) {
  return (
    <AngularWrapper
      component={TextComponent}
      inputs={{ text: props.text }}>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Обертка React

Обертка react-wrapper в основном реализована следующим образом:

@Component({
  selector: "react-wrapper",
  template: `<div #wrapper></div>`,
})
export class ReactWrapperComponent implements OnChanges, OnDestroy {
  @ViewChild("wrapper") containerRef: ElementRef;
  @Input()
  props: any = {};
  @Input()
  component: React.ElementType;

  ngAfterViewInit() {
    this.render();
  }

  ngOnChanges() {
    this.render();
  }

  private render() {
    ReactDOM.render(
      <this.component {...this.props} />,
      this.containerRef.nativeElement
    );
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вы можете найти ее полный исходный код здесь.

Каждый раз, когда компонент или реквизиты изменяются, React будет перерисовывать компонент. Довольно просто!

Это возможно, потому что в React компоненты очень легковесны, мы можем легко передавать компоненты и рендерить их. Нет необходимости в какой-либо глобальной настройке. С другой стороны, в Angular есть необходимость в глобальном контексте. Что если мы хотим передать дочерние компоненты Angular в компонент React?

Обертка Angular

Установка компонента Angular внутри произвольного элемента DOM также возможна, хотя и не так элегантна. Для этого нам нужно иметь глобальный NgModuleRef и компонент Angular.

Используя обратный вызов, мы монтируем компонент Angular в компонент React.

Мы передаем элемент div в Angular Component в качестве его дочерних элементов, а затем используем React.createPortal для встраивания в него дочерних элементов React.

function AngularWrapper(props) {
  const ngComponent = props.component;
  const ngModuleRef = useContext(AngularModuleContext);

  const hasChildren = !!props.children
  const ngContentContainerEl = useMemo<HTMLDivElement | null>(() => {
    if (hasChildren) return document.createElement('div');
    return null;
  }, [hasChildren]);

  const ref = useCallback<(node: HTMLElement) => void>(
    (node) => {
      const projectableNodes = ngContentContainerEl ? [[ngContentContainerEl]] : [];
      const componentFactory = ngModuleRef.componentFactoryResolver.resolveComponentFactory(ngComponent);
      const componentRef = componentFactory.create(ngModuleRef.injector, projectableNodes, node);
    },
    [ngComponent, ngModuleRef]
  );

  return (
    <>
      {React.createElement(componentName, { ref })}
      {ngContentContainerEl && ReactDOM.createPortal(<>{children}</>, ngContentContainerEl)}
    </>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

В этом файле есть еще много всего, например:

  • обработка событий
  • входы & обработка выходов
  • правильная передача React.forwardRef.

Вы можете найти исходный код здесь

Результат

Теперь мы можем использовать компоненты Angular и React взаимозаменяемо!


@Component({
  selector: 'inner-angular',
  template: `<div style="border: 1px solid; padding: 5px">this is inner Angular</div>`,
})
class InnerAngularComponent {}

@Component({
  template: `
    <div style="border: 1px solid; padding: 5px">
      <div>this is outer Angular</div>
      <react-wrapper [component]="ReactComponent"></react-wrapper>
    </div>
  `,
})
class OuterAngularComponent {
  ReactComponent = ReactComponent;
}

function ReactComponent(props: { children: any }) {
  return (
    <div style={{ border: '1px solid', padding: '5px' }}>
      <div>this is React</div>
      <div>
        <AngularWrapper component={InnerAngularComponent} />
        {props.children}
      </div>
    </div>
  );
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Рендеринг происходит правильно, как показано ниже:

Использование сервисов Angular в React

Вы когда-нибудь задумывались, как работает инъекция зависимостей в Angular? Он просто хранит внутри себя простую карту ключ-значение. Эта карта ключей-значений и есть инжектор.

В обертке react мы также предоставляем инжектор в контексте:

ReactDOM.render(
  <InjectorContext value={this.injector}>
    <this.component {...this.props} />
  </InjectorContext>,
  this.containerRef.nativeElement
)
Войти в полноэкранный режим Выйти из полноэкранного режима

Таким образом, в React мы можем получать сервисы следующим образом:

const injector = useContext(InjectorContext)
const authService = injector.get(AuthService)
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы добавили в библиотеку сокращенный хук, чтобы сделать это еще короче:

import { useInjected } from '@bubblydoo/react-angular'

const authService = useInjected(AuthService)
Войти в полноэкранный режим Выйти из полноэкранного режима

Таким образом, стало проще использовать инъекции Angular в компонентах React!

Использование Observables в React

Хотя существуют более функциональные решения, такие как observable-hooks, использование RxJS Observables настолько распространено в Angular, что мы также добавили хук для этого.

import { useObservable } from '@bubblydoo/react-angular'

const [value, error, completed] = useObservable(authService.isAuthenticated$)

if (error) return <>Something went wrong!<>

return <>{value ? "Logged in!" : "Not logged in"}</>
Вход в полноэкранный режим Выход из полноэкранного режима

Этот компонент позволяет использовать Angular Router в компонентах React.

interface Props {
  link?: string[];
  children: any;
}

export default function Link(props: Props) {
  const { link, children } = props;

  const router = useInjected(Router);
  const zone = useInjected(NgZone);
  const activatedRoute = useInjected(ActivatedRoute);
  const locationStrategy = useInjected(LocationStrategy);

  const onClick = (e?: any) => {
    e?.preventDefault();
    zone.run(() => {
      router.navigate(link);
    });
  };

  const urlTree = router.createUrlTree(link, { relativeTo: activatedRoute });
  const href = locationStrategy.prepareExternalUrl(router.serializeUrl(urlTree));
  const childProps = { onClick, href };

  return React.cloneElement(children, childProps);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование:

<Link link={['dashboard']}>
  <a>Go to dashboard</a>
</Link>
Войти в полноэкранный режим Выйти из полноэкранного режима

Где мы можем улучшить

i18n

Angular i18n пока не очень хорошо обрабатывается. $localize не работает в файлах .tsx. Также невозможно добавить атрибуты i18n к компонентам React, потому что extract-i18n смотрит только на шаблоны Angular.

NgZone

Работа с NgZone: иногда события, приходящие от элемента React, следует обрабатывать с помощью ngZone.run(() => ...), в случае если вы хотите использовать сервисы Angular, а события не отслеживаются зоной. См. пример компонента Link выше.

Получение ссылки на компонент Angular в React

Это тоже пока не реализовано, но мы можем добавить свойство componentRef в AngularWrapper для получения ссылки на компонент Angular.

Передача дочерних компонентов Angular в react-wrapper.

На данный момент это невозможно:

<react-wrapper [component]="Button">
  <div>Angular Text</div>
</react-wrapper>
Вход в полноэкранный режим Выход из полноэкранного режима

Обходной путь:

@Component({
  template: `<div>Angular Text</div>`
})
class TextComponent {}

@Component({
  template: `
<react-wrapper [component]="Button" [props]="{{children}}"></react-wrapper>
`
})
class AppComponent {
  children = React.createElement(
    AngularWrapper,
    { component: TextComponent }
  )
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Добавление этой функции значительно упростит взаимозаменяемое использование компонентов.

Использование AngularWrapper вне компонента Angular

На данный момент эта библиотека предназначена для использования внутри проектов Angular. Необходимо провести дополнительные испытания, чтобы убедиться, что она также работает в проектах Next.js и Create React App.

Одна из проблем заключается в том, что вам нужно будет создать и предоставить NgModule:

import { AngularModuleContext, AngularWrapper } from '@bubblydoo/angular-react'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'

const ngModuleRef = await platformBrowserDynamic()
  .bootstrapModule(AppModule)

<AngularModuleContext.Provider value={ngModuleRef}>
  <div>
    <AngularWrapper component={Text} inputs={{ text: 'Hi!'}}/>
  </div>
</AngularModuleContext.Provider>
Вход в полноэкранный режим Выход из полноэкранного режима

Другая проблема заключается в системе сборки: Проекты Next.js и CRA не настроены на чтение и сборку кода Angular, который нуждается в специальных компиляторах. Такая конфигурация обычно предоставляется @angular/cli, но будет сложно интегрировать ее с существующей системой сборки Next.js/CRA.

  • Вам нужно будет создать отдельный проект библиотеки Angular, чтобы собрать компоненты Angular в обычный код Javascript.
  • Вам нужно будет использовать @angular/compiler-cli/linker/babel в качестве плагина babel-loader в конфигурации Webpack.
  • Затем вы можете импортировать компоненты Angular в свой проект React.

Мы рассматриваем возможность усовершенствовать этот подход для перехода на Next.js, чтобы наконец-то использовать SSG/SSR. Мы экспериментируем с Turborepo, чтобы управлять отдельными проектами.

Кредиты

Хотя это очень разные проекты, я в основном черпал вдохновение из @angular/elements, а также из microsoft/angular-react (это старый проект, который больше не работает в новых версиях Angular).

Оцените статью
Procodings.ru
Добавить комментарий