В последних версиях 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/
Добавить комментарий