Почти все Angular проекты, над которыми я работаю, содержат routed-компоненты, то есть компоненты подключаемые через конфигурацию роутера в component и loadComponent и отображаемые в <router-outlet/>. Такие компоненты, как правило, знают, где взять нужные им данные, когда их загрузить и что показать, пока данных нет. Ещё их называют «умными» или smart containers.

Суть проблемы

Типичный routed-компонент у меня выглядел так:

// routes.ts
const routes: Routes = [
  /* ... */
  { 
    path: 'users/:userId',
    component: UserPage,
  }
]

// user-page.component.ts
@Component({
  /* ... */
  template: `@if (isLoading()) {
              Loading...
            } @else if (error()) {
              Failed to load user
            } @else if (user(); as user) {
              {{ user.name }}
            }`,
})
export class UserPage {
  private readonly activatedRoute = inject(ActivatedRoute);
  private readonly userService = inject(UserService);
  private readonly userId = toSignal(
    this.activatedRoute.paramMap.pipe(map(params => params.get('userId'))),
  );
  private readonly userResource = rxResource({
    params: () => this.userId(),
    stream: ({ params: userId }) => {
      if (!userId) {
        return of(null);
      }

      return this.userService.getById(userId);
    },
    defaultValue: null,
  });
  readonly user = userResource.value;
  readonly isLoading = userResource.isLoading;
  readonly error = userResource.error;
}

На первый взгляд ничего необычного: такое нередко можно увидеть в проектах, использованы сигналы и новейшая фича rxResource, нет подписок, от которых нужно отписываться, а главное — работает! (жмите approve — работает же) Но давайте на минутку остановимся и посмотрим на этот компонент через архитектурную призму.

Сейчас этот routed-компонент знает про конкретный класс UserService, хотя его настоящая потребность — получить объект User. Он знает, что размещён на роуте и что параметр называется userId, то есть его достаточно тяжело будет переиспользовать в другом контексте. Он объявляет user как User | null, хотя null здесь выглядит как протёкшая наружу деталь реализации загрузки, и шаблон вынужден её проверять. Это как минимум не полное следование SRP, DIP и высокая связанность (coupling).

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

И что же это получается? Ходишь-кодишь на работу, а потом бац — технический долг! И прощай любимые продуктовые фичи на целую неделю.

Было бы неплохо привести UserPage к следующему виду:

// user.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

// user-page.component.ts
@Component({
  /* ... */
  template: `{{ user().name }}`,
})
export class UserPage {
  readonly user = input.required<User>()
}

Дальше рассказываю, как у меня это получилось с помощью функционала withComponentInputBinding.

Что умеет withComponentInputBinding

Функционал появился в Angular 16 в 2023 году и включается одной строкой:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withComponentInputBinding())],
});

После этого роутер записывает данные роута в те инпуты routed-компонента, имена которых совпадают с ключами данных роута. Разбор функционала публиковался на Хабре, описан в документации, кроме того, для демонстрации работы withComponentInputBinding я собрал приложение в StackBlitz.

Роутер делает привязку следующих данных:

  1. query-параметры;

  2. path-параметры и matrix-параметры;

  3. статические данные роута data;

  4. данные резолверов.

Список отсортирован по возрастанию приоритета: наивысший приоритет отдаётся данным резолверов.

Если ключа в данных маршрута нет, роутер запишет в инпут undefined и затрёт значение по умолчанию, которое указано в input('default'). В версии Angular 22 это поведение настраивается:

// main.ts
provideRouter(
  routes,
  withComponentInputBinding(
    { unmatchedInputBehavior: 'undefinedIfStale' }
  )
);

Со значением 'undefinedIfStale' роутер пишет undefined только в те инпуты, которые раньше уже получали значение от маршрута. По умолчанию параметр равен 'alwaysUndefined', то есть поведение осталось прежним.

Привязка работает только для компонента, указанного в роуте через component или loadComponent. Вложенным в шаблон компонентам роутер ничего не передаёт — их данными обеспечивает страница через обычные биндинги. Это очевидно, если помнить, что роутер сам создаёт только компонент роута.

Наследование параметров и данных от родительских роутов задаётся параметром paramsInheritanceStrategy. Начиная с Angular 22 значение по умолчанию — 'always'. Раньше эту строку приходилось прописывать руками. В новых проектах она уже не нужна, а прежнее поведение доступно как 'emptyOnly'.

Таким образом, данные из конфигурации роута для routed-компонента UserPage имеют возможность попасть в его инпуты.

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

Используем резолвер

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

То есть резолверы позволяют получить path-параметр :userId, затем внедрить через DI UserService, вызвать метод получения User и вернуть его результат асинхронно.

Выглядеть это будет так:

// user-resolver.ts
export const userResolver: ResolveFn<User> = 
  (route: ActivatedRouteSnapshot) =>
    inject(UserService).getById(route.paramMap.get('userId')!);

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

// user-resolver.ts
export const  createUserResolver:
  (resolverParams: { userIdParamName: string} ) => ResolveFn<User> = 
  (resolverParams) => 
    (route: ActivatedRouteSnapshot) =>
      inject(UserService).getById(
        route.paramMap.get(resolverParams.userIdParamName)!
      );

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

//  routes.ts
const userIdParamName = 'userId'

export const routes: Routes = [
  /* ... */
  {
    path: `users/:${userIdParamName}`,
    component: UserPage,
    resolve: { user: createUserResolver({ userIdParamName }) },
  },
];

После этого компонент UserPage получит в свой input<User> значение из роутера. И мы получим целевое содержание компонента.

Так где тут инверсия

Вернусь к тому, с чего начинал. Старый UserPage держал в себе два конкретных класса: ActivatedRoute и UserService. На самом деле ему был нужен всего лишь объект User, но чтобы его добыть, он сам лез в роут за userId, сам звал сервис и сам разбирался, что показывать, пока ответа нет. Компонент зависел от деталей: от того, что он живёт на роуте, и от того, какой именно сервис отдаёт пользователя. Если бы мне нужно было переиспользовать этот компонент на другом роуте или использовать в шаблоне обычным <app-user-page>, мне пришлось бы вносить изменения в сам компонент. Или по крайней мере делать умную обёртку над ним.

Теперь в UserPage не осталось ни роута, ни сервиса — только input<User>. Компонент называет данные, которые ему нужны, и молчит о том, откуда они берутся. userId, вызов getById переехали в резолвер. А какой резолвер к какому компоненту подключить, решает конфигурация роутов.

Поэтому именно на границе компонента и происходит инверсия зависимостей. UserPage больше не знает, каким сервисом загружают пользователя и на каком маршруте он находится: ему нужен только объект User. Зависимость от UserService не исчезла из приложения — она переехала в резолвер, где ей и нашлось хорошее местечко. Резолвер получает данные, роутер передаёт их в инпут, а компонент отображает результат. Если User — общий контракт, а не тип конкретного сервиса, то компонент зависит от этого контракта, а не от деталей загрузки нужных данных.

Схематично это выглядит так:

Что характерно, функционал withComponentInputBinding прямо реализует IoC: компонент не запрашивает данные роута сам, роутер создаёт компонент и устанавливает значения его инпутов. Но сам по себе он никаких зависимостей не инвертирует. Чтобы появилась инверсия зависимостей, недостаточно просто включить функционал. Нужно осознанно провести разграничение и изоляцию: перенести использование провайдеров данных в резолверы, а компоненты сделать зависящими лишь от контракта.

Особенности подхода с получением данных из резолверов

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

// app.component.ts
@Component({
  template: `
    @if (isLoading()) {
      <app-loader />
    }
    <router-outlet />
  `,
})
export class AppComponent {
  private readonly router = inject(Router);
  protected readonly isLoading = computed(() => !!this.router.currentNavigation());
}

Также резолвер позволяет обрабатывать ошибки. Например, если возвращать null при ошибке, то тип инпута придётся расширить до User | null, и проверка пустого значения вернётся в шаблон. Сигнатура ResolveFn<T> разрешает вернуть RedirectCommand вместо значения, не меняя T:

// user-resolver.ts
export const userResolver: ResolveFn<User> = (route) => {
  const users = inject(UserService);
  const router = inject(Router);

  return users.getById(route.paramMap.get('userId')!).pipe(
    catchError(() => of(new RedirectCommand(router.parseUrl('/not-found')))),
  );
};

Тип резолвера остался ResolveFn<User> и соответствует input.required<User>().

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

И следующий код нормально скомпилируется:

// user-wrong.ts
interface UserWrong {
  unexpectedField: string;
}

// user-wrong-resolver.ts
const userWrongResolver: ResolveFn<UserWrong> = () =>
  of({ unexpectedField: 'value' });

//  routes.ts
export const routes: Routes = [
  /* ... */
  {
    path: `users/:userId`,
    component: UserPage, // input<User>
    resolve: { user: userWrongResolver }, // user: UserWrong
  },
];

// user-page.component.ts
@Component({
  /* ... */
  template: `{{ user().name }}`,
})
export class UserPage {
  readonly user = input.required<User>()
}

Если ключ userWrong не совпадает с инпутом user, при стандартном alwaysUndefined роутер передаст в user значение undefined. А если ключ user совпадает, роутер передаст результат резолвера без проверки его типа: в примере инпут получит значение типа UserWrong. Нужно быть внимательным, ссылаться в компоненте и в резолвере на один и тот же интерфейс, написать интеграционный тест для конфигурации, резолвера и компонента.

Обратное направление: данные из компонента

Первый вопрос, которым я задался: а можно ли передать данные из компонента так же просто, как в него?

Не так легко, но в принципе передать можно. А зачем?

Если это routed-компонент, то их и передавать особо некуда, а нужно просто уйти на другой роут после целевого действия. В Angular «декларативно уйти» почти всегда означает использовать routerLink в шаблоне. Уход после async-действия (save и т. п.) обычно делают императивно в обработчике — это нормальный и рекомендуемый путь.

Заключение

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

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

В своих рабочих проектах я стал использовать описанный подход с июля 2025 года. Для этого мне не понадобилось сколь либо значимых ресурсов. После включения withComponentInputBinding ничего не сломалось, какого-то массивного рефакторинга не потребовалось. Я стал, там где уместно и где не требовался объемный рефакторинг, переводить существующие routed-компоненты на использование инпутов с инициализацией данных в резолверах. Новые компоненты сразу писал так. Новые резолверы получились компактными и универсальными — их мне удалось переиспользовать. Также меня порадовала возможность наследования данных paramsInheritanceStrategy: always: вешаю один резолвер с данными на родительский роут, а компоненты на дочерних роутах получают эти же данные в свои инпуты, и не нужно прокидывать их через сервисы в DI. Интересный факт, что до этого момента в кодовой базе моей компании фича withComponentInputBinding совсем не применялась, не говоря уже о подходе в целом. И в этом смысле я стал первопроходцем.

Если хочется попробовать в своём проекте, то начать можно так: открыть умный routed-компонент и посмотреть, какие зависимости он инжектит исключительно ради данных для своей инициализации. Каждая такая зависимость — кандидат на переезд в резолвер.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знаете ли вы и используете в своих проектах withComponentInputBinding?
0%Нет, в первый раз слышу0
0%Знаю, но не использую0
0%Знаю, но использую только для проброса параметров роута0
0%Знаю, использую описанный подход и даже шире0
Никто еще не голосовал. Воздержавшихся нет.