Angular 15'te Functional Guard, Functional Interceptor, hostDirectives ve NgOptimizedImage
Angular 15'in ikinci yarısı: CanActivateFn ile functional guard, withInterceptors ile functional interceptor, hostDirectives ile directive birleştirme ve ngSrc ile NgOptimizedImage.
Sürüm notu: Bu yazı Angular 15 ile gelen yenilikleri anlatır; kodlar Angular 15 içindir.
Class'lardan Fonksiyonlara
Angular 15'in ilk yarısında standalone omurgayı kurduk. Bu yazıda o omurganın üzerine oturan dört yeniliğe bakacağız: fonksiyon olarak yazılan guard'lar, yine fonksiyon olarak yazılan interceptor'lar, birden fazla directive'i tek bir component'te birleştiren hostDirectives ve görsel yüklemeyi akıllı hâle getiren NgOptimizedImage.
İlk ikisinin ortak bir hikâyesi var. Angular 12'de guard ve interceptor yazmak için bir class açıyor, bir interface implement ediyor, bağımlılıkları constructor'dan alıyorduk. Angular 14 ile gelen inject() sayesinde bu törene gerek kalmadı; çoğu zaman tek bir fonksiyon yeterli.
Bu Özellikler İçin Hangi Sürüm Gerekli?
Bu yazıdaki her şey Angular 15 içinde, ama hepsi 15.0'da gelmedi. CanActivateFn gibi guard tipleri ve withInterceptors 15.0'da var; CanLoad'un deprecated olması 15.1'de, class-based guard'ların deprecated olması 15.2'de geldi. O yüzden 15'in son minor sürümünde olduğundan emin ol:
ng update @angular/core@15 @angular/cli@15
npm ls @angular/core
Zaten 15'teysen bu komut seni 15.x'in en son sürümüne çeker. Node ve TypeScript gereksinimleri bir önceki yazıdakiyle aynı: Node 14.20, 16.13 ya da 18.10 ve üstü, TypeScript en az 4.8.
Angular 12'deki Guard'ımız
Daha önce gördüğümüz class-based guard'ı hatırla. Giriş yapmamış kullanıcıyı hesap sayfasından login'e yönlendiren klasik bir örnek:
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) { }
canActivate(): boolean | UrlTree {
return this.auth.isLoggedIn() ? true : this.router.createUrlTree(['/login']);
}
}
{ path: 'account', component: AccountComponent, canActivate: [AuthGuard] }
İşin özü tek satır: kullanıcı giriş yapmış mı, yapmamışsa login'e yönlendir. Ama bu tek satır için bir class, bir decorator, bir interface ve iki parametreli bir constructor yazdık. Kapıdaki görevliye sadece "kartı olan girsin" demek istiyoruz, ama önce onu kadrolu işe almamız gerekiyor.
Functional Guard: CanActivateFn
Angular 15'te aynı guard:
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};
{ path: 'account', component: AccountComponent, canActivate: [authGuard] }
Guard artık düz bir fonksiyon. CanActivateFn tipi, fonksiyonun alacağı parametreleri (route ve state) ve dönebileceği değerleri tanımlıyor; ihtiyacın yoksa parametreleri yazmak zorunda değilsin. Bağımlılıkları inject() ile alıyoruz. Router guard'ı çalıştırırken bir injection context sağladığı için inject() burada sorunsuz çalışıyor.
Fonksiyon olmasının güzel bir yan etkisi var: parametre alan guard'lar yazmak çok kolaylaşıyor. Rol kontrolü yapan bir guard'ı şöyle üretebilirsin:
export function roleGuard(role: string): CanActivateFn {
return () => inject(AuthService).hasRole(role);
}
{ path: 'admin', component: AdminComponent, canActivate: [roleGuard('admin')] }
Class-based dünyada bunu yapmak için rolü route'un data alanına koyup guard'ın içinde okumak gerekirdi. Burada fonksiyon, ihtiyacı olan bilgiyi doğrudan parametre olarak alıyor.
Bir şeyi de netleştirelim: functional guard'lar standalone uygulamaya özel değil. RouterModule.forRoot kullanan NgModule tabanlı bir projede de aynı şekilde çalışıyor.
Diğer Guard'lar ve Resolver'lar
Aynı yaklaşım diğer guard ve resolver türleri için de geçerli. Kaydedilmemiş değişiklik uyarısı veren bir CanDeactivate guard'ı:
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
component.hasUnsavedChanges()
? confirm('Kaydedilmemiş değişiklikler var. Sayfadan çıkılsın mı?')
: true;
Route açılmadan önce veriyi yükleyen bir resolver:
export const productResolver: ResolveFn<Product> = (route) =>
inject(ProductService).getById(route.paramMap.get('id')!);
{
path: 'products/:id',
component: ProductDetailComponent,
resolve: { product: productResolver },
canDeactivate: [unsavedChangesGuard]
}
Lazy loading'de kullandığımız CanLoad guard'ı 15.1'de deprecated oldu; onun yerine Angular 14.1'de gelen CanMatch öneriliyor, fonksiyon hâli de CanMatchFn. Angular 15.2 ise class ve InjectionToken tabanlı guard ve resolver'ları deprecated olarak işaretledi. Yani mevcut class'ların hâlâ çalışıyor, ama yeni yazdığın guard'ların fonksiyon olması gerektiği mesajı net.
Functional Interceptor: HttpInterceptorFn
Daha önce gördüğümüz, her isteğe token ekleyen interceptor'ı hatırla:
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const authReq = req.clone({ setHeaders: { Authorization: `Bearer ${this.auth.token}` } });
return next.handle(authReq);
}
}
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
Angular 15'te:
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token;
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
]
});
Interceptor bir fonksiyon: isteği ve bir sonraki adımı temsil eden next'i alıyor. next.handle(req) yerine doğrudan next(req) çağırıyoruz. Kayıt da sadeleşti; HTTP_INTERCEPTORS token'ı ve multi: true yerine, bir önceki yazıda gördüğümüz provideHttpClient'a withInterceptors ile bir dizi veriyoruz. Dizideki sıra, istek giderken interceptor'ların çalışma sırası. Kargo bandı gibi düşün: paket her istasyondan sırayla geçiyor, her istasyon üzerine bir etiket yapıştırıp bir sonrakine yolluyor.
Hata Yakalayan Bir Interceptor
Yukarıdaki listede geçen errorInterceptor'ı da yazalım. 401 dönen her istekte kullanıcıyı login sayfasına göndersin:
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
Burada önemli bir detay var: inject(Router) çağrısını fonksiyonun en başında yaptık, catchError'ın içinde değil. Interceptor fonksiyonu çalışırken bir injection context içindeyiz; ama catchError'a verdiğimiz callback çok daha sonra, cevap geldiğinde çalışıyor ve o anda bu context artık yok. inject()'i callback'in içine koyarsan, daha önce gördüğümüz "injection context" hatasını alırsın. Kural basit: önce ihtiyacın olanları al, sonra stream'i kur. Bir de küçük not: throwError(() => error) yazımı RxJS 7'ye ait; projen hâlâ RxJS 6'daysa throwError(error) yazarsın.
Directive Composition API: hostDirectives
Diyelim ki butonlara tıklanınca analitik için bir kayıt atan bir directive'in var:
@Directive({
selector: '[appTrackClick]',
standalone: true
})
export class TrackClickDirective {
@Input() trackId = '';
@HostListener('click') onClick(): void {
console.log(`click: ${this.trackId}`);
}
}
Angular 12'de bunu her butona tek tek eklemek zorundaydın: <app-button appTrackClick trackId="save">. Birisi bir yerde eklemeyi unuttuğunda o butonun tıklamaları hiç kayda geçmezdi. Angular 15 ile directive'i doğrudan component'in kendisine bağlayabiliyorsun:
@Component({
selector: 'app-button',
standalone: true,
template: `<button type="button"><ng-content></ng-content></button>`,
hostDirectives: [
{ directive: TrackClickDirective, inputs: ['trackId'] }
]
})
export class ButtonComponent { }
<app-button trackId="save-profile">Kaydet</app-button>
Artık her app-button, TrackClickDirective'i kendiliğinden taşıyor. Bir arabanın fabrikadan klimalı çıkması gibi; her alıcının ayrıca taktırması gerekmiyor.
Birkaç kural var. hostDirectives'e eklenen directive'in standalone olması gerekiyor. Directive'in input ve output'ları varsayılan olarak dışarıya açılmıyor; template'ten bağlanabilmesini istediklerini inputs ve outputs dizilerinde açıkça listelemelisin. Biz trackId'yi listelediğimiz için app-button üzerine yazabildik. Bir de bu birleştirme derleme zamanında yapılıyor; bir directive'i çalışma anında, bir koşula göre ekleyip çıkaramazsın. Birden fazla directive'i aynı dizide birleştirip küçük, tek işlik parçalardan zengin component'ler kurmak bu API'nin asıl gücü.
NgOptimizedImage ve ngSrc
Angular 12'de görseller sıradan img etiketleriydi ve performans tamamen sana kalmıştı:
<img src="/assets/hero.jpg" alt="Kampanya görseli">
<img src="/assets/products/keyboard.jpg" alt="Mekanik klavye" loading="lazy">
Angular 15'te stable olan NgOptimizedImage directive'i ile:
<img ngSrc="/assets/hero.jpg" alt="Kampanya görseli" width="1200" height="500" priority>
<img ngSrc="/assets/products/keyboard.jpg" alt="Mekanik klavye" width="400" height="300">
Kullanmak için NgOptimizedImage'ı @angular/common'dan import edip component'in ya da module'ün imports dizisine eklemen yeterli. src yerine ngSrc yazdığın anda directive devreye giriyor ve seni iyi alışkanlıklara zorluyor.
width ve height zorunlu; eksikse geliştirme modunda hata alırsın. Bu sayede tarayıcı görsel inmeden yerini ayırıyor ve sayfa yüklenirken içerik aşağı kaymıyor. Ölçüyü önceden bilmediğin, kapsayıcısını dolduracak görseller için fill modu da var. priority almayan görseller varsayılan olarak lazy yükleniyor; elle loading="lazy" yazmana gerek yok. Sayfanın ana görseli için priority eklediğinde ise o görsel yüksek öncelikle isteniyor. Ana görseline priority eklemeyi unutursan, geliştirme modunda konsolda bir uyarı seni hatırlatıyor.
Görsellerini Imgix, Cloudinary gibi bir CDN'den sunuyorsan provideImgixLoader ya da provideCloudinaryLoader gibi hazır loader'larla ngSrcset desteği de kazanıyorsun; farklı ekran boyutları için doğru boyutta görsel isteniyor. Bu directive'in ilk hâli Angular 14.2'de rawSrc adıyla gelmişti, Angular 15'te ngSrc olarak yeniden adlandırılıp stable oldu.
Küçük Bir Deneme
Elindeki projede class-based bir guard'ı fonksiyona çevir ve route tanımında class adının yerine fonksiyonun adını yaz. Sonra bir roleGuard('admin') ekleyip parametreli guard'ın nasıl çalıştığını gör. Ardından token ekleyen interceptor'ını HttpInterceptorFn olarak yeniden yaz ve withInterceptors ile kaydet; tarayıcının Network sekmesinde Authorization header'ının hâlâ gittiğini kontrol et.
Bir kez de bilerek hata yap: errorInterceptor'daki inject(Router) satırını catchError'ın içine taşı, 401 dönen bir isteği tetikle ve hatayı oku. Son olarak sayfandaki en büyük görseli ngSrc, width, height ve priority ile güncelle, diğer görsellerin HTML'inde loading="lazy" attribute'unun kendiliğinden geldiğine bak.
Angular 15 ile class'lardan fonksiyonlara geçişin temeli atıldı. Bir sonraki sürümde Angular, reaktiviteye bakışını kökten değiştirecek bir yeniliği developer preview olarak sunuyor: Angular 16 ile gelen signal'lar, yani signal, computed ve effect, ve bunların RxJS ile nasıl bir arada yaşadığı.