Привет! В этой заметке покажу, как можно использовать функцию inject на сто процентов.

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

@Injectable()
export class SomeService {
  constructor(protected readonly duckService: DuckService) {}
  // ...
}

И стало:

@Injectable()
export class SomeService {
  protected readonly duckService = inject(DuckService);
  // ...
}

Удобно, конечно. Особенно тем, что от SomeService теперь легче наследоваться: не нужно в дочернем классе перечислять токены, а затем через super передавать их родителю. Кстати, по этой же причине я почти всегда объявляю сервисы через protected, чтобы ими можно было легко пользоваться в дочерних классах.

Но что, если я скажу, что это не всё, на что способна функция inject? Давайте посмотрим на паре примеров, как ещё её можно использовать.

Оборачиваем inject

Вы замечали, что с query-параметрами работать немного заморочено? Ну то есть: нам сначала надо заинжектить ActivatedRoute, чтобы получить данные о каком-то параметре:

private readonly route: ActivatedRoute = inject(ActivatedRoute);

Потом вытащить из него queryParams, а оттуда смапить объект на нужный нам параметр:

private readonly id$: Observable<string> = 
  this.route.queryParams.pipe(map(({ id }) => id))

А если мы захотим параметр обновить, то нам придётся инжектить роутер:

private readonly router: Router = inject(Router);

И вспоминать его синтаксис, а потом не забыть выставить режим merge для query-параметров (иначе все остальные параметры испарятся):

this.router.navigate(
  [], 
  { queryParams: { id }, queryParamsHandling: 'merge' }
)

А если мы захотим считать параметр синхронно, а не через Observable, то придётся отдельно обращаться к снапшоту:

const id: string = this.route.snapshot.queryParams.id;

Если нам нужно будет поменять название параметра, то нам нужно будет пробежаться по трём местам; и не забываем, что queryParams не типизирован, и TS нас тут в случае ошибки не спасёт.

Вам не кажется, что это всё сильно похоже на работу BehaviorSubject? Этот тот самый, на который можно подписаться, через который можно обновить значение и синхронно получить последнее. Что, если весь этот распылённый код собрать воедино, и управлять пресловутым query-параметром через одну точку входа?

Ну как-нибудь вот так:

@Component({ /* ... */ })
export class SomeComponent {
  protected readonly id$: QueryParam<string> = useQueryParam('id');
  public readonly entity$: Observable<SomeEntity> = this.id$.pipe(
    switchMap(/* ... */)
  );

  public updateForm(): void {
    const id: string = this.id$.getValue();
    // ...
  }

  public changeEntity(id: string): void {
    this.id$.update(id);
  }
}

Здесь нет ни Router, ни ActivatedRoute. Ведь всё, что нам нужно — это query-параметр, и у нас теперь есть объект, через который мы можем управлять этим параметром и получать о нём информацию. Можно ли так сделать? Можно! В этом нам как раз поможет функция inject.

У функции inject есть важный нюанс: ей необязательно пользоваться именно в конструкторе – это можно делать и в отдельной функции, которая к компоненту никак не относится. Главное, чтобы при вызове этой функции мы находились в injection-контексте, в противном случае Angular выкинет ошибку.

Мы находимся в injection-контексте на протяжении работы конструктора injectable-класса (и ещё в некоторых случаях, но они нам пока не интересны). Если во время работы конструктора мы вызовем функцию useQueryParam (как в примере выше), то она тоже будет находиться в контексте, а значит пользоваться функцией inject можно и там.

Таки давайте напишем функцию, которая будет создавать этот магический объект QueryParam. Для начала создадим сам класс, который унаследует Observable. Мы переносим в этот класс всю логику работы с роутером и активным роутом, поэтому передадим их через конструктор. Также нам понадобится ключ query-параметра, с которым мы будем работать — это строка, тоже передадим её через конструктор.

