Магия Injection Context

от автора

В последних версиях Angular появилась функция inject(), предназначенная для внедрения зависимостей. Эта функция может быть вызвана только в рамках Injection Context.

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

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

В чем сила Injection Context

Поскольку функция inject() может быть вызвана на любом уровне вложенности внутри Injection Context, мы можем создавать разнообразные функции-утилиты. Они будут содержать определенную логику, избавляя нас от необходимости описывать ее каждый раз в компонентах.

В качестве простого примера можно привести функцию takeUntilDestroyed, которая требует наличия DestroyRef. Но если вы не передадите этот параметр, функция автоматически получит его из текущего Injection Context:

export function takeUntilDestroyed<T>(destroyRef?: DestroyRef):  MonoTypeOperatorFunction<T> {   if (!destroyRef) {     assertInInjectionContext(takeUntilDestroyed); destroyRef = inject(DestroyRef);   }    const destroyed$ = new Observable<void>((observer) => { const unregisterFn = destroyRef!.onDestroy(observer.next.bind(observer)); return unregisterFn;   });    return <T>(source: Observable<T>) => { return source.pipe(takeUntil(destroyed$));   }; }  // Использование  @Component(/*...*/) class MyComponent {   source$ = new Subject();    constructor() { this.source$    .pipe(takeUntilDestroyed())    .subscribe(() => console.log('hello world'));   } }

Посмотрим, как можно применять Injection Context для решения более сложных задач.

injectStorage

Иногда нам требуется сохранять данные в localStorage и следить за их изменениями, особенно когда пользователь работает с несколькими вкладками. Можно упростить решение этой задачи с помощью Injection Context.

Для начала создадим токен, который будет содержать нужный нам Storage:

export const STORAGE_IMPL = new InjectionToken<Storage>('STORAGE_IMPL', {   factory: () => localStorage, });  export function provideStorageImpl(storage: Storage): Provider {   return { provide: STORAGE_IMPL, useValue: storage,   }; }

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

type DefaultValue<T> = {   defaultValue: T; }  type Options<T> = Partial<DefaultValue<T>>;  export function injectStorage(key: string): WritableSignal<string | null>; export function injectStorage(key: string, options: DefaultValue<string>): WritableSignal<string>; export function injectStorage(key: string, { defaultValue }: Options<string> = {}): WritableSignal<string | null> {   assertInInjectionContext(injectStorage);   const storage = inject(STORAGE_IMPL);    return signal(storage.getItem(key) ?? defaultValue ?? null); }  // Использование  @Component(/*...*/) class MyComponent {   foo: WritableSignal<string | null> = injectStorage('foo');   bar: WritableSignal<string> = injectStorage('bar', {  defaultValue: '',   }); }

Теперь мы можем получать сигнал, содержащий значение из Storage. Добавим возможность изменять это значение из приложения:

export function injectStorage(key: string): WritableSignal<string | null>; export function injectStorage(key: string, options: DefaultValue<string>): WritableSignal<string>; export function injectStorage(key: string, { defaultValue }: Options<string> = {}): WritableSignal<string | null> {   assertInInjectionContext(injectStorage);   const storage = inject(STORAGE_IMPL);    const value = signal(storage.getItem(key) ?? defaultValue ?? null);    effect(() => { const newValue = value();  if (newValue === null) {    storage.removeItem(key); } else {       storage.setItem(key, newValue); }   });    return value; } 

Еще можно добавить возможность синхронизировать состояние между компонентами или другими вкладками. Кроме того, нам бы хотелось иметь возможность работать со сложными типами данных, такими как JSON:

Утилита injectStorage работает с localStorage при помощи Injection Context. Она упрощает доступ и изменение данных в хранилище, а также синхронизирует их между компонентами и вкладками браузера.

Утилита поддерживает сложные типы данных, что делает ее применимой в самых разных ситуациях. Также образом можно отслеживать document.visibilityState, isIntersecting из IntersectionObserver и многое другое.

dialog

В проектах мы активно применяем библиотеку Taiga UI, в частности ее диалоги. Мы часто сталкиваемся с трудностями, связанными с определением типа входных данных и типа результата диалога. К сожалению, DialogService не выполняет проверку этих типов данных, поэтому их необходимо указывать вручную. Это может вызвать трудности, если мы захотим изменить тип данных в диалоге, поскольку мы не сможем увидеть ошибки в других частях системы.

Создадим простой тестовый диалог, который будет принимать number в качестве входных данных и возвращать boolean в качестве результата:

@Component({   standalone: true,   template: ` context value: {{ context.data.toFixed(2) }}  <button (click)="context.completeWith(false)">Cancel</button> <button (click)="context.completeWith(true)">OK</button>   `, }) class TestDialogComponent {   public readonly context = inject(POLYMORPHEUS_CONTEXT) as TuiDialogContext<boolean, number>; }

Попробуем открыть этот диалог, используя корректные данные:

