Синтаксис @Component() в Angular выглядит как аннотация, хотя на самом деле это вызов функции. Разработчик-новичок видит строку со знаком @ и читает её как украшение, а компилятор видит готовый вход в метапрограммирование: класс получает метаданные, провайдеров, шаблон и привязки ещё до запуска программы. За десять лет Angular выстроил вокруг декораторов всю архитектуру: описания компонентов, внедрение зависимостей, события узла документа, параметры конструктора. Разбор механики превращает набор непрозрачных конструкций в предсказуемый инструмент, который можно отлаживать, расширять и осознанно применять. Ниже разобраны устройство декораторов на уровне языка TypeScript, специфика компилятора Ivy, эволюция API в версиях вплоть до Angular 22 и ошибки, которые дорого обходятся командам.
Как устроен декоратор TypeScript на уровне языка
Декоратор в TypeScript это функция, которую вызывают в момент определения класса. Аргументы зависят от вида цели: конструктор класса при декорировании класса, дескриптор свойства при декорировании метода, имя параметра и его порядковый номер при декорировании аргумента конструктора. Минимальный случай без фреймворка занимает несколько строк:
function LogCreation(constructor: Function) {
console.log(`класс ${constructor.name} определён`);
}
@LogCreation
class OrderService {
save(): void {}
}
Запись @LogCreation без скобок применяет функцию напрямую. Запись со скобками задаёт фабрику декораторов: сначала исполняется вызов с аргументами, потом возвращённая функция применяется к цели. Вся мощь Angular построена на втором варианте:
function Retry(attempts: number) {
return function (target: Function) {
target.prototype.retryLimit = attempts;
};
}
@Retry(3)
class PaymentClient {}
Здесь @Retry(3) означает вызов Retry с числом 3 и применение результата к классу. Точно так же устроен @Component({...}): фигурные скобки с конфигурацией это аргумент фабрики.
В TypeScript сосуществуют два поколения синтаксиса. Экспериментальные декораторы появились первыми и включаются флагом в tsconfig:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2022"
}
}
Стандартные декораторы пришли в TypeScript 5.0 в марте 2023 года и работают без флагов, потому что повторяют финальную редакцию предложения TC39. В новом стандарте появились автополя с ключевым словом accessor и типизированный объект контекста, зато исчезли декораторы свойств и декораторы параметров конструктора. Экспериментальное поколение никуда не делось, и именно на нём держится Angular: без @Inject в конструкторе значительная часть фреймворкового кода перестаёт компилироваться.
Полный набор декораторов Angular от описания компонента до параметров конструктора
Angular делит свои декораторы на три этажа. Классы помечаются @Component, @Directive, @Pipe, @NgModule и @Injectable. Члены класса помечаются @Input, @Output, @HostBinding и @HostListener. Параметры конструктора принимают @Inject, @Optional, @Self, @SkipSelf, @Host и @Attribute. Пример собирает этажи вместе:
import {Component, Input, Output, EventEmitter, HostListener, HostBinding, ChangeDetectionStrategy} from '@angular/core';
@Component({
selector: 'app-ticket-card',
templateUrl: './ticket-card.html',
styleUrl: './ticket-card.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TicketCard {
@Input('ticket') item: Ticket | undefined;
@Output() selected = new EventEmitter<Ticket>();
@HostBinding('class.is-open') isOpen = false;
@HostListener('click', ['$event'])
onClick(event: MouseEvent): void {
this.selected.emit(this.item);
}
}
Каждый элемент конфигурации читается компилятором: селектор попадает в статическое определение, templateUrl подставляет код разметки, инкапсуляция стилей ограничивает распространение классов. Обязательны только selector и хотя бы один вариант разметки.
С версии Angular 19 компонент считается standalone по умолчанию, то есть импортируемым без NgModule, а явный флаг standalone: false требуется лишь сущностям, которые остаются в модулях. Та же версия подняла нижнюю планку TypeScript до 5.5. В Angular 22 линейка декораторов класса пополнилась @Service: это сокращённая запись для @Injectable({providedIn: 'root'}) с неявным одиночным провайдером в корневом инжекторе, опцией factory и без расширенных ключей вроде useClass. Заодно OnPush стал стратегией отслеживания изменений по умолчанию, что снижает число лишних перерисовок без ручной настройки.
Компиляция Ivy превращает метаданные в статические поля класса
Компилятор Angular работает иначе, чем рантайм-рефлексия. Во время сборки он читает декораторы из исходного кода и порождает статические поля: ɵcmp для компонента, ɵdir для директивы, ɵpipe для пайпа, ɵprov для сервиса и ɵfac для фабрики создания. Схематичный результат выглядит так:
export class TicketCard {
static ɵcmp = defineComponent({
type: TicketCard,
selectors: [['app-ticket-card']],
inputs: {item: 'ticket'},
outputs: {selected: 'selected'},
decls: 4,
vars: 2,
template: function TicketCard_Template(rf, ctx) {
if (rf & 1) {
// инструкции создания и обновления узлов
}
},
styles: [_ticketCardCss]
});
}
Подход решает сразу три задачи. Сборка не тратит время браузера на чтение метаданных, дерево кода очищается от неиспользуемых провайдеров, а привязки остаются проверенными на типах. Отсюда растёт и требование статической анализируемости: конфигурация декоратора обязана быть литералом, который компилятор прочитает без запуска кода. Попытка собрать объект конфигурации в переменную и подставить его спредом ломает сборку с диагностикой о значении, которое невозможно определить статически.
Другие фреймворки поступают иначе. NestJS опирается на флаг emitDecoratorMetadata и библиотеку reflect-metadata, чтобы в рантайме читать типы параметров конструктора из специального поля. Angular в такой подпорке не нуждается: список токенов внедрения зашит в фабрику ɵfac на этапе сборки, и браузер получает уже готовый план создания объектов.
Внедрение зависимостей через @Injectable @Inject и модификаторы области видимости
Типовой сервис в Angular выглядит скромно: декоратор, класс и пара полей через inject():
import {Injectable, inject} from '@angular/core';
import {HttpClient} from '@angular/common/http';
@Injectable({providedIn: 'root'})
export class RatesApi {
private readonly http = inject(HttpClient);
load(base: string) {
return this.http.get<Rates>(`/api/rates?base=${base}`);
}
}
Провайдер providedIn: 'root' регистрирует класс лениво: пока сервис ни разу не внедрён, он не попадает в сборку. Декорированный класс получает поле ɵprov, и дерево кода видит точную информацию о том, что нужно приложению. Появившийся в Angular 22 декоратор @Service закрепляет паттерн в одну строку: он всегда даёт одиночный провайдер в корне и не требует конфигурации для типового случая.
Декораторы параметров конструктора решают тонкие задачи разрешения зависимостей:
constructor(
@Inject(API_BASE_URL) private readonly baseUrl: string,
@Optional() @Inject(BackupLogger) private readonly backup: BackupLogger | null,
) {}
Декоратор @Inject подставляет токен вместо типа, что незаменимо для интерфейсов и примитивов. @Optional разрешает отсутствие зависимости и отдаёт null вместо ошибки. @Self запрещает подъём по дереву инжекторов, @SkipSelf пропускает текущий инжектор и начинает поиск с родительского, @Host ограничивает поиск границей текущего шаблона. Функция inject() закрывает те же сценарии опциями: inject(TOKEN, {optional: true}) или {skipSelf: true}, поэтому конструкторы в новом коде постепенно худеют, а список зависимостей переезжает в инициализаторы полей.
Отдельного упоминания заслуживает @Attribute. Он читает статическую строку атрибута один раз при создании элемента:
constructor(@Attribute('layout') public layout: string) {}
Разница с обычным входом принципиальна: @Input() реагирует на изменения атрибута, @Attribute фиксирует значение при создании и не подписывается на обновления. Для неизменяемых настроек вроде названия раскладки это ровно то, что нужно.
Сигнальные функции input и output вытесняют декораторы свойств
Современный Angular рекомендует описывать входы функциями:
import {Component, input, output, computed} from '@angular/core';
@Component({selector: 'app-progress', template: '{{ratio()}}'})
export class Progress {
readonly total = input.required<number>();
readonly done = input(0);
readonly changed = output<number>();
readonly ratio = computed(() => this.done() / this.total());
}
Функция input() появилась в Angular 17.1 и возвращает InputSignal: значение читается вызовом, а не обращением к полю. Вариант input.required<number>() превращает отсутствие атрибута в ошибку сборки, input(0) задаёт значение по умолчанию, input<number>() оставляет undefined. Сигналы сочетаются между собой: computed пересчитывает производную величину только после изменения опорных значений, эффекты связывают входы с побочными действиями. Рядом живут output() для событий вместо @Output() и model() для двусторонней привязки в синтаксисе с квадратными скобками и бананом.
Классический декоратор @Input() при этом не ушёл на пенсию. Он принимает конфигурацию с алиасом, признаком обязательности и функцией преобразования вроде booleanAttribute, а также поддерживает геттеры и сеттеры. Механика едина для обоих стилей: компилятор записывает входы статически, имена чувствительны к регистру, входы наследуются при расширении класса. Ограничение тоже общее: вызывать input() разрешено только в инициализаторах полей компонента или директивы.
Наследие экспериментальных декораторов и границы нового стандарта
Новая редакция TC39 сознательно урезана: в ней есть декораторы класса, метода, геттера и автополя, но нет декораторов свойств и параметров конструктора. Angular опирается и на те и на другие, поэтому флаг experimentalDecorators в конфигурации проекта остаётся обязательным, а смешение стилей приводит к диагностике компилятора. Для разработчика библиотек расхождение означает выбор: код на экспериментальной семантике живёт в экосистеме Angular и NestJS, код на стандартных декораторах переносим между сборщиками без флагов.
Порядок применения различается между поколениями, поэтому бездумный перенос цепочки декораторов из одного стиля в другой меняет поведение кода. Команда Angular держит совместимость через собственный компилятор: он читает экспериментальные декораторы и генерирует платформенный код самостоятельно, независимо от трансформаций TypeScript. Именно поэтому переключение флага внутри рабочего проекта Angular не даёт ничего, кроме ошибок, а вопросы стиля решаются на уровне экосистемы, а не одного файла.
Частые ошибки при работе с декораторами и способы их избежать
Большинство сбоев вокруг декораторов предсказуемо и собирается в короткий чек-лист, который экономит часы отладки:
- Сервис без @Injectable или @Service падает с NullInjectorError при первом внедрении, поэтому помечать класс нужно до написания тела;
- Конфигурация @Component должна быть литералом, динамическая сборка объекта со спредами ломает статический анализ компилятора;
- Декораторы параметров допустимы только в конструкторе, попытка поставить @Inject над полем класса завершится ошибкой сборки;
- Алиас в записи @Input('ticket') задаёт публичное имя атрибута, а не поля, путаница этих имён ломает привязки в шаблонах;
- Обновление версии следует запускать командой ng update, встроенные миграции сами переводят код на умолчания standalone и новый синтаксис;
- Сторонние библиотеки с декораторами требуют того же флага experimentalDecorators, иначе сборка падает на этапе компиляции.
Декораторы в Angular не устарели и не сдают позиций: они сместились из рантайма в компилятор, передав часть ролей сигнальным функциям и inject(). Команда, которая понимает, где кончается скрытая механика и начинается генерация кода на этапе сборки, пишет быстрее и отлаживает меньше. Знак @ остаётся короткой рукой для целой цепочки решений компилятора, и за эту краткость разработчики ценят фреймворк уже почти десятилетие.