export class QueryParam<T> extends Observable<T> {
  constructor(
    private readonly paramKey: string,
    private readonly activatedRoute: ActivatedRoute,
    private readonly router: Router
  ) {}
}

Итак, этот класс в первую очередь Observable, а значит, при подписке мы должны получать значения этого query-параметра. Свяжем наш класс со значением параметра вот таким образом:

export class QueryParam<T> extends Observable<T> {
  // заведём поток для получения значений из query-параметра
  private readonly paramValue$ = this.activatedRoute.queryParams.pipe(
    distinctUntilKeyChanged(this.paramKey), // отсеиваем одинаковые значения
    map(() => this.getValue()), // достаём из словаря с параметрами нужный
    share({
      connector: () => new BehaviorSubject(this.getValue()),
      resetOnRefCountZero: true,
    }) // зашейрим это значение, 
    // чтобы новый подписчик не инициировал каждый раз вызов getValue()
  );

  constructor(
    private readonly paramKey,
    private readonly activatedRoute: ActivatedRoute,
    private readonly router: Router
  ) {
    super((subscriber) => {
      // каждый новый подписчик будет проксироваться в наш paramValue$
      const subscription = this.paramValue$.subscribe(subscriber);

      // при отписке от нашего Observable разрываем прокси
      return () => subscription.unsubscribe(); 
    });
  }

  public getValue(): T {
    return this.activatedRoute.snapshot.queryParams[this.paramKey];
  }
}

Теперь у нас есть возможность получать значение по подписке и синхронно через функцию getValue. Осталось сделать возможность обновить значение. Добавим функцию update, которая будет пользоваться полем router и обновлять значение прямо в адресной строке.

export class QueryParam<T> extends Observable<T> {
  private readonly paramValue$; // = ...

  costructor(/* ... */) {
    // ...
  };

  public getValue(): T {
	  // ...
  }
  
  public update(value: T): Promise<boolean> {
    return this.router.navigate([], {
      queryParams: { [this.paramKey]: value },
      queryParamsHandling: 'merge',
    });
  }
}

И теперь самое главное: напишем функцию-фабрику, которая будет генерировать нам такой объект:

export function useQueryParam<T>(paramKey: string): QueryParam<T> {
  return new QueryParam(paramKey, inject(ActivatedRoute), inject(Router));
}

Обращаем внимание, что в неё требуется передать только ключ параметра, остальное (роутер и роут) функция добудет сама через функцию inject. Чтобы посмотреть, как этим пользоваться, ещё раз взглянем на продемострированный выше пример:

@Component({ /* ... */ })
export class SomeComponent {
	protected readonly id$: QueryParam<string> = useQueryParam('id');
	//                                                        ~~~~~~
	// нам достаточно передать только ключ параметра, и уже можно пользоваться
	
	public readonly entity$: Observable<SomeEntity> = this.id$.pipe(
	  switchMap(/* ... */)
	);

	public updateForm(): void {
	  const id: string = this.id$.getValue();
	  // ...
	}
	
	public changeEntity(id: string): void {
	  this.id$.update(id);
	}
}

Очевидно, что в такой же манере можно организовать работу и с path-параметрами. Здесь можно поиграться в StackBlitz.

Заменяем stateless-сервисы

Давайте разберём ещё один пример, где функция inject поможет избавиться от одного неудобства. На этот раз будем говорить про модальные окна. Откроем раздел Overview в документации по MatDialog и немного отредактируем его первый пример.

Там ничего особенного: просто кнопка, которая открывает модальное окно и передаёт туда информацию. Модальное окно, как мы знаем, открывается с помощью сервиса MatDialog, который всегда запровайжен в root.

А теперь давайте создадим некоторый сервис, который нужно запровайдить в компонент с кнопкой. Например такой:

@Injectable()
export class SomeService {
  a = 'hello';
}

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

@Component({
	// ...
  providers: [SomeService] // <-- вот так
})
export class DialogOverviewExample {
  // ...
}

