Мой опыт миграции приложения на standalone-компоненты

Как мигрировать приложение на standalone-компоненты. Опыт фронтенд-разработчика

Антон Горелов
Антон Горелов Frontend-разработчик
28 мая 2024

Рассказываем, что дают standalone-компоненты, на что стоит обратить внимание в процессе миграции и тестирования и когда использовать standalone, а когда стоит все же остаться на модулях.

Изображение записи

Часто фронтенд-разработчики при рефакторинге или написании приложения с нуля спорят о выборе одного из двух подходов. Первый — «все делаем через модули, они прекрасно работают, не надо ничего нового». Второй — «есть standalone-компоненты, супер, используем новый инструмент».

Ниже фронденд-разработчик Selectel Антон Горелов описывает сценарии, с которыми чаще всего сталкивается лично, и обобщает свой опыт. Материал будет полезна фронтенд-разработчикам уровня Junior+ и Middle.

Standalone-компоненты были добавлены уже в далекой 14 версии Angular. Если кратко, это компоненты, которые больше не нужно объявлять в модулях. Для работы с ними нужно добавить свойство standalone: true и явно указать зависимости. Казалось бы, это удобно и модули больше никому не нужны. Однако новое — не всегда лучшее.

Автомиграция с помощью schematic

Первый вопрос, который у меня возник перед миграцией, — есть ли готовые инструменты, которыми можно воспользоваться, чтобы не изобретать велосипед. В официальной документации Angular для миграции приложений предлагается schematic, который потребует вашего минимального участия.

Процесс состоит из четырех шагов.

  1. Сконвертировать компоненты, директивы и пайпы. Каждый элемент преобразуется в standalone с помощью соответствующего флага, добавляются нужные импорты и зависимости.
  2. Удалить ненужные классы с декоратором NgModule. Ненужные модули идентифицируются и удаляются. Интересно, что если мигратор не поймет, что модуль можно удалить (safe remove), будет добавлено соответствующее TODO, чтобы сделать это вручную.
  3. Перенести основной модуль приложения на standalone API. bootstrapModule будет преобразован в новый bootstrapApplication и все соответствующие параметры (declarations, providers) будут трансформированы с помощью нового API.
  4. Запустить линтеры.

Если проект небольшой, можно использовать вообще одну команду, которая сделает красиво все за вас: ng g @angular/core:standalone --all.

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

Если же у нас огромный монорепозиторий и набор enterprise-решений с большим количеством зависимостей в production, я придерживаюсь поэтапного перехода. Другими словами, стараюсь мигрировать каждый пакет/модуль/домен по отдельности.

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

Ручная миграция

Lazy-loading standalone-компонента

В рамках моей задачи нужно перенести часть рабочего кода (модуль) в отдельный npm-пакет и дополнить существующие страницы новыми. Начнем с переключения lazy-loading модуля на lazy-loading компоненты в AppModule. Текущий модуль содержит лениво загружаемый маршрут, указывающий на NgModule с вложенными дочерними маршрутами. Поскольку модуля больше нет, в свойстве loadChildren теперь можно напрямую указывать конфиг или компонент.

Вот как выглядит привычный Lazy Loading до изменений (далее я буду его переписывать):


    {
    path: 'license',
    loadChildren: () => import('./license').then(m => m.LicenseModule),
},

Вариант загрузки компонента по отдельности

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


    {
    path: 'license',
    loadComponent: () =>
        import('./license/components/license.component')
            .then(c => c.LicenseComponent),
},

Реализуем то же самое с несколькими компонентами:


    {
    path: 'license/new',
    loadComponent: () =>
        import('./license/components/new-license.component')
            .then(c => c.NewLicenseComponent),
},
{
    path: 'license/edit',
    loadComponent: () =>
        import('./license/components/edit-license.component')
            .then(c => c.EditLicenseComponent),
},
...

Вариант загрузки всего Routing Config

В качестве альтернативы можно загрузить все дочерние маршруты единожды, как в примере ниже:


    export const LICENSE_ROUTES: Routes = [
  { 
    path: 'new',
    component: NewLicenseComponent,
  }, 
  { 
    path: 'edit',
    component: EditLicenseComponent,},
]; 
... 
{ 
  path: 'license',
  loadChildren: () => import('./license/license.routes')
    .then(m => m.LICENSE_ROUTES),
},

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

