Магия Injection Context
В последних версиях Angular появилась функция inject()
, предназначенная для внедрения зависимостей. Эта функция может быть вызвана только в рамках Injection Context.
Несмотря на то, что с момента появления этой технологии прошло уже много времени, многие разработчики все еще не полностью раскрыли ее потенциал. Возможно, это связано с тем, что они привыкли к традиционным методам внедрения зависимостей и не спешат переходить на новые подходы, или с недостатком подробной документации и практических примеров использования функции inject()
.
Я обнаружил, как inject()
может существенно упростить код и сделать его более гибким, и хочу поделиться своими находками, а может, и помочь другим разработчикам более полно раскрыть потенциал этой функции.
В чем сила Injection Context
Поскольку функция inject()
может быть вызвана на любом уровне вложенности внутри Injection Context, мы можем создавать разнообразные функции-утилиты. Они будут содержать определенную логику, избавляя нас от необходимости описывать ее каждый раз в компонентах.
В качестве простого примера можно привести функцию takeUntilDestroyed
, которая требует наличия DestroyRef
. Но если вы не передадите этот параметр, функция автоматически получит его из текущего Injection Context:
export function takeUntilDestroyed(destroyRef?: DestroyRef): MonoTypeOperatorFunction {
if (!destroyRef) {
assertInInjectionContext(takeUntilDestroyed);
destroyRef = inject(DestroyRef);
}
const destroyed$ = new Observable((observer) => {
const unregisterFn = destroyRef!.onDestroy(observer.next.bind(observer));
return unregisterFn;
});
return (source: Observable) => {
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_IMPL', {
factory: () => localStorage,
});
export function provideStorageImpl(storage: Storage): Provider {
return {
provide: STORAGE_IMPL,
useValue: storage,
};
}
Теперь у нас есть возможность выбрать хранилище, которое мы хотим использовать. Создадим утилиту, способную считывать данные из выбранного хранилища:
type DefaultValue = {
defaultValue: T;
}
type Options = Partial>;
export function injectStorage(key: string): WritableSignal;
export function injectStorage(key: string, options: DefaultValue): WritableSignal;
export function injectStorage(key: string, { defaultValue }: Options = {}): WritableSignal {
assertInInjectionContext(injectStorage);
const storage = inject(STORAGE_IMPL);
return signal(storage.getItem(key) ?? defaultValue ?? null);
}
// Использование
@Component(/*...*/)
class MyComponent {
foo: WritableSignal = injectStorage('foo');
bar: WritableSignal = injectStorage('bar', {
defaultValue: '',
});
}
Теперь мы можем получать сигнал, содержащий значение из Storage
. Добавим возможность изменять это значение из приложения:
export function injectStorage(key: string): WritableSignal;
export function injectStorage(key: string, options: DefaultValue): WritableSignal;
export function injectStorage(key: string, { defaultValue }: Options = {}): WritableSignal {
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) }}
`,
})
class TestDialogComponent {
public readonly context = inject(POLYMORPHEUS_CONTEXT) as TuiDialogContext;
}
Попробуем открыть этот диалог, используя корректные данные:
const dialogService = inject(TuiDialogService);
dialogService
.open(new PolymorpheusComponent(TestDialogComponent), {
data: 123,
})
.subscribe(result => {
// result это boolean, потому что это указано в generic-параметре функции open
});
Если мы укажем другие типы, ошибка не будет обнаружена сразу, а проявится только во время работы приложения:
const dialogService = inject(TuiDialogService);
dialogService
.open(new PolymorpheusComponent(TestDialogComponent), {
data: `123`,
})
.subscribe(result => {
console.log(result.startsWith(`test`));
});
Мы можем создать утилиту dialog
, чтобы решить проблему, когда ошибка не появляется во время компляции. Утилита будет возвращать функцию, принимающую в качестве аргумента входящие данные диалога и возвращающую Observable
с типом результата.
Сначала давайте выясним, как определить эти типы, зная только класс компонента. Для этого мы можем преобразовать все значения компонента в union и выбрать из них те, что соответствуют TuiDialogContext
:
type ExtractDialogData = T[keyof T] extends TuiDialogContext ? D : never;
type ExtractDialogResult = T[keyof T] extends TuiDialogContext ? R : void;
Теперь можно сделать саму утилиту:
function dialog(
component: new () => T,
): (data: ExtractDialogData) => Observable> {
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 any>(
method: V,
context: ClassMethodDecoratorContext,
): (...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 any>(
method: V,
context: ClassMethodDecoratorContext,
): (...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: `{{foo()}}`,
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;
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 разработчики могут:
создавать разнообразные утилиты и декораторы, уменьшая дублирование кода и повышая его переиспользуемость;
создавать более абстрактные и гибкие решения, уменьшая связанность компонентов и облегчая их тестирование.
Продолжая изучать и применять этот инструмент, можно значительно улучшить архитектуру приложения и ускорить процесс разработки.