Магия Injection Context

4cbb69e0f9564c6d6adf445659d41b20.jpg

В последних версиях 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 разработчики могут:

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

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

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

© Habrahabr.ru