Вариант с использованием функции provideRouter

Для новых приложений, начинающих свой путь сразу на standalone-компонентах, документация предлагает именно такой подход. Можно указывать все в одном файле, например app.config.ts, с использованием функции provideRouter. Там же — прописать стратегии загрузки и т. д. Более подробно это описано в документации Angular.

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


    const routes: Routes = [
  {
    path: 'first-component',
    component: FirstComponent,  // this is the component with the <router-outlet> in the template
    children: [
      {
        path: 'child-a',  // child route path
        title: resolvedChildATitle,
        component: ChildAComponent,  // child route component that the router renders
      },
      {
        path: 'child-b',
        title: 'child b',
        component: ChildBComponent,  // another child route component that the router renders
      },
    ],
  },
];
export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes)]
};

Импорт сущностей

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

Совет 1. Внимательно относитесь к imports! Подключайте то, что действительно требуется для работы

Например, CommonModule лучше импортировать не целиком, а гранулярно подключить необходимые для компонента зависимости: NgIf, NgFor, AsyncPipe и NotificationsComponent. Это позволит механизму tree-shaking отработать лучше.


    @Component({
  standalone: true,
  selector: 'app-license',
  imports: [
    // CommonModule,
    // Вместо импортирования модуля импортнуть нужное
    NgIf,
    NgFor,
    AsyncPipe,
    NotificationsComponent,
  ],
  templateUrl: './license.component.html',
})
export class LicenseComponent {...}

В версии Angular 15.1 появились standalone-альтернативы, заменяющие стандартные модули:

Совет 2. Не все вложенные модули стоит переписывать здесь и сейчас

При миграции я столкнулся с подключением других feature-модулей. Их можно разделить на две группы.

  • Модули в виде одного домена. Это набор структур, объединенных одной задачей. Пример — FeatureFlagModule. Из названия понятно, что он отвечает за работу с фича-флагами. Внутри содержится набор провайдеров, токенов, директив и сервисов, а также парочка компонентов. Структура была написана давно, сейчас в ней редко что-то меняется.
  • Модули, которые содержат набор элементов и которые могут существовать отдельно. При этом набор элементов можно разделить. Для простоты приведу пример небольших модулей, в которых нет кучи зависимостей. Они достаточно просты и понятны. Пример с подобной структурой ниже.

    NotificationsModule
 	> notifications.component.ts
 	> add-notification.component.ts
 	> notification.interface.ts

Вторую группу можно мигрировать без особых трудностей и затрат по времени. А что с первой?

Проблема в том, что у меня нет ответа на вопрос, нужно ли такие модули переписывать. Есть два подхода: модули и standalone-компоненты, но нет единого мнения, какой из них лучше. Как бы сделали вы? Мои аргументы за использование модулей такие.

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

И здесь плавно переходим к тому, что Angular раньше строго диктовал, как писать и создавать структуру. При отказе от модулей структура становится более расплывчатой. Повторюсь: я не против отказа от модулей. Главное — здравый смысл и то, что standalone-компоненты стоит применять не повсеместно, а там, где действительно нужно.

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

Наконец, есть классы, которые разделять бессмысленно. Например,  TabsContainer и TabComponent. По именованию видно, что классы жестко связаны и используются как единое целое. И там, где их нужно применить, импортировать TabsModule гораздо проще и понятнее.

Модули, тем не менее, имеют и недостатки.

  • Дополнительный бойлерплейт;
  • Лишняя абстракция;
  • Tree-shaking работает не так хорошо, даже если избегать создания папки Shared Modules, которая содержит все компоненты подряд. Это обусловлено тем, что механизму tree-shaking сложнее резолвить зависимости.

На пункт Tree-shaking стоит обратить особое внимание, поскольку это одна из целей, которую преследовала команда Angular. И это важно при разработке крупных приложений, библиотек, ui-kit систем. Переход на standalone-компоненты в таком случае нужен, т. к. на первое место ставится более быстрая загрузка, сокращение объема кода для анализа и выполнения браузером.

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