Это значит, что на каждый экземпляр компонента будет создаваться свой экземпляр SomeService. Проверим, что им можно пользоваться:

export class DialogOverviewExample {
  // ...
  constructor(public dialog: MatDialog, public someService: SomeService) {
	  console.log(this.someService.a);
  }
  // ...
}

Работает. Ну впрочем, мы ничего сверъестественного и не сделали.

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

export class DialogOverviewExampleDialog {
  constructor(
    public dialogRef: MatDialogRef<DialogOverviewExampleDialog>,
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
    public someService: SomeService // <-- инжектим
  ) {
    console.log(this.someService.a); // <-- пользуемся
  }
}

Если вы хорошо знаете, как работает дерево DI, то уже поняли, какая ошибка будет выведена при открытии модального окна. Удостоверимся: нажмём на кнопку. Получим классический NullInjectorError. Модалка, понятное дело, не откроется. У некоторых вкатывающихся в Angular должны начаться флэшбэки.

Почему так? Почему в компоненте DialogOverviewExample мы можем пользоваться этим сервисом, а в компоненте DialogOverviewExampleDialog уже нет? Мы ведь его запровайдили в компоненте, и из него же вызвали модальное окно.

Давайте нарисуем дерево провайдеров.

Снимок экрана 2024-05-05 в 18.09.03.png

Вот так оно выглядит до открытия модального окна: MatDialog, как и положено, находится в инжекторе root. От него наследуется инжектор под названием DialogOverviewExample (наследование показано стрелочкой). Внутри него есть сам компонент DialogOverviewExample и тот самый несчастный SomeService.

Что произойдёт, когда мы откроем модальное окно? Смотрим:

Снимок экрана 2024-05-05 в 18.12.42.png

У нас добавился новый инжектор под названием DialogOverviewExampleDialog. Он уже относится к компоненту модалки. Стрелкой снова показано наследование, а пунктирной линией я показал, что этот инжектор был создан именно сервисом MatDialog.

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

Что делать? Есть несколько вариантов.

Вариант 1

Перенести SomeService в root. Решило бы проблему, но нельзя, потому что нам нужен не синглтон, а свой экземпляр на каждый экземпляр компонента. Ну вот по бизнесу так надо. Не подходит.

Вариант 2

Перенести MatDialog внутрь инжектора DialogOverviewExample. Тогда модальное окно унаследуется уже от него, и сервис будет доступен. Создать лишний экземпляр сервиса мы можем, так как мы там не храним никаких данных, нам от него нужна лишь функция.

Снимок экрана 2024-05-05 в 20.00.30.png

Это уже можно, и это решит проблему. Указываем MatDialog в списке провайдеров:

@Component({
  // ...
  providers: [SomeService, MatDialog] // <-- добавляем сюда
})
export class DialogOverviewExample { /* ... */ }

Открываем модалку и получаем… точно такой же NullInjectorError. У тех, кто обновлялся до 15 версии теперь тоже должны начаться флэшбэки. Потому что начиная с 15 версии MatDialog под капотом использует сервис Dialog, который также по умолчанию провайдится в root. А его-то мы не поднимали вверх по дереву, он там в корне висеть и остался. Добавим, мы не гордые:

@Component({
  // ...
  providers: [SomeService, MatDialog, Dialog] // <-- добавляем сюда
})
export class DialogOverviewExample {
  // ...
}

Открываем модалку и видим, что всё работает как положено.

Снимок экрана 2024-05-05 в 18.36.44.png

А что, если мы всё-таки гордые? Согласитесь, неприятно, когда обновляешь Angular до 15 версии, а у тебя перестали работать некоторые модалки, потому что теперь нужно поднимать на уровень компонента не один, а два провайдера. А если завтра их будет три? Нестабильно.

Вариант 3. Как правильно

Убираем MatDialog и Dialog из списка провайдеров, пользуемся теми, что лежат в root. Внутри корневого компонента инжектим Injector и передаём его при создании модалки:

