Mert Özen Her Satırda Daha İleri
Frontend Angular — #17

Angular'da Change Detection ve Zone.js: OnPush, ChangeDetectorRef ve NgZone

Mert Özen 31 Eki 2026 7 dk 2 görüntülenme
Angular'da Change Detection ve Zone.js: OnPush, ChangeDetectorRef ve NgZone

Angular ekranı nasıl güncelliyor? Zone.js'in yakaladığı event'ler, component ağacında change detection, OnPush stratejisi, immutable veri, ChangeDetectorRef ve NgZone.runOutsideAngular.

Sürüm notu: Bu yazıdaki kod ve komutlar Angular 12 içindir.

Ekranı Kim Güncelliyor?

Serinin başından beri aynı şeyi yapıyoruz: class'taki bir property'yi değiştiriyoruz, ekran kendiliğinden güncelleniyor. Hiçbir yerde "şimdi ekranı yenile" demedik. Peki Angular bir değerin değiştiğini nereden biliyor? Değişkenlerin üzerinde nöbet tutan biri mi var?

Aslında yok. Angular değişkenleri tek tek izlemez. Onun yerine "bir şey olmuş olabilir" dediği anlarda bütün ekranı baştan kontrol eder. Bu kontrol işinin adı change detection. "Bir şey olmuş olabilir" anını haber veren muhbirin adı ise Zone.js. Bu yazıda ikisini de yakından tanıyacağız, sonra da bu mekanizmayı nasıl daha az yorabileceğimize bakacağız.

Zone.js: Her Kapıyı Dinleyen Kapıcı

Projede src/polyfills.ts dosyasını aç. Aşağılarda şu satır duruyor:

import 'zone.js';  // Included with Angular CLI.

Zone.js, uygulama başlamadan önce tarayıcının asenkron API'lerini sarar: addEventListener, setTimeout, setInterval, XMLHttpRequest, Promise ve benzerleri. Bunların hepsi artık Zone.js'in elinden geçerek çalışır. Bir apartman kapıcısı gibi düşün: kim girerse girsin, kapıcı görür ve yönetime "biri geldi" diye haber verir. Angular da bu haberi aldığında change detection'ı başlatır.

Bunu bir örnekle görelim:

@Component({
  selector: 'app-countdown',
  template: `<p>Kalan süre: {{ seconds }}</p>`
})
export class CountdownComponent implements OnInit {
  seconds = 10;

  ngOnInit(): void {
    setInterval(() => {
      if (this.seconds > 0) {
        this.seconds--;
      }
    }, 1000);
  }
}

Burada Angular'a hiçbir şey söylemiyoruz; sadece düz bir setInterval çağırıyoruz. Yine de ekrandaki sayı her saniye azalıyor. Çünkü setInterval callback'i bittiğinde Zone.js Angular'a haber veriyor, Angular da template'leri yeniden kontrol edip değişen seconds değerini ekrana basıyor. Tıklama event'leri ve HttpClient istekleri için de tam olarak aynı şey oluyor. Örnekte interval'i temizlemedik; gerçek kodda daha önce gördüğümüz ngOnDestroy içinde clearInterval yapmayı unutma.

Ağacın Tepesinden Aşağıya

Haber geldiğinde Angular ne yapıyor? Daha önce gördüğümüz component ağacını hatırla. Varsayılan davranışta Angular en tepedeki AppComponent'ten başlar ve ağacı yukarıdan aşağıya dolaşır. Her component'in template'indeki ifadeleri yeniden hesaplar, bir önceki değerle karşılaştırır ve fark varsa DOM'un sadece o kısmını günceller.

Bunu kendi gözünle görmek için bir component'e template'ten çağrılan bir metod ekle:

@Component({
  selector: 'app-product-list',
  template: `
    {{ logCheck() }}
    <app-product-card *ngFor="let p of products" [product]="p"></app-product-card>
  `
})
export class ProductListComponent {
  products: Product[] = [
    { id: 1, name: 'Mekanik Klavye', price: 1450 },
    { id: 2, name: 'Kablosuz Mouse', price: 600 }
  ];

  logCheck(): string {
    console.log('ProductListComponent kontrol edildi');
    return '';
  }
}

