Синтаксис @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 не даёт ничего, кроме ошибок, а вопросы стиля решаются на уровне экосистемы, а не одного файла.

Частые ошибки при работе с декораторами и способы их избежать

Большинство сбоев вокруг декораторов предсказуемо и собирается в короткий чек-лист, который экономит часы отладки:

  1. Сервис без @Injectable или @Service падает с NullInjectorError при первом внедрении, поэтому помечать класс нужно до написания тела;
  2. Конфигурация @Component должна быть литералом, динамическая сборка объекта со спредами ломает статический анализ компилятора;
  3. Декораторы параметров допустимы только в конструкторе, попытка поставить @Inject над полем класса завершится ошибкой сборки;
  4. Алиас в записи @Input('ticket') задаёт публичное имя атрибута, а не поля, путаница этих имён ломает привязки в шаблонах;
  5. Обновление версии следует запускать командой ng update, встроенные миграции сами переводят код на умолчания standalone и новый синтаксис;
  6. Сторонние библиотеки с декораторами требуют того же флага experimentalDecorators, иначе сборка падает на этапе компиляции.

Декораторы в Angular не устарели и не сдают позиций: они сместились из рантайма в компилятор, передав часть ролей сигнальным функциям и inject(). Команда, которая понимает, где кончается скрытая механика и начинается генерация кода на этапе сборки, пишет быстрее и отлаживает меньше. Знак @ остаётся короткой рукой для целой цепочки решений компилятора, и за эту краткость разработчики ценят фреймворк уже почти десятилетие.