@Component({
  // ...
  providers: [SomeService] // <-- ничего лишнего
})
export class DialogOverviewExample {
  constructor(
    public dialog: MatDialog, 
    public someService: SomeService, 
    public injector: Injector // <-- ижективно инжектим инжектор через инжекшен
  ) {
    console.log(someService.a);
  }

  openDialog(): void {
    const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
      data: { name: this.name, animal: this.animal }, 
      injector: this.injector // <-- и передаём его сюда
    });
	  // ...
  }
}

Вариант на все времена, работает как часы. Но чувствуете ли вы удовлетворение? Я нет. Мало того, что об этом не написано в документации, так я ещё должен вручную дополнительно доставать Injector и передавать его в специальный параметр. Это вообще не моя работа во-первых, во вторых у меня в классе теперь висит одно мутное лишнее поле, которого могло и не быть. Так сразу и говорите: чтобы пользоваться модалками, вам нужно всегда нужно доставать два провайдера.

Вариант 4. Как можно

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

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

И вправду, перед нами пример stateless-сервиса — MatDialog. Да, я знаю, что он не совсем stateless, но по крайней мере, его можно спокойно поделить на две части — stateful и stateless. Первая часть будет хранить список открытых модалок и так далее, а вторая открывать модалки и передавать их в список первой. Первая часть нас вообще редко интересует, нам бы просто модалку открыть. Давайте напишем обёртку для MatDialog, которая сама разодобудет тот самый Injector из предыдущего решения, и сама его передаст при открытии модалки.

Напишем снова функцию с использованием inject:

export function useMatDialog() {
  const injector = inject(Injector);
  const matDialog = inject(MatDialog);

  return {
    // сигнатуру я скопировал из деклараций MatDialog
    open<T, D = any, R = any>(template: ComponentType<T> | TemplateRef<T>, config?: MatDialogConfig<D>): MatDialogRef<T, R> {
      const newConfig: MatDialogConfig<D> = { injector, ...config };
      return matDialog.open(template, newConfig)
    }
  }
}

Здесь мы получаем всю необходимую для нормальной работы информацию: Injector и MatDialog. Возвращаем объект с единственной функцией, которая проксирует метод open в сервис, перед этим подставляя в него параметр injector, если другой не указали извне.

Как пользоваться:

@Component({
  // ...
  providers: [SomeService], // <-- здесь только SomeService
})
export class DialogOverviewExample {
  dialog = useMatDialog(); // <-- получаем обёртку MatDialog
  
  // ...

  openDialog(): void {
    const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
      // в параметры передаём только бизнесовую информацию:
      data: { name: this.name, animal: this.animal } 
    });
    // ...
  }
}

Вот теперь я удовлетворён: здесь нет ничего лишнего, и следить ни за чем не нужно, всё самое страшное инкапсулировано в useMatDialog. Когда-то давно это сохранило бы мне 10 часов разбирательств, почему не открывается модалка, и я мог бы потратить их на что-то более полезное, например резать воду.

В общем, так можно поступать с любым stateless-сервисом, и забыть хотя бы о проблеме получения актуальных провайдеров. Ссылка на StackBlitz.


У меня всё. Я ещё иногда пишу в телеграм-канал.

