Angular: Dependency injection in decorator
На текущем моем месте работы мы широко используем декораторы. Декораторы содержат достаточно много логики и удобны в переиспользовании. Однако, чем больше логики содержится в них, тем больше зависимостей они имеют. Некоторые декораторы имеют чисто вспомогательную функцию, например, автоматическую отмену предыдущего obserable, возвращенного этой функцией, deboune, добавление метаданных для последующего получения их в рантайме и т.д. Обычно проблем с такими декораторами не возникает. Но как только в декораторе начинает присутствовать какая-то бизнес-логика, тут же возникают проблемы.
Возьмем пример из реального проекта: декоратор, который добавляет и инициализирует фильтры. Фильтры полностью хранятся в query параметрах, что позволяет гибко добавлять и настраивать фильтры прямо из декоратора класса. Это выглядит примерно так:
@WithFilters([
FilterType.DeviceFilter,
{
filter: FilterType.TrafficFilter,
settings: {
...DefaultTrafficFilterSettings,
value: TrafficFilterValues.all,
defaultValue: TrafficFilterValues.all,
}
},
FilterType.Category,
// ... some other filters
])
@Component({ /* ... */ })
class SomeComponent {
// ...
}
По понятным причинам, предоставить реальный код невозможно из-за ограничений. Однако, из данного описания видно суть декоратора. Он инициализирует фильтры и добавляет методы изменения или обновления фильтров, аналогичные хукам в Angular. Сложность данного декоратора заключается в том, что для его работы требуются зависимости из DI контейнера Angular. Изначально эти зависимости передавались через конструктор класса.
@WithFilters()
@Component()
class SomeComponent {
constructor(public filterService: FilterService) { }
}
Это крайне неудобно, поскольку при добавлении новой зависимости требовалось вносить изменения во все классы, декорированные этим декоратором. Кроме того, такой подход не соответствует принципам чистого кода.
После обновления до Angular 14 появилась замечательная функция inject()
.
Вот хорошая статья на эту тему.
Если коротко: функция inject()
позволяет получить зависимости в любом месте constructor phase,
без добавления этой зависимости как параметра конструктора.
Вот небольшой пример:
// without inject()
@Component()
class WithoutInjectComponent {
constructor(private service: Service) {}
}
// with inject()
@Component()
class WithInjectComponent {
private service = inject(Service);
}
Как видно из примера выше, теперь нет необходимости изменять конструктор для получения нужных зависимостей.
Однако стоит понимать, что нельзя вызывать inject(Service)
в любом месте, А только в функции конструкторе. В обоих примерах это как раз соблюдается.
Из скомпилированного кода видно, что вызов происходит именно в функции конструктора:
var WithoutInjectComponent = /** @class */ (function () {
function WithoutInjectComponent(service) {
this.service = service;
}
return WithoutInjectComponent;
}());
var WithInjectComponent = /** @class */ (function () {
function WithInjectComponent() {
this.service = inject(Service);
}
return WithInjectComponent;
}());
И вот, обратившись к официальной документации по class decorators, найдем вот такой код, который идеально подходит под нашу ситуацию:
function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
reportingURL = "http://www...";
};
}
Отрефакторим этот декоратор, что бы написать “Hello Word” с помощью Renderer2.
Для начала попробуем заинжектить Renderer2
.
import { inject, Renderer2 } from '@angular/core';
type Constructor = { new (...args: any[]): {} };
export function SayHello<T extends Constructor>(constructor: T) {
return class extends constructor {
renderer2 = inject(Renderer2);
};
}
И сразу же посмотрим, что получится, задекорировав компонент.
import { Component } from '@angular/core';
import { SayHello } from './say-hello.decorator';
@SayHello()
@Component({
selector: 'my-app',
template: '<!-- component is empty -->',
})
export class AppComponent {
ngOnInit(): void {
console.log(this);
}
}
В консоли видим свойство renderer2:
Всё работает отлично, однако возникает проблема в том что бы подвязаться на Angular hook. Если просто добавить метод в класс, то, к сожалению, ngOnInit не будет вызываться:
import { inject, Renderer2 } from '@angular/core';
type Constructor = { new (...args: any[]): {} };
export function SayHello<T extends Constructor>(constructor: T) {
return class extends constructor {
renderer2 = inject(Renderer2);
ngOnInit(): void {
console.log(this.renderer2);
}
};
}
Это связано с внутренним устройством декоратора @Component
. Однако, это можно решить просто переопределив метод в прототипе.
Хотя такой код может уже не выглядеть так изящно, тем не менее, его можно полностью типизировать.
Для этого создадим интерфейс, который будет описывать наши зависимости.
interface SayHelloTarget {
elementRef: ElementRef;
renderer2: Renderer2;
}
Далее просто переопределим метод в прототипе, реализуем интерфейс, и в функции ngOnInit
типизируем this
этим интерфейсом:
import { inject, ElementRef, Renderer2 } from '@angular/core';
type Constructor = { new (...args: any[]): {} };
interface SayHelloTarget {
elementRef: ElementRef;
renderer2: Renderer2;
}
export function SayHello<T extends Constructor>(constructor: T) {
function ngOnInit(this: SayHelloTarget) {
console.log(this);
}
Reflect.defineProperty(constructor.prototype, 'ngOnInit', {
value: ngOnInit,
});
return class extends constructor {
elementRef = inject(ElementRef);
renderer2 = inject(Renderer2);
};
}
Вот теперь после таких манипуляций Angular правильно будет вызывать ngOnInit
.
Осталось только добавить вызов оригинального метода ngOnInit
, если он есть, и реализовать какие-то действия с зависимостями.
Для примера я просто вставлю надпись “Hello, World” и изображение логотипа.
По итогу код будет выглядеть вот так:
export function SayHello(name: string) {
return function <T extends Constructor>(constructor: T) {
const origOnInit = Reflect.get(constructor.prototype, 'ngOnInit');
function ngOnInit(this: SayHelloTarget) {
console.log('@SayHello.ngOnInit()');
// .... other code
origOnInit?.();
}
Reflect.defineProperty(constructor.prototype, 'ngOnInit', {
value: ngOnInit,
});
return class extends constructor implements SayHelloTarget {
elementRef = inject(ElementRef);
renderer2 = inject(Renderer2);
};
};
}
Рабочий пример и код вы можете увидеть в этом stackblitz (Обратите внимание, что
template компонента пустой и добавление всей разметки происходит в SayHello
декораторе):