Например, до 15 версии в Angular нельзя было полноценно отказаться от модулей. Если был standalone-компонент, их приходилось создавать ради одной директивы. Новые части определенно стоит писать на standalone и строить соответствую структуру.

Совет 3. Обратить внимание на экспорт сущностей из таких модулей

Другое, что я сделал с подобным модулем, — добавил в exports нужные компоненты. Это позволяет экспортировать не весь модуль, а его часть. И для визуального удобства сделал алиасы импортируемых сущностей, создав индексные (barrel) файлы и добавив ссылки в tsconfig.json.


    {
  "compilerOptions": {
    "paths": {
      "@core/tokens": ["src/app/core/tokens/index.ts"],
      "@selectel/iam-panel/*": [
          "packages/iam-panel/src/lib/*", "packages/iam-panel/*"
      ],
    }
  }
}

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


    // Module
exports: [
	AddSubscribersDialogComponent, 
	DeleteSubscribersDialogComponent,
];

Это позволит избежать подобных импортов в вашем коде:


    import { AddSubscribersDialogComponent } from '/iam-panel/dialogs-module/add-subscribers-dialog/add-subscribers-dialog.component';

А такой вариант смотрится гораздо проще читать и выглядит чище:


    import { AddSubscribersDialogComponent } from '@iam-panel/dialogs';
...
{
	standalone: true,
	imports: [AddSubscribersDialogComponent],
}

Совет 4. Использовать provideIn (tree-shakeable API) вместо providers модуля

Совет не мой и существует очень давно. Здесь лишь хочу отметить, что отказ от модулей позволит соблюсти рекомендацию использования свойства providedIn. А это, в свою очередь, также положительно повлияет на размер конечного бандла. Более подробно можно почитать в публикации.

Тестирование

После переноса сущностей я запустил тесты. Для их работы мы используем Karma, Jasmine, Spectator. Так как логику внутри не менял, то ожидал, что все тесты пройдут успешно. Но увы. У standalone-компонентов есть нюансы, с которыми я встретился и получил ошибки.

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


    let spectator: Spectator<PollComponent>;
  const createComponent = createComponentFactory({
    component: PollComponent,
    declarations: [MockDirective(HideUrlDirective)],
    imports: [PopoverModule],
  });

  beforeEach(() => (spectator = createComponent()));

 Разгадка в следующем.

  • Директива в шаблоне обычного компонента импортируется в модуле. В тестовом модуле мы объявляем зависимости, которые во время теста использует компонент. Директива не была указана в импортах и из-за этого тест упал.
  • Директива в шаблоне standalone-компонента импортируется в нем же. Spectator добавит сам компонент в поле imports и подтянет все оригинальные зависимости.

На GitHub этот вопрос уже поднимался. Интересный, на мой взгляд, ответ дал в комментариях пользователь Netanel Basal. Аналогично это работает и для standalone-пайпов. На GitHub показано, как Spectator работает с импортами компонента.

Таким образом, можно сказать, что standalone компоненты предлагают лучший Developer Experience. А настройка тестового окружения заметно улучшается с увеличением числа зависимостей, т. к. все они импортированы внутри компонента.

В заключение отмечу, что standalone-компоненты — отличный инструмент для улучшения тестируемости. Если раньше при использовании модулей приходилось обновлять зависимости в тестовом модуле, то со standalone-компонентами больше не нужно об этом думать. Это происходит потому, что отдельный компонент напрямую импортирует свои зависимости, тогда как раньше это происходило из модуля Angular, который дублировался в тестовых настройках.

Вывод

Код мигрирован, тесты работают, наша работа завершена. Вернемся к вопросу использования подходов. 

Как видите, универсального решения на все случаи жизни нет. В самых простых или «идеальных» проектах можно автоматизировать рефакторинг чуть менее, чем полностью, через schematics. В реальном же мире фанатично переводить все приложение на standalone, на мой взгляд, не стоит. Можно постепенно переписывать части, которые наиболее необходимы и от рефакторинга которых мы получим улучшение в Developer Experience.