P.S. У. меня стойкое желание назвать эти функции хуками. Особенно из-за того, что я использую слово “use” как префикс, и это перекликается с React. Поэтому объявляю интерактив!

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


  1. nin-jin
    06.05.2024 07:25

    Для начала покажу, как в $mol происходит работа с сервисами вообще и параметрами урла в частности:

    id( next?: string ) {
      return this.$.$mol_state_arg.value( 'id', next )
    }

    Да, и это всё. Никаких токенов, параметров конструкторов, декораторов, инъекторов, хуков, возни со стримами, ручных подписок/отписок, и конечно никаких развесистых статей, как это правильно готовить.

    А теперь расскажу, как это по настоящему правильно готовить в Ангуляре (пишу по памяти, много лет не трогал эту поделку):

    class BaseObject {
      constructor( readonly $: Injector ) {}
    }
    
    class MyApp {
      get id() {
        return this.$.get( ActivatedRoute ).snapshot.queryParams.id ?? null
      }
      set id( id: string ) {
        this.$.get( Router ).navigate( ... )
      }
    }

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


    1. daemonyeen
      06.05.2024 07:25
      +1

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


      1. nin-jin
        06.05.2024 07:25

        Мемоизацию тоже добавьте по вкусу.


  1. muaddibco
    06.05.2024 07:25
    +1

    Относительно useQueryParam - предложенный подход имеет один очень серьезный недостаток называемый "скрытые зависимости". Поскольку использование функции useQueryParam скрыто, это сильно снижает читаемость кода и усложняет его тестируемость. С этой точки зрения использование сервиса более оправдано:

    @Injectable({ providedIn: 'root' })
    export class QueryParamService {
      constructor(private activatedRoute: ActivatedRoute, private router: Router) {}
    
      getQueryParam<T>(paramKey: string): QueryParam<T> {
        return new QueryParam<T>(paramKey, this.activatedRoute, this.router);
      }
    }


    1. Shirbak Автор
      06.05.2024 07:25

      использование функции useQueryParam скрыто

      Как же скрыто?

      @Component({ /* ... */ })
      export class SomeComponent {
        protected readonly id$: QueryParam<string> = useQueryParam('id');
        //                                           ~~~~~~~~~~~~~~~~~~~
        //                           вот она родимая, прямо в компоненте
      }

      Я открыл файл и вижу, что здесь будет идти речь о query-параметре id.

      Можем, в принципе, воспользоваться вашим решением:

      @Component({ /* ... */ })
      export class SomeComponent {
        protected readonly queryParamService = inject(QueryParamService);
        protected readonly id$: QueryParam<string> = 
          this.queryParamService.getQueryParam('id');
      }

      Если я правильно понял, под скрытыми зависимостями имеются в виду ActivatedRoute и Router . Ну в этом случае их здесь тоже нет — они скрыты в сервисе.

      Получается практически то же самое, разве что у нас появилось лишнее поле queryParamService. К тому же, этот сервис providedIn: root и получает не совсем тот экземпляр ActivatedRoute.

      А это важно, если мы будем работать с path-параметрами.


  1. Xuxicheta
    06.05.2024 07:25

    1. Это работает совершенно не так, как хуки в реакте, поэтому use тут только вводит в заблуждение людей, знакомых с реактом.

    2. Если писать какую-то обертку над инжектами, то и называться он должен с inject.

    3. С query params там не всё так просто, по факту вы создали Subject прибитый к роутеру, да, мы все делали подобные, иногда декораторами, иногда сервисом. Мне нравился вариант с отдельными провайдерами для каждого параметра, в котором зашито и его имя и тип и функция конвертации. Но там надо разруливать моменты с одновременными навигейтами и взаимозависимости, чуть сложнее будет.

      Опять таки, это не use. И команда ангуляра сейчас предлагает переводить это в инпуты, сомнительно, но окей.

    4. С диалогом, передача своего инжектора сделана для возможности подмешивания своих данных в контекст темплейта. Тут лучше для каждого диалогового окна сделать свой отдельный сервис, который умеет его открывать. Даст прибитую изоляцию и типизацию.


  1. visirok
    06.05.2024 07:25

    По статье чувствуется, что автор глубоко разбирается в теме. Поэтому рискну задать мой «детский» вопрос, внятного ответа на который я не нашёл ни на форумах ни у GitHub Copilot.

    В TypeScript можно делать статические переменные и на их базе Singletons и Factories. В каких случаях их недостаточно и необходимо применять Angular’s injection?