Оновлений Angular Code Style: оглядаємо сигнали, standalone-компоненти та нові можливості

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

В Angular останнім часом з’явилося багато нового: від сигналів до оновленого стилю для шаблонів. Тож я подумав: можливо, настав час поділитися оновленим підходом до code style?

⚙️ Standalone

З випуском 19-ї версії всі компоненти, які ви створюєте, за замовчуванням стають standalone.

Якщо у вас версія нижче 19, я рекомендую створювати виключно standalone-компоненти, уникаючи використання модулів.

// Standalone-компонент для версій Angular < 19
@Component({
  standalone: true,
  selector: 'my-component',
  template: `Hello World!`
})

// Standalone-компонент у Angular 19
@Component({
	// прапор standalone більше не потрібен
  selector: 'my-component',
  template: `Hello World!`
})

Порада! У Angular 19 з’явився прапор компілятора, який видасть помилку, якщо виявить компонент, директиву або пайп, що не є standalone.

{
  "angularCompilerOptions": {
    "strictStandalone": true
  }
}

⚙️ Signals

Ну тут все зрозуміло: стараємось більше використовувати сигнали, computed, а також input() та output().

@Component({
  selector: 'my-example',
  template: `
    <h1>{{ counter() }}</h1>
    <h2>{{ isEven() ? 'Even' : 'Odd' }}</h2>
    <span>{{ name }}</span>
    <button (click)="increment()">Increment</button>
  `,
})
export class ExampleComponent {
  // Для отримання значень зовні
  readonly name = input<string>();

  // Для відправки на зовні
  readonly clickBtn = output();

  // Використовуємо signal для збереження значень
  readonly counter = signal(0);

  // Використовуємо computed для обчислення парності числа
  readonly isEven = computed(() => this.counter() % 2 === 0);

  // Збільшення значення сигналу
  increment(): void {
    this.counter.update(value => value + 1);
    this.clickBtn.emit()
  }
}

Ви ж розумієте, що команда фреймворку рухається в бік відмови від Zone.js, тому що більше будете використовувати сигнали, то менше потрібно буде рефакторити код у майбутньому.

⚙️ Нова ера Lifecycle

Попрощайтеся з ngOnInit та ngAfterViewInit і оберіть спрощений підхід!

Ось базовий приклад, як було раніше з хуком ngOnChanges:

@Component({
  standalone: true,
  selector: 'my-is-even',
  template: `<h1>Is Even: {{ isEven }}</h1>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class IsEvenComponent implements OnChanges {
  isEven: boolean | undefined;
  @Input({ required: true }) counter!: number;

  ngOnChanges(changes: SimpleChanges): void {
      if (changes['counter']) {
        this.isEven = changes['counter'].currentValue % 2 === 0;
      }
    }
}

Тепер — як це написати по-новому:

@Component({
  selector: 'my-is-even',
  template: `<h1>Is Even: {{ isEven() }}</h1>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class IsEvenComponent {
  counter = input.required<number>();
  isEven = computed(() => this.counter() % 2 === 0);
}

⚙️ Inject

Раніше для того, щоб заюзати наш DI, потрібно було в конструкторі прописувати залежності. Тепер у нас є новий метод inject!

// Раніше
constructor(private userService: UserService) {}

// Зараз
private readonly userService = inject(UserService)

// Дістати тільки одне проперті
private readonly user = inject(UserService).user

//У методі
readonly user = this.getUser();

getUser(): User {
  return inject(UserService).user;
}

Що він дає:

  • inject зберігає правильний тип при використанні токенів;
  • inject можна використовувати в інших функціях, що дає змогу створювати композиційні функції;
  • при спадкуванні немає потреби передавати впроваджені сервіси до батьківського класу через виклик super в конструкторі.

Ін’єкція через конструктор залежить від прапора useDefineForClassFields, який потрібно явно встановити в значення false.

⚙️ Constructor

Окей, ми зрозуміли, що конструктор більше не є необхідним для inject. Але що ж робити з хуками afterRender та afterNextRender? А як щодо ефектів? Адже в документації зазначено, що їх потрібно інітити саме в конструкторі!?

@Component({ /* ... */ })
export class ExampleComponent {

  private readonly logProductChangesEffect = effect(() => { ...});

  private readonly afterNextRenderRef = afterRender(() => { ... });

  private readonly afterNextRender = afterNextRender(() => {...});

}

Бам! Такий підхід робить ваш код чистішим і організованішим.

⚙️ RxJS

А що робити з кодом, який написаний на стрімах?

Просто конвертуємо його в сигнали!

@Component({
  selector: 'app-example',
  template: `
    @if(pageData()) { 
      {{ pageData() }} 
    }@else { 
      'Empty data' 
    }
    <button (click)="nextPage()">Next Page</button>
  `,
})
export class ExampleComponent {
  // Створюємо BehaviorSubject як джерело даних
  private readonly page$ = new BehaviorSubject<number>(1);

  private readonly http = inject(HttpClient);

  // Приклад потоку даних
  private readonly data$ = of({ some: 'data' }); 

  // Перетворюємо BehaviorSubject і купу логіки в сигнал
  readonly pageData = toSignal(
    this.page$.pipe(
      filter(Boolean),
      withLatestFrom(this.data$),
      filter(([_, data]) => !!data),
      map(([page, data]) => ({ page, data })),
      switchMap((params) =>
        this.http.get('/api/data', { params }).pipe(
          catchError((err) => {
            console.error('Error fetching data:', err);
            return of(null);
          })
        )
      )
    )
  );

  nextPage(): void {
    const currentPage = this.page$?.getValue() || 0;
    this.page$.next(currentPage + 1);
  }
}

⚙️ Шаблони

Раніше ми використовували директиви на кшталт ngIf, ngFor, ng-template для роботи з умовними виразами та рендерингом шаблонів.

ngIf

// Раніше
<div *ngIf="isVisible; else placeholder">Content is visible</div>
<ng-template #placeholder>
  Placeholder
</ng-template>

// Зараз
@if(isVisible) {
  Content is visible
} @else {
  Placeholder
}

ngFor

// Раніше
<div *ngFor="let item of items">
  <p>{{ item }}</p>
</div>

// Зараз 
@for (item of items; track item.name) {
  <li>{{ item.name }}</li>
} @empty {
  <li>There are no items.</li>
}

let

// Раніше
<div *ngIf="user$ | async as user">
  <h1>Hello, {{user.name}}</h1>
  <user-avatar [photo]="user.photo"/>
  <ul>
    <li *ngFor="let snack of user.favoriteSnacks; trackBy: trackById">
      {{snack.name}}
    </li>
  </ul>
  <button (click)="update(user)">Update profile</button>
</div>

// Зараз
@let user = user$ | async;

@if(user) {
  <h1>Hello, {{user.name}}</h1>
  <user-avatar [photo]="user.photo"/>
  <ul>
    @for (snack of user.favoriteSnacks; track snack.id) {
      <li>{{snack.name}}</li>
    }
  </ul>
  <button (click)="update(user)">Update profile</button>
}

defer

@Component({
  selector: 'my-example',
  template: `
    <h1>Some Title</h1>
    <div>Some description for many symbols</div>

    <!-- Відкладений рендеринг компонента -->
    @defer {
      <large-component></large-component>
    }
  `,
})
export class ExampleComponent {}

⚙️ Зона відповідальності

Кожен компонент або сервіс повинен бути визначений у окремому файлі, щоб полегшити читання та обслуговування.

Файли повинні містити не більше 400 рядків коду для читабельності та уникнення складності.

Функції повинні бути невеликими й зазвичай не перевищувати 75 рядків.

⚙️ Неймінг

Class name. Назви класів повинні бути написані в стилі upper camel case (наприклад, MyComponent, UserService). Назва класу, компонента або сервісу повинна збігатися з назвою файлу, щоб полегшити навігацію проєктом (наприклад, клас UserService має бути в файлі user.service.ts).

File names. Для файлів потрібно використовувати прийняті суфікси, як-от: *.component.ts, *.directive.ts, *.module.ts, *.pipe.ts, *.service.ts тощо.

Component selectors. Використовуйте «dashed-case» або «kebab-case» для іменування селекторів компонентів.

Directives selectors. Використовуйте «lower camel case» для іменування селекторів директив.

Constants. Імена констант у форматі lower camel case (heroRoutes) легші для читання та розуміння порівняно з традиційними іменами в форматі UPPER_SNAKE_CASE (HERO_ROUTES).

Output. Не додавайте префікс до властивостей output. Називайте події без префікса «on».

⚙️ Послідовність і делегування

Розміщуйте властивості на початку, а потім методи. Розміщуйте приватні властивості після публічних, в алфавітному порядку.

Обмежте логіку в компоненті лише тією, що потрібна для відображення. Вся інша логіка має бути передана сервісам.

Залишайте компоненти простими та зосередженими на їх основному призначенні.

⚙️ Інше

Коли підписуєтесь на стріми, завжди переконуйтесь, що відписуєтесь від них належним чином, використовуючи оператори як-от take, takeUntil тощо.

Уникайте підписки всередині підписок.

Якщо можна виконувати їх усі паралельно, використовуйте forkJoin, або switchMap.

Уникайте використання типу any.

Використовуйте Lint з Husky для автоматичної перевірки коду перед кожним комітом.

Висновок

Просто дотримуйтесь цих порад, і буде вам щастя! 😄

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Ух, жесть, останній раз писав на ангулярі в 2021, наскільки ж все змінюється на краще — хоча мені і тоді ангуляр більше за реакт подобався :)
А поясніть, будь ласка, щодо standalone компонентів. Я спочатку думав, коли їх зарелізили, що це така собі альтернатива всяким shared модулям, а тепер, виходить, до побачення модулі взагалі як концепт? Шкода, якщо чесно.

Standalone-компоненти дійсно дозволяють скіпнути необхідность огортати все в модулі, що робить архітектуру простішою та гнучкішою. Це не стільки альтернатива shared-модулям, скільки крок у бік більш декларативного підходу.

Дякую за статтю! Проте в документації Angular вказано, що в ngAfterContentInit можемо референсити content queries, а вже в ngAfterViewInit можемо референсити view queries.

angular.dev/...​lifecycle#ngafterviewinit
angular.dev/...​ecycle#ngaftercontentinit

inject можна використовувати в інших функціях, що дає змогу створювати композиційні функції;

Саме в інших функціях і методах використовувати inject() не можна, інакше отримайте помилку:
ERROR RuntimeError: NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.

У вашому прикладі getUser() працює без проблем, тому що ви привласнюєте результат її виконання полю.

технічно можливо використовувати inject() у звичайних функціях через runInInjectionContext(), але я рекомендую обмежуватись використанням контекста класа для кращої передбачуваності і читабельності

Ну тут все зрозуміло: стараємось більше використовувати сигнали, computed, а також input() та output().

Забули про model()

прапор компілятора

Прапор може мати країна, але не компілятор

Хороші приклади, коротко і по суті.
Тільки не зрозумів, для чого перетворювати Observable в сигнали... Ніби ж від RxJS немає потреби відмовлятись.

Тоді вам не потрібно буде використовувати pipe | async чи інші штуки типу { stream: stream$ | async } as data, щоб отримати дані у view.

Ідея мігрувати на zoneless підхід в change detection — сигнали ідеально для цього підходять. Окрім цього, сигнали це свіжий подих в реактивному програмуванню з серйозним потенціалом. Їх можна вже прямо зараз використовувати для реактивності синхронізованого кода, для асинхронного — від RxJS не відійти (поки що).

Тому signals — це майбутнє Angular.

oops, зрозумів питання як «для чого треба переходити із RxJS на сигнали»

Дякую за статтю. Проте, трохи незрозуміло, чому вона має назву саме «Оновлений Angular Code Style...». Все (чи майже все), що перелічено після нових особливостей останніх версій (одразу після блоку «Шаблони») вже давно як є типу дефолтні Angular Code Style.
Ідея по справжньому оновити Code Style нещодавно була озвучена і навіть вже починає формалізуватись. І, думаю, реалізацію можна чекати в найближчих мажорних оновленнях.

Constants. Імена констант у форматі lower camel case (heroRoutes) легші для читання та розуміння порівняно з традиційними іменами в форматі UPPER_SNAKE_CASE (HERO_ROUTES).

Особисто мені легші для читання і розуміння саме UPPER_SNAKE_CASE.

Щодо огляду нових можливостей насамперед цікаві signals і наскільки далеко можна з ними піти станом на зараз. Прогнозую в найближчі два роки відхід від zone.js, можливо повна відмова від class-синтаксису у фреймворку та ще більше можливостей для hmr в розробці (в 19 завезли стилі і частково шаблони, класна штука, про яку не згадано в статті).

Дякую за коментар! Це скоріше рекомендації для розробників, які тільки починають перехід на версію 17+

Підписатись на коментарі