TOP 5 Błędów w Zadaniach Rekrutacyjnych z Angulara
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.
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ą.
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.
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
);
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).
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:
-
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.
-
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.
-
switchMap - bo przy nowym wpisaniu chcemy anulować poprzednie żądanie. Użytkownik nie potrzebuje wyników dla "a", "an", "ang" - tylko dla "angular".
Zobacz też
- Angular vs React - Porównanie dla Programistów - porównanie Angular z React
- Najtrudniejsze Pytania z Frameworków JavaScript - Angular, React, Vue i więcej
- Pytania Rekrutacyjne z TypeScript - TypeScript jest wymagany w Angular
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.