Product burada id, name ve price alanları olan basit bir interface. Şimdi sayfada herhangi bir yere tıkla, bir input'a yaz ya da ekranın başka bir köşesinde yukarıdaki geri sayım çalışsın. Konsolda bu satırın tekrar tekrar düştüğünü göreceksin. Bu component'le hiç ilgisi olmayan bir event bile bütün ağacın kontrolünü tetikliyor. Development modunda logları çift görmen de normal: Angular her turdan sonra değerlerin sabit kaldığından emin olmak için ikinci bir doğrulama turu yapar.

Angular bu kontrolü çok hızlı yapar ve çoğu uygulamada bu varsayılan davranış hiç sorun çıkarmaz. Ama yüzlerce satırlık tablolar, sık güncellenen grafikler ya da her saniye gelen veriler devreye girince "her şeyi her seferinde kontrol et" stratejisi pahalılaşmaya başlar.

OnPush: Sebep Yoksa Kontrol Yok

Angular'a "bu component'i her turda kontrol etme, sadece gerçekten bir sebep varsa bak" diyebilirsin:

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

@Component({
  selector: 'app-product-card',
  template: `
    <h3>{{ product.name }}</h3>
    <p>{{ product.price }} TL</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
  @Input() product!: Product;
}

Tek fark decorator'daki changeDetection: ChangeDetectionStrategy.OnPush satırı. Varsayılan değer ChangeDetectionStrategy.Default ve onu hiç yazmayız. OnPush ile işaretlenmiş bir component, change detection turu ona geldiğinde kontrol edilmeyi hak edip etmediğine bakılır; sebep yoksa o component de, altındaki bütün dal da atlanır.

Bir binadaki asansör bakımı gibi düşün. Default stratejide teknisyen her gün bütün katları gezer. OnPush'ta ise sadece arıza kaydı açılan katlara uğrar. İş yükü ciddi şekilde azalır ama arıza kaydının doğru açılması gerekir. Aksi hâlde bozuk kat gözden kaçar.

OnPush'u Ne Tetikler?

OnPush bir component şu durumlarda kontrol edilir:

Birincisi, bir @Input() değerinin referansı değiştiğinde. Parent yeni bir nesne ya da yeni bir dizi verirse Angular bunu fark eder.

İkincisi, component'in kendi template'inde ya da alt component'lerinde bir DOM event'i yakalandığında. Kartın içindeki bir butona tıklamak o kartı kontrol listesine sokar.

Üçüncüsü, template'teki bir async pipe yeni bir değer aldığında. Daha önce gördüğümüz async pipe arka planda component'i "kontrol edilmeli" diye işaretler.

Dördüncüsü, sen elle istediğinde. Bunu birazdan ChangeDetectorRef ile göreceğiz.

Listede olmayanlar da önemli. OnPush bir component'in içinde setTimeout ile bir property'yi değiştirirsen change detection turu yine çalışır ama bu component atlanır, ekran güncellenmez. Bir service'ten gelen değeri subscribe içinde elle bir property'ye atadığında da çoğu zaman aynı şey olur.

Immutable Veri: Diziyi Değiştirme, Yenisini Ver

OnPush'la en sık düşülen tuzak şu:

// ProductListComponent (parent)
addProduct(): void {
  // Wrong: same array reference, an OnPush child will not notice
  this.products.push({ id: 3, name: 'Monitör', price: 4200 });

  // Right: a new array, the reference changes
  this.products = [...this.products, { id: 3, name: 'Monitör', price: 4200 }];
}

Angular input'ları karşılaştırırken nesnenin içine bakmaz, sadece referansa bakar; yani "bu hâlâ aynı kutu mu?" diye sorar. push ile kutunun içine yeni bir şey koyarsın ama kutu aynı kalır. Diziyi bir @Input() ile alan OnPush bir child component bu değişikliği görmez. Spread operatörüyle yeni bir dizi oluşturduğunda ise elinde yeni bir kutu vardır ve child durumu fark eder.

Aynı kural nesneler için de geçerli: this.product.price = 999 yerine this.product = { ...this.product, price: 999 }. Veriyi yerinde değiştirmek yerine yeni bir kopya üretme alışkanlığına immutable veri denir. OnPush ile birlikte kullanıldığında hem performans kazandırır hem de değişikliklerin nerede olduğunu takip etmeyi kolaylaştırır.

ChangeDetectorRef: markForCheck ve detectChanges

Bazen değişiklik listede olmayan bir yoldan gelir ve Angular'ı elle uyarman gerekir. Bunun için component'e ChangeDetectorRef inject edilir:

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-cart-badge',
  template: `<span class="badge">{{ count }}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartBadgeComponent implements OnInit {
  count = 0;

  constructor(private cartService: CartService, private cdr: ChangeDetectorRef) { }

  ngOnInit(): void {
    this.cartService.count$.subscribe(count => {
      this.count = count;
      this.cdr.markForCheck();
    });
  }
}

markForCheck() bu component'i ve köke kadar bütün atalarını "bir sonraki turda kontrol et" diye işaretler. Hemen bir şey çalıştırmaz, sadece arıza kaydını açar. detectChanges() ise beklemez; bu component ve altındaki dal için change detection'ı o anda, senkron olarak çalıştırır.

Genel kural şu: çoğu durumda markForCheck() yeterlidir ve daha güvenlidir. detectChanges()'i, Angular'ın normal döngüsünün dışında kaldığın ve sonucu hemen ekranda görmen gereken özel durumlara sakla. Bu örnekte daha temiz bir yol da var: count$'ı doğrudan template'te async pipe ile kullanırsan hem markForCheck hem de aboneliği kapatma derdi ortadan kalkar.

NgZone.runOutsideAngular: Kapıcıya Haber Verme

Zone.js'in her event'i haber vermesi bazen fazla bile. Sayfa kaydırıldığında saniyede onlarca scroll event'i gelir ve her biri bütün ağacın kontrolünü tetikler. Oysa bizim tek derdimiz, kullanıcı 300 pikseli geçince bir "Başa dön" butonu göstermek.

import { Component, NgZone, OnDestroy, OnInit } from '@angular/core';

@Component({
  selector: 'app-scroll-top',
  template: `<button *ngIf="visible" (click)="scrollTop()">Başa dön</button>`
})
export class ScrollTopComponent implements OnInit, OnDestroy {
  visible = false;

  constructor(private ngZone: NgZone) { }

  ngOnInit(): void {
    this.ngZone.runOutsideAngular(() => {
      window.addEventListener('scroll', this.onScroll);
    });
  }

  ngOnDestroy(): void {
    window.removeEventListener('scroll', this.onScroll);
  }

  scrollTop(): void {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }

  private onScroll = (): void => {
    const shouldShow = window.scrollY > 300;
    if (shouldShow !== this.visible) {
      this.ngZone.run(() => (this.visible = shouldShow));
    }
  };
}

runOutsideAngular içinde kaydedilen event listener Angular'ın zone'u dışında çalışır. Kapıcı bu kapıdan gireni görmez, yönetime de haber vermez. Kullanıcı ne kadar kaydırırsa kaydırsın change detection tetiklenmez. Butonun görünürlüğünün gerçekten değişmesi gerektiği an ise ngZone.run ile Angular'ın dünyasına geri dönüyoruz ve tek bir kontrol turu çalışıyor.

onScroll'u arrow function olarak tanımlamamızın sebebi iki taraflı: this doğru component'i göstersin ve removeEventListener'a aynı fonksiyon referansını verebilelim. Bu yöntem animasyonlar, sık tetiklenen mousemove event'leri ve üçüncü parti grafik kütüphaneleri için de kullanılır. Angular'ın ilerleyen sürümlerde Zone.js'e bağımlılığı nasıl gevşettiğini sürüm yazılarında göreceğiz.

Küçük Bir Deneme

Beş-altı ürünlük bir liste component'i yaz ve her ürünü ayrı bir kart component'iyle göster. Kart template'ine yukarıdaki gibi logCheck() ekle, parent'a da her saniye bir sayaç artıran bir setInterval koy. Konsolda kartların her saniye kontrol edildiğini gör.

Sonra kartı OnPush yap ve konsolu tekrar izle. Ardından parent'a bir "Fiyatları güncelle" butonu ekle. Önce bir ürünün fiyatını this.products[0].price = 999 şeklinde yerinde değiştir ve ekranın güncellenmediğini gör. Sonra aynı işi yeni bir nesne ve yeni bir diziyle yap, farkı not et.

Bu tür davranışları her seferinde elle denemek yerine, kodun doğru çalıştığını otomatik olarak kontrol etmenin bir yolu var. Sıradaki konumuz Angular'da test: Karma, Jasmine ve TestBed ile component ve service'leri test etmek.