const dialogService = inject(TuiDialogService);  dialogService   .open<boolean>(new PolymorpheusComponent(TestDialogComponent), { data: 123,   })   .subscribe(result => { // result это boolean, потому что это указано в generic-параметре функции open   });

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

const dialogService = inject(TuiDialogService);  dialogService   .open<string>(new PolymorpheusComponent(TestDialogComponent), { data: `123`,   })   .subscribe(result => { console.log(result.startsWith(`test`));   });

Мы можем создать утилиту dialog, чтобы решить проблему, когда ошибка не появляется во время компляции. Утилита будет возвращать функцию, принимающую в качестве аргумента входящие данные диалога и возвращающую Observable с типом результата.

Сначала давайте выясним, как определить эти типы, зная только класс компонента. Для этого мы можем преобразовать все значения компонента в union и выбрать из них те, что соответствуют TuiDialogContext:

type ExtractDialogData<T> = T[keyof T] extends TuiDialogContext<any, infer D> ? D : never; type ExtractDialogResult<T> = T[keyof T] extends TuiDialogContext<infer R, any> ? R : void;

Теперь можно сделать саму утилиту:

function dialog<T>(  component: new () => T, ): (data: ExtractDialogData<T>) => Observable<ExtractDialogResult<T>> {   const dialogService = inject(TuiDialogService);    return data => dialogService.open(new PolymorpheusComponent(component), { data }); }  const testDialog = dialog(TestDialogComponent);  testDialog(123).subscribe(result => {   console.log(result.startsWith(`test`)); }); 

Теперь у нас есть строгая проверка типов, более простой API и нам не нужно внедрять в компонент TuiDialogService. Конечно, есть еще возможности для улучшения, например можно добавить возможность передавать другие параметры в функцию, такие как размер диалога, возможность его закрыть и так далее.

Декоратор @log

Нам часто приходится логировать вызовы определенных методов. С появлением новых ECMA-декораторов появилась возможность работать с Injection Context прямо внутри них. Рассмотрим пример:

function log<T, V extends (this: T, ...args: any[]) => any>(   method: V,   context: ClassMethodDecoratorContext<T, V>, ): (...args: any[]) => any {   if (context.static) {     throw new Error('@log decorator cannot be applied for static methods');   }    let loggerService: LoggerService;    context.addInitializer(() => {     // Этот код выполняется в конструкторе, то есть в Injection Context     loggerService = inject(LoggerService);   });    return function (this: T, ...args: any[]): any { const result = method.apply(this, args);  loggerService.log(    `Method ${String(context.name)} was called with ${args.join(`, `)}, and returns ${result}`, );  return result;  }; }  @Component(/*...*/) class MyComponent {   @log   someMethod(value: string): boolean { return value === `foo`;   } }

Здесь мы создаем ECMA-декоратор @log, который:

  • может быть применен только к методам экземпляра класса;

  • получает зависимость LoggerService, добавляя инициализатор в конструктор класса;

  • вызывает оригинальный метод, логирует его вызов и результат выполнения и возвращает этот результат.

Мы можем инжектить зависимости в наших декораторах. В этом примере нам нужен LoggerService для отправки логов.

Декоратор @bindInjectionContext

Рассмотрим еще один пример с @bindInjectionContext. Иногда нам нужно иметь доступ к Injection Context внутри методов для создания эффекта или чего-либо еще.

Используя подход с внедрением зависимостей при инициализации класса, мы можем создать декоратор, который будет привязывать текущий контекст внедрения к любому методу:

function bindInjectionContext<T, V extends (this: T, ...args: any[]) => any>(   method: V,   context: ClassMethodDecoratorContext<T, V>, ): (...args: any[]) => any {   if (context.static) { throw new Error('@bindInjectionContext decorator cannot be applied for static methods');   }    let injector: Injector;    context.addInitializer(() => { injector = inject(Injector);   });    return function (this: T, ...args: any[]): any { return runInInjectionContext(injector, () => method.apply(this, args));   }; }  @Component(/* ...*/) class MyComponent {   @bindInjectionContext   someMethod(): void { effect(() => {    // всё работает });   } }

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

Допустим, у нас есть компонент, который использует injectStorage для работы с localStorage. Мы хотим протестировать его поведение.

import { Component } from '@angular/core'; import { injectStorage } from './injectStorage'; // Путь путь к вашей утилите  @Component({   selector: 'app-storage-component',   template: `<div>{{foo()}}</div>`,   standalone: true, }) export class StorageComponent {   foo = injectStorage('foo'); // Сигнал, получаемый через injectStorage }

Для теста этого компонента необязательно знать, какие зависимости внедряет утилита injectStorage. Вместо этого можно замокать саму утилиту:

import { ComponentFixture, TestBed } from '@angular/core/testing'; import { StorageComponent } from './storage.component'; import { injectStorage } from './injectStorage'; import { signal } from '@angular/core';  jest.mock('./injectStorage'); // Мокаем injectStorage  describe('StorageComponent', () => {   let component: StorageComponent;   let fixture: ComponentFixture<StorageComponent>;    beforeEach(() => { // Мокаем возвращаемое значение injectStorage jest.mocked(injectStorage).mockReturnValue(signal('mocked value'));  fixture = TestBed.createComponent(StorageComponent); component = fixture.componentInstance; fixture.detectChanges();   });    it('should display the value from injectStorage', () => { const compiled = fixture.nativeElement; expect(compiled.querySelector('div').textContent).toContain('mocked value');   }); });

Заключение

Injection Context — мощный инструмент в Angular, который открывает новые горизонты для разработки более чистого и эффективного кода. Благодаря возможности вызывать функцию inject() на любом уровне вложенности внутри Injection Context разработчики могут:

  • создавать разнообразные утилиты и декораторы, уменьшая дублирование кода и повышая его переиспользуемость;

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

Продолжая изучать и применять этот инструмент, можно значительно улучшить архитектуру приложения и ускорить процесс разработки.


ссылка на оригинал статьи https://habr.com/ru/articles/861172/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *