TOP 5 Błędów w Zadaniach Rekrutacyjnych z Angulara

Sławomir Plamowski 23 min czytania
angular frontend interview-questions pytania-rekrutacyjne rxjs typescript

Według Stack Overflow Developer Survey 2024, Angular znajduje się w pierwszej piątce najpopularniejszych frameworków frontendowych, a jednocześnie jest jednym z najtrudniejszych do opanowania. Ta kombinacja sprawia, że rekrutacje na stanowiska Angular developerów potrafią zaskoczyć nawet doświadczonych programistów. Przeanalizowałem setki zadań rekrutacyjnych i zauważyłem powtarzający się schemat - te same błędy pojawiają się niezależnie od poziomu doświadczenia kandydata.

Framework opracowany przez Google wymusza określony sposób myślenia o architekturze aplikacji. Nie wystarczy znać składnię - trzeba rozumieć dlaczego Angular działa tak, a nie inaczej. W tym artykule pokażę pięć błędów, które najczęściej dyskwalifikują kandydatów i wyjaśnię jak ich unikać.

1. Wycieki Pamięci przez Niezarządzane Subskrypcje RxJS

Odpowiedź w 30 sekund

Każda subskrypcja do Observable, która nie zostanie zakończona, pozostaje w pamięci nawet po zniszczeniu komponentu. To prowadzi do wycieków pamięci, duplikowania wywołań i trudnych do zdiagnozowania bugów. Rozwiązanie to automatyczne zarządzanie przez AsyncPipe lub świadome wywoływanie unsubscribe() w ngOnDestroy.

Odpowiedź w 2 minuty

Reactive Programming to jeden z filarów Angulara. Biblioteka RxJS dostarcza potężne narzędzia do pracy ze strumieniami danych, ale ta moc wymaga odpowiedzialności. Kiedy komponent subskrybuje się do Observable, tworzy się połączenie, które trwa dopóki jawnie je nie zakończymy lub źródło samo nie wyemituje complete.

Problem pojawia się gdy komponent zostaje zniszczony, na przykład przez nawigację do innej strony, ale subskrypcja wciąż żyje. Callback przekazany do subscribe() będzie wywoływany przy każdej nowej wartości, próbując aktualizować komponent, który już nie istnieje. W najlepszym przypadku dostaniemy błąd w konsoli. W najgorszym - aplikacja zacznie działać nieprzewidywalnie, zużywając coraz więcej pamięci.

Klasyczny Problem - Kod z Wycikiem

// Komponent z wyraźnym wyciekiem pamięci
@Component({
  selector: 'app-user-dashboard',
  template: `
    <div *ngFor="let notification of notifications">
      {{ notification.message }}
    </div>
  `
})
export class UserDashboardComponent implements OnInit {
  notifications: Notification[] = [];

  constructor(private notificationService: NotificationService) {}

  ngOnInit() {
    // Ten interval będzie działał WIECZNIE, nawet po zniszczeniu komponentu
    interval(5000).subscribe(() => {
      this.loadNotifications();
    });

    // Ta subskrypcja też nigdy się nie kończy
    this.notificationService.notifications$.subscribe(data => {
      this.notifications = data;
    });
  }

  private loadNotifications() {
    // Każde wywołanie tworzy NOWĄ subskrypcję!
    this.notificationService.getNotifications().subscribe(data => {
      this.notifications = data;
    });
  }
}

W tym kodzie mamy aż trzy źródła wycieków. Interval z RxJS to nieskończony strumień - będzie emitował wartości co 5 sekund na zawsze. Subskrypcja do notifications$ również nie ma określonego końca. A metoda loadNotifications() tworzy nową subskrypcję przy każdym wywołaniu, nigdy nie zamykając poprzednich.

Rozwiązanie - Prawidłowe Zarządzanie Subskrypcjami

// Poprawna implementacja z zarządzaniem subskrypcjami
@Component({
  selector: 'app-user-dashboard',
  template: `
    <!-- AsyncPipe automatycznie zarządza subskrypcją -->
    <div *ngFor="let notification of notifications$ | async">
      {{ notification.message }}
    </div>
  `
})
export class UserDashboardComponent implements OnInit, OnDestroy {
  // Strumień do synchronizacji z szablonem przez AsyncPipe
  notifications$: Observable<Notification[]>;

  // Subject do sygnalizowania zniszczenia komponentu
  private destroy$ = new Subject<void>();

  constructor(private notificationService: NotificationService) {}

  ngOnInit() {
    // Strumień kończy się automatycznie gdy destroy$ wyemituje
    this.notifications$ = interval(5000).pipe(
      takeUntil(this.destroy$),
      switchMap(() => this.notificationService.getNotifications())
    );
  }

  ngOnDestroy() {
    // Sygnalizujemy zakończenie - wszystkie subskrypcje z takeUntil się kończą
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Wzorzec z Subject i operatorem takeUntil to jedno z najpopularniejszych rozwiązań w społeczności Angular. Tworzymy prywatny Subject o nazwie destroy$, który emituje wartość tylko raz - w momencie niszczenia komponentu. Każda subskrypcja, która używa takeUntil(this.destroy$), automatycznie się kończy.

AsyncPipe w szablonie to jednak najczystsze rozwiązanie. Pipe automatycznie subskrybuje się do Observable, wyświetla wartości i co najważniejsze - automatycznie wywołuje unsubscribe() gdy komponent jest niszczony. Nie trzeba pamiętać o żadnym czyszczeniu.

Kiedy unsubscribe() NIE jest potrzebne

Nie wszystkie Observable wymagają ręcznego zarządzania. Angular automatycznie obsługuje subskrypcje w tych przypadkach:

// HTTP Client - emituje jedną wartość i kończy się
this.http.get('/api/users').subscribe(users => {
  this.users = users;
});

// Router events - Angular zarządza subskrypcjami routera
this.router.events.subscribe(event => {
  // Bezpieczne bez unsubscribe
});

// Operatory take i first - kończą strumień po określonej liczbie wartości
someObservable$.pipe(
  take(1)  // Kończy się po pierwszej wartości
).subscribe(value => {
  this.value = value;
});

HttpClient zwraca Observable, który emituje dokładnie jedną wartość (odpowiedź serwera) i kończy się. Router w Angular sam zarządza cyklem życia swoich Observable. Operatory take(n) i first() gwarantują zakończenie strumienia.

flowchart TB subgraph "Cykl Życia Subskrypcji" A[Komponent tworzy się] --> B[ngOnInit - subskrypcja] B --> C{Observable emituje} C -->|wartość| D[Callback wykonuje się] D --> C C -->|complete| E[Subskrypcja kończy się] A --> F[ngOnDestroy] F --> G{Czy unsubscribe?} G -->|Tak| E G -->|Nie| H[Wyciek pamięci!] H --> I[Callback nadal się wykonuje] I --> J[Błędy, zużycie pamięci] end style H fill:#ffcdd2 style J fill:#ffcdd2 style E fill:#c8e6c9

2. Ignorowanie Strategii Change Detection

Odpowiedź w 30 sekund

Domyślna strategia ChangeDetectionStrategy.Default sprawdza WSZYSTKIE komponenty w drzewie przy każdym zdarzeniu. Przy złożonych aplikacjach to zabija wydajność. Strategia OnPush sprawdza komponent tylko gdy zmieni się referencja @Input lub wystąpi zdarzenie - może przyspieszyć aplikację wielokrotnie.

Odpowiedź w 2 minuty

Change detection to proces synchronizacji modelu danych z widokiem. Angular musi wiedzieć, które części DOM wymagają aktualizacji. Domyślna strategia jest prosta - przy każdym zdarzeniu (kliknięcie, odpowiedź HTTP, timer) sprawdź całe drzewo komponentów od góry do dołu.

Ta prostota ma swoją cenę. W aplikacji z setkami komponentów, każde kliknięcie może wywołać tysiące porównań. Angular porównuje bieżący stan danych z poprzednim, wywołuje lifecycle hooki, aktualizuje DOM. Wszystko to dzieje się wielokrotnie na sekundę.

Strategia OnPush zmienia zasady gry. Komponent z OnPush jest sprawdzany tylko w trzech sytuacjach: zmiana referencji do wartości @Input, zdarzenie DOM wewnątrz komponentu (np. kliknięcie), lub jawne wywołanie markForCheck(). To dramatycznie redukuje liczbę sprawdzeń.

Klasyczny Problem - Wolna Lista

// Komponent bez optymalizacji - sprawdzany przy KAŻDEJ zmianie w aplikacji
@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of products">
      <app-product-card [product]="product"></app-product-card>
    </div>
  `
  // Brak changeDetection - używa Default
})
export class ProductListComponent {
  @Input() products: Product[];
}

@Component({
  selector: 'app-product-card',
  template: `
    <div class="card">
      <h3>{{ product.name }}</h3>
      <p>{{ calculateDiscount() }}</p>  <!-- Wywołanie metody w szablonie! -->
      <span>{{ product.price | currency }}</span>
    </div>
  `
})
export class ProductCardComponent {
  @Input() product: Product;

  // Ta metoda jest wywoływana przy KAŻDYM change detection!
  calculateDiscount(): string {
    console.log('Calculating discount...'); // Sprawdź ile razy to się wywoła
    return this.product.discount > 0
      ? `${this.product.discount}% taniej!`
      : '';
  }
}

Tu robi się ciekawie - metoda calculateDiscount() jest wywoływana w interpolacji szablonu. Angular nie wie, czy wynik metody się zmienił, więc musi ją wywołać przy każdym cyklu change detection. Przy liście 100 produktów i 10 sprawdzeniach na sekundę to 1000 wywołań metody co sekundę.

Rozwiązanie - OnPush i Niemutowalne Dane

// Zoptymalizowany komponent z OnPush
@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of products; trackBy: trackByProductId">
      <app-product-card [product]="product"></app-product-card>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductListComponent {
  @Input() products: Product[];

  // trackBy pozwala Angularowi identyfikować elementy bez referencji
  trackByProductId(index: number, product: Product): number {
    return product.id;
  }
}

@Component({
  selector: 'app-product-card',
  template: `
    <div class="card">
      <h3>{{ product.name }}</h3>
      <!-- Obliczone raz i przypisane do właściwości -->
      <p>{{ discountLabel }}</p>
      <span>{{ product.price | currency }}</span>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent implements OnChanges {
  @Input() product: Product;

  // Wartość obliczona tylko gdy product się zmieni
  discountLabel: string = '';

  ngOnChanges(changes: SimpleChanges) {
    if (changes['product']) {
      this.discountLabel = this.calculateDiscount();
    }
  }

  private calculateDiscount(): string {
    return this.product.discount > 0
      ? `${this.product.discount}% taniej!`
      : '';
  }
}

Kluczowe zmiany są dwie. Po pierwsze, oba komponenty używają ChangeDetectionStrategy.OnPush, więc Angular sprawdza je tylko gdy zmieni się referencja do @Input. Po drugie, obliczenie rabatu następuje w ngOnChanges, a nie w szablonie - metoda wywołuje się tylko raz przy zmianie produktu, nie przy każdym cyklu.

Funkcja trackBy w ngFor to dodatkowa optymalizacja. Bez niej Angular przy każdej zmianie tablicy products usuwa wszystkie elementy DOM i tworzy je od nowa. Z trackBy Angular identyfikuje elementy po id i aktualizuje tylko te, które faktycznie się zmieniły.

Praca z OnPush i Niemutowalnymi Danymi

OnPush wymaga zmiany sposobu myślenia o aktualizacji danych. Zamiast modyfikować istniejące obiekty, tworzymy nowe:

// Serwis zarządzający produktami w sposób niemutowalny
@Injectable({ providedIn: 'root' })
export class ProductService {
  private productsSubject = new BehaviorSubject<Product[]>([]);
  products$ = this.productsSubject.asObservable();

  // Aktualizacja produktu - tworzenie NOWEJ tablicy i NOWEGO obiektu
  updateProduct(productId: number, changes: Partial<Product>) {
    const currentProducts = this.productsSubject.getValue();

    // map() tworzy nową tablicę
    const updatedProducts = currentProducts.map(product => {
      if (product.id === productId) {
        // Spread operator tworzy nowy obiekt
        return { ...product, ...changes };
      }
      return product;
    });

    // Nowa referencja - OnPush wykryje zmianę
    this.productsSubject.next(updatedProducts);
  }

  // Dodanie produktu - również nowa tablica
  addProduct(product: Product) {
    const currentProducts = this.productsSubject.getValue();
    // Spread operator tworzy nową tablicę z dodanym elementem
    this.productsSubject.next([...currentProducts, product]);
  }

  // Usunięcie - filter() zwraca nową tablicę
  removeProduct(productId: number) {
    const currentProducts = this.productsSubject.getValue();
    const filtered = currentProducts.filter(p => p.id !== productId);
    this.productsSubject.next(filtered);
  }
}

Wzorzec, który mi się sprawdził to traktowanie danych jak wartości w programowaniu funkcyjnym. Zamiast products[0].name = 'New name' (mutacja), tworzę nową tablicę z nowym obiektem na pierwszej pozycji. To wymaga więcej kodu, ale OnPush nagradza nas wielokrotnie szybszą aplikacją.

flowchart LR subgraph "Default Strategy" A1[Zdarzenie] --> B1[Sprawdź ROOT] B1 --> C1[Sprawdź wszystkie dzieci] C1 --> D1[Sprawdź wszystkie wnuki] D1 --> E1[... cała hierarchia] end subgraph "OnPush Strategy" A2[Zdarzenie] --> B2{Zmiana @Input?} B2 -->|Nie| C2[Pomiń komponent] B2 -->|Tak| D2[Sprawdź komponent] D2 --> E2{Dzieci OnPush?} E2 -->|Tak| F2{Zmiana ich @Input?} F2 -->|Nie| G2[Pomiń] end style C2 fill:#c8e6c9 style G2 fill:#c8e6c9 style E1 fill:#ffcdd2

3. Błędna Komunikacja Między Komponentami

Odpowiedź w 30 sekund

Kandydaci często próbują komunikować komponenty przez bezpośrednie manipulowanie DOM, ViewChild do zmiany stanu dziecka z rodzica, lub globalne zmienne. Prawidłowe podejście to @Input/@Output dla relacji rodzic-dziecko i współdzielony serwis z Subject dla komponentów niespokrewnionych.

Odpowiedź w 2 minuty

Angular to framework oparty na komponentach, gdzie każdy komponent powinien być izolowaną jednostką z jasno określonym interfejsem. Komunikacja między komponentami to jedno z najczęściej zadawanych pytań na rozmowach - i jedno z miejsc, gdzie kandydaci najczęściej popełniają błędy architektoniczne.

Właściwy wzorzec zależy od relacji między komponentami. Dla komponentów w relacji rodzic-dziecko używamy dekoratorów @Input do przekazywania danych w dół i @Output z EventEmitter do komunikacji w górę. Dla komponentów, które nie są bezpośrednio spokrewnione w drzewie DOM, najczystszym rozwiązaniem jest współdzielony serwis.

Klasyczny Problem - Antypattern z ViewChild

// ANTYPATTERN - bezpośrednia manipulacja stanem dziecka
@Component({
  selector: 'app-parent',
  template: `
    <app-child #childComponent></app-child>
    <button (click)="updateChildDirectly()">Zaktualizuj</button>
  `
})
export class ParentComponent {
  @ViewChild('childComponent') child: ChildComponent;

  updateChildDirectly() {
    // Bezpośrednia modyfikacja stanu dziecka - łamie enkapsulację!
    this.child.data = 'Nowa wartość';
    this.child.isVisible = true;
    this.child.refresh(); // Wymuszanie wywołania metody
  }
}

@Component({
  selector: 'app-child',
  template: `<div *ngIf="isVisible">{{ data }}</div>`
})
export class ChildComponent {
  data: string = '';
  isVisible: boolean = false;

  refresh() {
    // Metoda wywoływana z zewnątrz
  }
}

Ten kod działa, ale łamie fundamentalne zasady projektowania komponentów. Rodzic wie zbyt dużo o wewnętrznej implementacji dziecka. Jeśli ChildComponent zmieni nazwy swoich właściwości, kod rodzica się zepsuje. Nie ma jasnego kontraktu między komponentami.

Rozwiązanie - @Input/@Output dla Relacji Rodzic-Dziecko

// Prawidłowa komunikacja rodzic -> dziecko -> rodzic
@Component({
  selector: 'app-parent',
  template: `
    <!-- Dane płyną w dół przez @Input -->
    <app-child
      [data]="parentData"
      [isVisible]="showChild"
      (dataChanged)="onChildDataChanged($event)"
      (refreshRequested)="onRefreshRequested()">
    </app-child>

    <button (click)="toggleChild()">Przełącz widoczność</button>
  `
})
export class ParentComponent {
  parentData: string = 'Dane od rodzica';
  showChild: boolean = true;

  toggleChild() {
    this.showChild = !this.showChild;
  }

  // Zdarzenia płyną w górę przez @Output
  onChildDataChanged(newData: string) {
    this.parentData = newData;
    // Rodzic decyduje co zrobić ze zmianą
  }

  onRefreshRequested() {
    // Rodzic kontroluje logikę odświeżania
    this.loadFreshData();
  }

  private loadFreshData() {
    // Logika pobierania danych
  }
}

@Component({
  selector: 'app-child',
  template: `
    <div *ngIf="isVisible">
      <p>{{ data }}</p>
      <input [value]="data" (input)="onInputChange($event)">
      <button (click)="requestRefresh()">Odśwież</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
  // Jasny kontrakt - co komponent przyjmuje
  @Input() data: string = '';
  @Input() isVisible: boolean = false;

  // Jasny kontrakt - co komponent emituje
  @Output() dataChanged = new EventEmitter<string>();
  @Output() refreshRequested = new EventEmitter<void>();

  onInputChange(event: Event) {
    const input = event.target as HTMLInputElement;
    // Dziecko NIE modyfikuje data bezpośrednio
    // Emituje zdarzenie - rodzic decyduje co zrobić
    this.dataChanged.emit(input.value);
  }

  requestRefresh() {
    // Dziecko prosi rodzica o odświeżenie
    this.refreshRequested.emit();
  }
}

Pokażę na przykładzie dlaczego ten wzorzec jest lepszy. Komponent dziecko ma jasno zdefiniowany interfejs - dwa wejścia (@Input) i dwa wyjścia (@Output). Rodzic nie musi wiedzieć jak dziecko jest zaimplementowane wewnętrznie. Może zmienić się cała implementacja ChildComponent, ale dopóki interfejs pozostaje ten sam, kod rodzica nadal działa.

Komunikacja Między Niespokrewnionymi Komponentami

Gdy komponenty nie są w bezpośredniej relacji rodzic-dziecko, potrzebujemy innego mechanizmu. Współdzielony serwis z Subject lub BehaviorSubject to najczystsze rozwiązanie:

// Serwis jako mediator komunikacji
@Injectable({ providedIn: 'root' })
export class NotificationService {
  // BehaviorSubject zapamiętuje ostatnią wartość
  private notificationsSubject = new BehaviorSubject<Notification[]>([]);

  // Publiczny Observable do subskrypcji
  notifications$ = this.notificationsSubject.asObservable();

  // Subject do jednorazowych zdarzeń
  private notificationAddedSubject = new Subject<Notification>();
  notificationAdded$ = this.notificationAddedSubject.asObservable();

  addNotification(notification: Notification) {
    const current = this.notificationsSubject.getValue();
    this.notificationsSubject.next([...current, notification]);

    // Emitujemy też zdarzenie o dodaniu
    this.notificationAddedSubject.next(notification);
  }

  removeNotification(id: string) {
    const current = this.notificationsSubject.getValue();
    this.notificationsSubject.next(
      current.filter(n => n.id !== id)
    );
  }

  clearAll() {
    this.notificationsSubject.next([]);
  }
}

// Komponent wysyłający powiadomienia
@Component({
  selector: 'app-action-panel',
  template: `
    <button (click)="notifySuccess()">Sukces</button>
    <button (click)="notifyError()">Błąd</button>
  `
})
export class ActionPanelComponent {
  constructor(private notificationService: NotificationService) {}

  notifySuccess() {
    this.notificationService.addNotification({
      id: crypto.randomUUID(),
      type: 'success',
      message: 'Operacja zakończona pomyślnie'
    });
  }

  notifyError() {
    this.notificationService.addNotification({
      id: crypto.randomUUID(),
      type: 'error',
      message: 'Wystąpił błąd'
    });
  }
}

// Komponent wyświetlający powiadomienia - może być gdziekolwiek w aplikacji
@Component({
  selector: 'app-notification-toast',
  template: `
    <div class="toast-container">
      <div
        *ngFor="let notification of notifications$ | async"
        class="toast"
        [class.success]="notification.type === 'success'"
        [class.error]="notification.type === 'error'">
        {{ notification.message }}
        <button (click)="dismiss(notification.id)">×</button>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationToastComponent {
  notifications$ = this.notificationService.notifications$;

  constructor(private notificationService: NotificationService) {}

  dismiss(id: string) {
    this.notificationService.removeNotification(id);
  }
}

BehaviorSubject różni się od zwykłego Subject tym, że zapamiętuje ostatnią wyemitowaną wartość. Gdy nowy komponent się subskrybuje, od razu otrzymuje aktualny stan. To idealne dla danych, które powinny być dostępne natychmiast po załadowaniu komponentu.

flowchart TB subgraph "Wzorzec @Input/@Output" P[Parent Component] C[Child Component] P -->|"@Input() data"| C C -->|"@Output() event"| P end subgraph "Wzorzec Shared Service" S[NotificationService] A[Action Panel] T[Toast Component] H[Header Component] A -->|"addNotification()"| S S -->|"notifications$"| T S -->|"notifications$"| H end style S fill:#fff3e0 style P fill:#e3f2fd style C fill:#e3f2fd

4. Niewłaściwe Użycie Operatorów RxJS

Odpowiedź w 30 sekund

Najczęstszy błąd to używanie subscribe() wewnątrz innego subscribe(), co prowadzi do "callback hell" i problemów z zarządzaniem subskrypcjami. Rozwiązanie to operatory wyższego rzędu: switchMap gdy chcemy anulować poprzednie żądania, concatMap gdy kolejność ma znaczenie, mergeMap gdy chcemy równoległego wykonania.

Odpowiedź w 2 minuty

RxJS oferuje ponad 100 operatorów do transformacji strumieni danych. Większość kandydatów zna podstawowe - map, filter, tap - ale prawdziwe wyzwania pojawiają się przy operatorach wyższego rzędu. To właśnie one decydują o tym, jak obsługujemy asynchroniczne zależności między operacjami.

switchMap, concatMap, mergeMap i exhaustMap - każdy z nich służy do mapowania wartości na wewnętrzny Observable, ale różnią się strategią obsługi wielu jednoczesnych strumieni. Wybór złego operatora może prowadzić do race conditions, niepotrzebnych żądań HTTP, lub utraty danych.

Klasyczny Problem - Zagnieżdżone Subscribe

// ANTYPATTERN - zagnieżdżone subscribe (callback hell)
@Component({
  selector: 'app-user-profile',
  template: `
    <div *ngIf="user">
      <h2>{{ user.name }}</h2>
      <div *ngFor="let order of orders">
        {{ order.product }} - {{ order.total | currency }}
      </div>
    </div>
  `
})
export class UserProfileComponent implements OnInit {
  user: User;
  orders: Order[] = [];

  constructor(
    private route: ActivatedRoute,
    private userService: UserService,
    private orderService: OrderService
  ) {}

  ngOnInit() {
    // Zagnieżdżone subscribe - trudne do zarządzania i testowania
    this.route.params.subscribe(params => {
      this.userService.getUser(params['id']).subscribe(user => {
        this.user = user;

        this.orderService.getOrdersByUser(user.id).subscribe(orders => {
          this.orders = orders;

          // A co jeśli potrzebujemy szczegółów każdego zamówienia?
          orders.forEach(order => {
            this.orderService.getOrderDetails(order.id).subscribe(details => {
              order.details = details; // Mutacja!
            });
          });
        });
      });
    });
  }
}

Ten kod ma kilka poważnych problemów. Po pierwsze, żadna z subskrypcji nie jest zarządzana - wszystkie mogą wyciekać. Po drugie, gdy użytkownik zmieni się szybko (np. przez nawigację), poprzednie żądania wciąż będą się wykonywać i mogą nadpisać nowsze dane. Po trzecie, mutujemy obiekty orders bezpośrednio, co łamie zasady pracy z OnPush.

Rozwiązanie - Łańcuch Operatorów

// Prawidłowe użycie operatorów RxJS
@Component({
  selector: 'app-user-profile',
  template: `
    <ng-container *ngIf="viewModel$ | async as vm">
      <div *ngIf="vm.user">
        <h2>{{ vm.user.name }}</h2>
        <div *ngFor="let order of vm.orders">
          {{ order.product }} - {{ order.total | currency }}
          <span *ngIf="order.details">
            ({{ order.details.items.length }} produktów)
          </span>
        </div>
      </div>
      <div *ngIf="vm.loading">Ładowanie...</div>
      <div *ngIf="vm.error">{{ vm.error }}</div>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent implements OnInit {
  viewModel$: Observable<UserProfileViewModel>;

  constructor(
    private route: ActivatedRoute,
    private userService: UserService,
    private orderService: OrderService
  ) {}

  ngOnInit() {
    this.viewModel$ = this.route.params.pipe(
      // switchMap anuluje poprzednie żądania gdy zmieni się parametr
      switchMap(params => this.loadUserData(params['id'])),

      // Obsługa błędów bez przerywania strumienia
      catchError(error => of({
        user: null,
        orders: [],
        loading: false,
        error: 'Nie udało się załadować danych użytkownika'
      }))
    );
  }

  private loadUserData(userId: string): Observable<UserProfileViewModel> {
    return this.userService.getUser(userId).pipe(
      // concatMap zachowuje kolejność operacji
      concatMap(user =>
        this.orderService.getOrdersByUser(user.id).pipe(
          // mergeMap do równoległego pobierania szczegółów
          mergeMap(orders =>
            orders.length > 0
              ? this.loadOrderDetails(orders)
              : of([])
          ),
          // Łączymy wszystko w jeden obiekt
          map(ordersWithDetails => ({
            user,
            orders: ordersWithDetails,
            loading: false,
            error: null
          }))
        )
      ),
      // Stan ładowania na początku
      startWith({
        user: null,
        orders: [],
        loading: true,
        error: null
      })
    );
  }

  private loadOrderDetails(orders: Order[]): Observable<OrderWithDetails[]> {
    // forkJoin czeka na wszystkie żądania równolegle
    const detailRequests = orders.map(order =>
      this.orderService.getOrderDetails(order.id).pipe(
        // Łączymy zamówienie ze szczegółami w nowy obiekt
        map(details => ({ ...order, details })),
        // Obsługa błędu pojedynczego żądania
        catchError(() => of({ ...order, details: null }))
      )
    );

    return forkJoin(detailRequests);
  }
}

interface UserProfileViewModel {
  user: User | null;
  orders: OrderWithDetails[];
  loading: boolean;
  error: string | null;
}

Pokażę na przykładzie jak działają poszczególne operatory. switchMap przy zmianie parametru routera anuluje wszystkie poprzednie żądania - jeśli użytkownik szybko przeskakuje między profilami, nie dostaniemy nieaktualnych danych. concatMap gwarantuje, że najpierw pobierzemy użytkownika, potem jego zamówienia - kolejność ma znaczenie. mergeMap pozwala pobrać szczegóły wszystkich zamówień równolegle - nie czekamy na zakończenie jednego żądania przed rozpoczęciem następnego.

Kiedy Użyć Którego Operatora

// switchMap - anuluj poprzednie, weź najnowsze
// Przypadek użycia: wyszukiwarka z autocomplete
searchInput$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(term => this.searchService.search(term))
  // Użytkownik wpisuje szybko - interesuje nas tylko ostatnie zapytanie
);

// concatMap - zachowaj kolejność, czekaj na zakończenie
// Przypadek użycia: zapisywanie zmian w kolejności
saveActions$.pipe(
  concatMap(action => this.api.save(action))
  // Musimy zapisać A przed B - kolejność ma znaczenie
);

// mergeMap - wykonuj równolegle
// Przypadek użycia: pobieranie danych wielu elementów
selectedIds$.pipe(
  mergeMap(id => this.api.getDetails(id))
  // Kolejność nie ma znaczenia, chcemy jak najszybciej
);

// exhaustMap - ignoruj nowe dopóki trwa poprzednie
// Przypadek użycia: formularz z przyciskiem submit
submitButton$.pipe(
  exhaustMap(() => this.api.submitForm(formData))
  // Ignoruj wielokrotne kliknięcia podczas wysyłania
);
sequenceDiagram participant U as Użytkownik participant C as Komponent participant SW as switchMap participant API as API Note over U,API: Scenariusz: szybkie wyszukiwanie U->>C: Wpisuje "ang" C->>SW: emit("ang") SW->>API: GET /search?q=ang U->>C: Wpisuje "angular" C->>SW: emit("angular") Note over SW: Anuluje poprzednie żądanie! SW--xAPI: Cancelled SW->>API: GET /search?q=angular API->>SW: Wyniki dla "angular" SW->>C: Wyniki C->>U: Wyświetla wyniki

5. Błędy w Konfiguracji Dependency Injection

Odpowiedź w 30 sekund

Najczęstszy błąd to rejestrowanie serwisu w wielu modułach, co tworzy wiele instancji zamiast jednego singletona. Serwis dodany do providers w lazy-loaded module ma osobną instancję dla tego modułu. Rozwiązanie: używaj providedIn: 'root' dla singletonów lub świadomie wybieraj poziom providera.

Odpowiedź w 2 minuty

Dependency Injection to jeden z fundamentów Angulara. Mechanizm składa się z dwóch części - Injector, który wie jak tworzyć zależności, oraz Provider, który mówi Injectorowi jak konkretną zależność stworzyć. Problem pojawia się, gdy nie rozumiemy hierarchii Injectorów.

Angular tworzy drzewo Injectorów odpowiadające drzewu modułów i komponentów. Root Injector jest na szczycie. Każdy lazy-loaded module ma swój własny Injector. Każdy komponent może też mieć własny Injector. Gdy komponent prosi o zależność, Angular szuka jej idąc w górę drzewa - od komponentu, przez moduł, aż do root.

Klasyczny Problem - Niechciany Multi-Instance

// Serwis, który powinien być singletonem
@Injectable()  // Brak providedIn!
export class CartService {
  private items: CartItem[] = [];

  addItem(item: CartItem) {
    this.items.push(item);
    console.log('Cart items:', this.items.length);
  }

  getItems(): CartItem[] {
    return this.items;
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

// Moduł główny
@NgModule({
  providers: [CartService]  // Instancja dla głównego modułu
})
export class AppModule {}

// Moduł funkcjonalny (lazy-loaded)
@NgModule({
  providers: [CartService]  // BŁĄD! Osobna instancja dla tego modułu!
})
export class ShopModule {}

// Inny moduł funkcjonalny
@NgModule({
  providers: [CartService]  // Kolejna osobna instancja!
})
export class CheckoutModule {}

W tym scenariuszu mamy trzy instancje CartService. Użytkownik dodaje produkt do koszyka w ShopModule, ale CheckoutModule widzi pusty koszyk - ma własną instancję serwisu. To klasyczny bug, który jest trudny do zdiagnozowania bo kod wygląda poprawnie.

Rozwiązanie - providedIn: 'root'

// Serwis z prawidłową konfiguracją singletona
@Injectable({
  providedIn: 'root'  // Jedna instancja dla całej aplikacji
})
export class CartService {
  private itemsSubject = new BehaviorSubject<CartItem[]>([]);

  // Publiczny Observable do subskrypcji
  items$ = this.itemsSubject.asObservable();

  // Obliczone wartości jako Observable
  total$ = this.items$.pipe(
    map(items => items.reduce((sum, item) => sum + item.price * item.quantity, 0))
  );

  itemCount$ = this.items$.pipe(
    map(items => items.reduce((sum, item) => sum + item.quantity, 0))
  );

  addItem(item: CartItem) {
    const current = this.itemsSubject.getValue();
    const existingIndex = current.findIndex(i => i.productId === item.productId);

    if (existingIndex >= 0) {
      // Aktualizacja ilości istniejącego produktu
      const updated = [...current];
      updated[existingIndex] = {
        ...updated[existingIndex],
        quantity: updated[existingIndex].quantity + item.quantity
      };
      this.itemsSubject.next(updated);
    } else {
      // Dodanie nowego produktu
      this.itemsSubject.next([...current, item]);
    }
  }

  removeItem(productId: string) {
    const current = this.itemsSubject.getValue();
    this.itemsSubject.next(current.filter(item => item.productId !== productId));
  }

  updateQuantity(productId: string, quantity: number) {
    const current = this.itemsSubject.getValue();
    const updated = current.map(item =>
      item.productId === productId
        ? { ...item, quantity }
        : item
    );
    this.itemsSubject.next(updated);
  }

  clear() {
    this.itemsSubject.next([]);
  }
}

// Moduły NIE dodają CartService do providers - jest automatycznie dostępny
@NgModule({
  // providers: []  - CartService dostępny dzięki providedIn: 'root'
})
export class ShopModule {}

@NgModule({
  // providers: []  - ta sama instancja CartService
})
export class CheckoutModule {}

providedIn: 'root' ma dodatkową zaletę - umożliwia tree shaking. Jeśli żaden komponent nie używa serwisu, nie zostanie on włączony do finalnej paczki. Przy tradycyjnym providers w module, serwis zawsze jest włączany.

Świadome Użycie Wielu Instancji

Czasami chcemy, aby każdy komponent miał własną instancję serwisu. To przydatne dla serwisów przechowujących stan specyficzny dla komponentu:

// Serwis stanu formularza - osobna instancja dla każdego formularza
@Injectable()  // Celowo bez providedIn
export class FormStateService {
  private formData: any = {};
  private isDirty = false;

  setField(name: string, value: any) {
    this.formData[name] = value;
    this.isDirty = true;
  }

  getFormData() {
    return { ...this.formData };
  }

  isFormDirty(): boolean {
    return this.isDirty;
  }

  reset() {
    this.formData = {};
    this.isDirty = false;
  }
}

// Komponent z własną instancją serwisu
@Component({
  selector: 'app-user-form',
  template: `...`,
  providers: [FormStateService]  // Nowa instancja dla każdego komponentu
})
export class UserFormComponent {
  constructor(private formState: FormStateService) {}
  // Ta instancja FormStateService jest izolowana
}

// Inny komponent - ma WŁASNĄ instancję
@Component({
  selector: 'app-product-form',
  template: `...`,
  providers: [FormStateService]  // Osobna instancja
})
export class ProductFormComponent {
  constructor(private formState: FormStateService) {}
  // Ta instancja jest niezależna od UserFormComponent
}

Kandydaci, którzy robią wrażenie to ci, którzy potrafią wyjaśnić kiedy użyć providedIn: 'root' (singleton dla całej aplikacji), kiedy providers w module (singleton dla modułu i jego importerów), a kiedy providers w komponencie (instancja per komponent).

flowchart TB subgraph "Hierarchia Injectorów" ROOT[Root Injector
providedIn: 'root'] subgraph "Lazy Loaded Modules" SHOP[Shop Module Injector] CHECKOUT[Checkout Module Injector] end subgraph "Komponenty" FORM1[UserForm Injector] FORM2[ProductForm Injector] end ROOT --> SHOP ROOT --> CHECKOUT SHOP --> FORM1 CHECKOUT --> FORM2 end subgraph "CartService (singleton)" CS[Jedna instancja
providedIn: 'root'] end subgraph "FormStateService (per component)" FS1[Instancja 1] FS2[Instancja 2] end ROOT -.->|"udostępnia"| CS FORM1 -.->|"tworzy własną"| FS1 FORM2 -.->|"tworzy własną"| FS2 style ROOT fill:#fff3e0 style CS fill:#c8e6c9 style FS1 fill:#e3f2fd style FS2 fill:#e3f2fd

Na Co Rekruterzy Naprawdę Zwracają Uwagę

Po przeprowadzeniu wielu rozmów rekrutacyjnych zauważam, że ocena kandydata nie opiera się wyłącznie na poprawności kodu. Oczywiście rozwiązanie musi działać, ale równie ważne jest jak kandydat dochodzi do rozwiązania i jak o nim opowiada.

Pierwsza rzecz to zrozumienie "dlaczego". Kandydat, który wie że trzeba użyć takeUntil do zarządzania subskrypcjami jest dobry. Kandydat, który potrafi wyjaśnić dlaczego wyciek subskrypcji jest problemem, jakie konsekwencje ma w produkcji i kiedy można bezpiecznie pominąć unsubscribe - ten kandydat wyróżnia się z tłumu.

Druga rzecz to świadomość kompromisów. Nie ma idealnych rozwiązań - każda decyzja architektoniczna ma swoje plusy i minusy. OnPush przyspiesza aplikację, ale wymaga niemutowalnych danych. providedIn: 'root' upraszcza zarządzanie zależnościami, ale nie zawsze chcemy singleton. Kandydat, który widzi te kompromisy i potrafi argumentować za swoim wyborem, pokazuje dojrzałość inżynierską.

Trzecia rzecz to umiejętność debugowania. Zadania rekrutacyjne często zawierają celowe błędy. Rekruter obserwuje jak kandydat identyfikuje problem, jakich narzędzi używa, jak systematycznie zawęża przyczynę. Ktoś, kto chaotycznie zmienia losowe rzeczy w nadziei że zadziała, nie sprawdza się w produkcyjnych projektach.

Zadania Praktyczne

Sprawdź swoją wiedzę przed rozmową. Spróbuj rozwiązać te problemy bez patrzenia na rozwiązania:

Zadanie 1: Znajdź Wyciek Pamięci

@Component({
  selector: 'app-dashboard',
  template: `<div>{{ data }}</div>`
})
export class DashboardComponent implements OnInit {
  data: string;

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.dataService.getData().subscribe(d => this.data = d);
    setInterval(() => this.refresh(), 5000);
  }

  refresh() {
    this.dataService.getData().subscribe(d => this.data = d);
  }
}

Ile źródeł wycieków widzisz? Jak je naprawić?

Zadanie 2: Popraw Change Detection

@Component({
  selector: 'app-item-list',
  template: `
    <div *ngFor="let item of items">
      {{ formatItem(item) }}
    </div>
  `
})
export class ItemListComponent {
  @Input() items: Item[];

  formatItem(item: Item): string {
    return `${item.name} - ${item.price.toFixed(2)} PLN`;
  }
}

Dlaczego ten komponent może być wolny przy dużych listach? Jak go zoptymalizować?

Zadanie 3: Wybierz Operator

Masz wyszukiwarkę, która wysyła żądanie do API przy każdej zmianie tekstu. Użytkownik wpisuje "angular" litera po literze. Który operator użyjesz i dlaczego?

searchInput$.pipe(
  debounceTime(300),
  // switchMap? concatMap? mergeMap? exhaustMap?
  ???(term => this.api.search(term))
);

Rozwiązania:

  1. Trzy wycieki: subskrypcja w ngOnInit bez unsubscribe, setInterval bez clearInterval, subskrypcja w refresh() przy każdym wywołaniu. Napraw przez takeUntil/destroy$, switchMap zamiast wielu subskrypcji w refresh, zapisanie interval ID i wyczyszczenie w ngOnDestroy.

  2. Metoda formatItem() jest wywoływana przy każdym change detection. Z OnPush i obliczeniem wartości w ngOnChanges lub użyciem pure pipe problem znika. Dodaj też trackBy do ngFor.

  3. switchMap - bo przy nowym wpisaniu chcemy anulować poprzednie żądanie. Użytkownik nie potrzebuje wyników dla "a", "an", "ang" - tylko dla "angular".


Zobacz też


Gotowy na Więcej Pytań z Angulara?

Te pięć błędów to tylko wierzchołek góry lodowej. Na rozmowie rekrutacyjnej możesz spotkać pytania o moduły, routing, formularze reaktywne, testowanie, SSR i wiele innych tematów.

Przygotowaliśmy kompletny zestaw 50+ pytań rekrutacyjnych z Angulara z odpowiedziami w formacie "30 sekund / 2 minuty". Każde pytanie zawiera przykłady kodu, typowe błędy i wskazówki jak wyróżnić się na rozmowie.

Sprawdź Fiszki Online - Angular i Więcej

Możesz też zobaczyć bezpłatny podgląd pytań z Angulara, żeby przekonać się o jakości naszych materiałów.


Artykuł przygotowany przez zespół Flipcards, na podstawie doświadczeń z kilkunastu lat w branży IT i setek przeprowadzonych rozmów rekrutacyjnych w firmach takich jak BNY Mellon, UBS i wiodących firmach fintech.

Chcesz więcej pytań rekrutacyjnych?

To tylko jeden temat z naszego kompletnego przewodnika po rozmowach rekrutacyjnych. Uzyskaj dostęp do 800+ pytań z 13 technologii.

Kup pełny dostęp Zobacz bezpłatny podgląd
Powrót do blogu

Zostaw komentarz

Pamiętaj, że komentarze muszą zostać zatwierdzone przed ich opublikowaniem.