Mert Özen Her Satırda Daha İleri
Frontend Angular Material ve CDK — #07

CDK Scrolling ve Layout: Virtual Scroll ile Uzun Katalog, BreakpointObserver, Clipboard ve Diğer Yardımcılar

Mert Özen 15 Kas 2026 11 dk 5 görüntülenme
CDK Scrolling ve Layout: Virtual Scroll ile Uzun Katalog, BreakpointObserver, Clipboard ve Diğer Yardımcılar

CDK virtual scroll 5.000 satırlık listede DOM'da yalnız 15 satır tuttu ve sayfayı 249 ms yerine 38 ms'de çizdi; bedeli Ctrl+F'in görünmeyen satırları bulamaması ve satır yüksekliğinin sabit olması gerekmesi. Bookstore'un stok listesinde virtual scroll, content-visibility ve düz listeyi, değişken yükseklikteki zıplamaları, BreakpointObserver ile menü ve bottom sheet seçimini, clipboard ve CdkListbox'ı ölçüyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Angular Material ve CDK (Component Dev Kit; Angular ekibinin stilsiz davranış library'si) 22.2.7, .NET 10 ve Node.js 24 ile denenmiştir; tarayıcı davranışı Playwright 1.65 ile headless Chromium'da gözlenmiştir. Bookstore.Api ölçümlerde SQL Server yerine SQLite ile çalıştı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim paneline bir "Stock" sayfası ekliyoruz: üç depodaki 5.000 kitap baskısının eldeki ve rezerve adetleri, tek listede. Satırlar 48px; ekranda aynı anda on satır kadar görünüyor. Üç soru: 5.000 satırı düz bir @for ile basmak sayfayı ne kadar ağırlaştırır, CDK virtual scroll ne kadar hafifletir? Virtual scroll'lu listede kullanıcı tarayıcının Ctrl+F'i ile listenin sonlarındaki bir kitabı arayınca bulur mu? Satırların bir kısmı uzun başlık yüzünden iki satıra sararsa itemSize="48" ne yapar?

cdk-virtual-scroll-viewport ile 5.000 Satır

Virtual scroll'un fikri basit: DOM'da (Document Object Model; sayfanın tarayıcıdaki element ağacı) yalnız görünen satırlar ve üstüne altına biraz tampon var, kaydırdıkça aynı satır element'leri yeni verilerle dolduruluyor. Listenin gerçek yüksekliğini boş bir spacer element'i taşıyor; scrollbar onun sayesinde 5.000 satırlık bir liste gibi davranıyor. Bunun için CDK'nın tek bilmesi gereken şey bir satırın yüksekliği:

<div class="header" aria-hidden="true">
  <span>Title</span><span>ISBN</span><span>Warehouse</span><span class="num">On hand</span><span class="num">Reserved</span>
</div>
<!-- itemSize satırın gerçek yüksekliğine eşit olmalı: viewport pozisyonları buradan hesaplar -->
<cdk-virtual-scroll-viewport itemSize="48" class="viewport" role="list" aria-label="Stock">
  <div class="row" role="listitem" *cdkVirtualFor="let row of rows(); trackBy: trackById">
    <span class="title">{{ row.title }}</span>
    <span class="isbn">
      {{ row.isbn }}
      <button matIconButton type="button" [cdkCopyToClipboard]="row.isbn" (cdkCopyToClipboardCopied)="copied($event, row.isbn)"
              [attr.aria-label]="'Copy ISBN ' + row.isbn">
        <mat-icon>content_copy</mat-icon>
      </button>
    </span>
    <span>{{ row.warehouse }}</span>
    <span class="num">{{ row.onHand }}</span>
    <span class="num">{{ row.reserved }}</span>
  </div>
</cdk-virtual-scroll-viewport>
.viewport {
  height: 60vh;
}

// Sabit yükseklik: itemSize ile aynı 48px, uzun başlıklar sarmak yerine kesilir
.row {
  height: 48px;
  box-sizing: border-box;
  border-bottom: 1px solid var(--mat-sys-surface-container-high);

  .title {
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
}

Viewport'un kendi yüksekliği olmalı (burada 60vh); yüksekliği içeriğe göre büyüyen bir viewport bütün satırları basar ve virtual scroll'un anlamı kalmaz. *cdkVirtualFor, @for'un yerini alıyor; kontrol akışı syntax'ı yok, bu yüzden trackBy function'ı da yine bizde. Bookstore.Api'ye demo amaçlı, deterministik üretilmiş 5.000 satırlık bir GET /api/admin/stock ekledik; JSON 627.103 byte ve tek seferde geliyor. Bu kararı aşağıda tartışıyoruz.

Production build'i servis edip dashboard'dan "Stock" link'ine tıklamadan satırların ekrana gelmesine kadar geçen süreyi, DOM'daki element sayısını ve JavaScript heap'ini ölçtük. Aynı sayfayı iki kez daha derledik: biri 5.000 satırı düz @for ile basıyor, diğeri aynısına CSS content-visibility: auto ekliyor:

virtual (prod)   : rows in DOM 15 | elements 286 | 38 ms from click to rendered rows | JS heap 10 MB
all rows (prod)  : rows in DOM 5000 | elements 55118 | 249 ms from click to rendered rows | JS heap 23 MB
c-visibility(prod): rows in DOM 5000 | elements 55118 | 121 ms from click to rendered rows | JS heap 22 MB

Virtual scroll DOM'da 15 satır tuttu (480px'lik viewport'ta görünen 10 satır, üstüne CDK'nın varsayılan 100–200px'lik tamponu) ve sayfa 38 ms'de çizildi. Düz liste 55.118 element üretti ve 249 ms sürdü; bu süre her filtre değişiminde ve her yeniden render'da tekrar ödeniyor. content-visibility: auto ekranda olmayan satırların layout ve paint'ini atladığı için süreyi 121 ms'ye indirdi, ama element'lerin hepsi DOM'da ve bellekte kaldı. Süreler bu makinede ölçüldü; daha yavaş bir laptop'ta fark büyür, küçülmez.

Kaydırırken satırların yeniden kullanıldığını da gördük:

scrolling        : scrollTop 0: rows 15 first "The Hobbit · Hardcover #1" last "The Great Gatsby · Hardcover #15"
                 : scrollTop 100000: rows 17 first "Emma · Annotated #2082" last "The Count of Monte Cristo · Annotated #2098"
                 : scrollTop 239500: rows 13 first "Moby-Dick · Annotated #4988" last "Dune · Hardcover #5000"

100.000px aşağıda DOM'daki ilk satır #2082 (100.000 / 48 ≈ 2083), en altta son satır #5000. Satır sayısı 13 ile 17 arasında kaldı.

Ctrl+F, Arama ve Screen Reader

Tahminin ikinci sorusu. Ctrl+F'i Playwright'tan basamadığımız için Chromium'un aynı arama motorunu kullanan standart dışı window.find()'ını çağırdık:

virtual (prod)   : window.find("The Great Gatsby · Hardcover #15") true
virtual (prod)   : window.find("Anna Karenina · Hardcover #4013") false
all rows (prod)  : window.find("The Great Gatsby · Hardcover #15") true
all rows (prod)  : window.find("Anna Karenina · Hardcover #4013") true
c-visibility(prod): window.find("The Great Gatsby · Hardcover #15") true
c-visibility(prod): window.find("Anna Karenina · Hardcover #4013") true

Ekrandaki kitap üçünde de bulundu; listenin sonlarındaki #4013 yalnız virtual scroll'da bulunamadı, çünkü o satır DOM'da yok. content-visibility: auto ise satırı çizmediği hâlde aramaya açık bıraktı. Aynı sebep screen reader için de geçerli: virtual listede screen reader yalnız DOM'daki 15 satırı görür, "liste, 5.000 öğe" diyemez. Bu yüzden virtual scroll'lu bir sayfa kendi aramasını getirmeli. Stok sayfasının başlık ve ISBN (International Standard Book Number; kitabın uluslararası numarası) araması bellekteki 5.000 satırın üstünde çalışıyor:

// Ctrl+F render edilmeyen satırları göremez, bu yüzden sayfanın kendi araması var
protected readonly rows = computed(() => {
  const term = this.search().trim().toLowerCase();
  const rows = this.stock.value();
  return term ? rows.filter(r => r.title.toLowerCase().includes(term) || r.isbn.includes(term)) : rows;
});

Hangisini seçmeli? Liste gerçekten uzun ve kullanıcı içinde kaydırarak geziniyorsa (stok, log, mesaj listesi) virtual scroll. Kullanıcı belirli bir kaydı arıyorsa ve sonuçlar sayfa sayfa anlamlıysa 03. yazıdaki server-side sayfalama: tarayıcıya 627 kB yerine 25 satır gelir. Liste birkaç yüz ile birkaç bin satır arasındaysa, Ctrl+F ve screen reader'ın bütün listeyi görmesi önemliyse content-visibility: JavaScript gerektirmez, MDN'in (Mozilla Developer Network; Mozilla'nın web dokümantasyonu) uyumluluk verisine göre Chrome 85, Firefox 125 ve Safari 18'den beri var. Bizim stok sayfamızda depo personeli listeyi kaydırarak sayım yapıyor ve arama kutusu var; virtual scroll'u seçtik.

Satır Yüksekliği Sabit Değilse

Tahminin üçüncü sorusu. Bir denemede başlıkların sarmasına izin verdik ve her üç satırdan birine uzun bir alt başlık ekledik; satırlar 48px ile 221px arasında değişti. Sonra listeyi 10'ar piksellik 400 adımda kaydırıp ekrandaki bir satırın kaydırma kadar mı yoksa daha fazla mı yer değiştirdiğini saydık:

fixed 48px rows       : row heights 48px | spacer 240000px | 400 scroll steps: 0 jumps (max 0px), 0 with blank space at the bottom
wrapping, itemSize 48 : row heights 48/61/81/101/121/141/161/181/201/221px | spacer 240000px | 400 scroll steps: 22 jumps (max 219px), 0 with blank space at the bottom
wrapping, autosize    : row heights 48/61/81/101/121/161/181/201/221px | spacer 542199px | 400 scroll steps: 0 jumps (max 0px), 0 with blank space at the bottom

Sabit itemSize ile CDK render edilen bloğu "ilk satırın index'i × 48" kadar aşağı kaydırıyor. Gerçek satırlar daha uzunsa bu hesap tutmuyor: 400 adımın 22'sinde ekrandaki satır 219px'e kadar zıpladı. Kullanıcı bunu, okuduğu satırın kaydırırken birden kaybolması olarak görür. Üçüncü satırdaki autosize strategy'si satırları render ettikçe ölçüyor ve zıplama sıfıra indi; ama bu strategy ana @angular/cdk paketinde değil, @angular/cdk-experimental/scrolling'de. 22.2.7'de paket hâlâ "experimental" adını taşıyor; denemeyi npm i --no-save ile yaptık ve snapshot'a koymadık. Üretimde tercihimiz satır yüksekliğini sabitlemek: başlık kesiliyor (text-overflow: ellipsis), tamamı bir tooltip'te ya da detay sayfasında. Kart gibi gerçekten değişken yükseklikli içerik için ya sayfalama ya da content-visibility daha az sürpriz çıkarır.

BreakpointObserver: Ekran Genişliğine Göre Davranış

01. yazıda admin shell'in sidenav'ını BreakpointObserver ile telefonda over moduna almıştık; mantık orada. 04. yazıda bir soru açık kalmıştı: seçili siparişlerin toplu işlemleri telefonda bottom sheet olarak açılıyor, masaüstünde ise ekranın altından açılan bir sheet tuhaf duruyor. Burada farklı olan şey görünüm değil, hangi component'in açılacağı; CSS bunu çözemez:

// Aynı işlemler: telefonda bottom sheet (başparmağın ulaştığı yer), geniş ekranda butonun yanında bir menü
protected readonly handset = toSignal(
  inject(BreakpointObserver).observe(Breakpoints.Handset).pipe(map(state => state.matches)),
  { initialValue: false },
);
@if (selection.selected.length) {
  @if (handset()) {
    <button matButton="tonal" type="button" (click)="openActions()">Actions</button>
  } @else {
    <button matButton="tonal" type="button" [matMenuTriggerFor]="actionsMenu">Actions</button>
  }
}
<mat-menu #actionsMenu="matMenu">
  <button mat-menu-item type="button" (click)="runAction('cancel')">
    Cancel {{ selection.selected.length }} {{ selection.selected.length === 1 ? 'order' : 'orders' }}
  </button>
  <button mat-menu-item type="button" (click)="runAction('clear')">Clear selection</button>
</mat-menu>
1280x800 : mat-menu role=menu at y=192-304 | items "Cancel 2 orders", "Clear selection" | focus "Cancel 2 orders"
390x844  : bottom sheet role=dialog at y=716-844 | items "Cancel 2 orders", "Clear selection" | focus "Cancel 2 orders"

Geniş ekranda role="menu" bir mat-menu butonun hemen altında açıldı, telefonda bottom sheet ekranın alt kenarında; ikisinde de focus ilk işlemde. Breakpoints.Handset CDK'nın tanımında iki media query: dikeyde 600px'ten, yatayda 960px'ten dar ekranlar. Yani 900px genişliğinde yatay tutulan bir tablet de "handset" sayılıyor ve bottom sheet alıyor; kendi breakpoint'in farklıysa media query'yi kendin yaz.

Aynı yazıda tersini de yaptık. Hesap butonu 390px'te toolbar'a sığmıyordu ve yalnız düzeni ilgilendiren bu sorunu bir media query ile çözdük:

// Yalnız düzeni değiştiren bir şey media query'dir, BreakpointObserver değil: telefonda hesap butonu yalnız ikonunu gösterir
// (isim butonun adı olarak accessibility tree'de kalır)
@media (max-width: 599.98px) {
  .user-name {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
before 390px | overflowing: menu (42px in 36px), account_circle Ada Lovelace (98px in 89px) | button named "Ada Lovelace": 1
after  390px | overflowing: menu (42px in 36px) | button named "Ada Lovelace": 1

Hesap butonu artık taşmıyor ve screen reader için adı hâlâ "Ada Lovelace". (menu ikon butonundaki 6px'lik fark her iki ölçümde de var; Material'ın ripple element'inden geliyor ve görünür bir taşma değil.)

Clipboard, Listbox ve Diğer Yardımcılar

Stok sayfasında iki CDK yardımcısı daha var. cdkCopyToClipboard ISBN'i panoya kopyalıyor; CDK'nın kaynak koduna göre bunu görünmez bir textarea ve document.execCommand('copy') ile yapıyor ve sonucu cdkCopyToClipboardCopied ile boolean olarak veriyor; kopyalama başarısızsa kullanıcıya bunu söylemek bize kalıyor. Depo filtresi CdkListbox: role="listbox", ok tuşlarıyla gezinme ve seçim CDK'dan, görünüm bizim:

<!-- CdkListbox: role="listbox", ok tuşları ve seçim CDK'dan, görünüm bizim -->
<ul class="warehouses" cdkListbox cdkListboxOrientation="horizontal" aria-label="Warehouse"
    [cdkListboxValue]="[warehouse()]" (cdkListboxValueChange)="warehouseChanged($event.value)">
  @for (w of warehouses; track w) {
    <li class="chip" [cdkOption]="w">{{ w || 'All' }}</li>
  }
</ul>
copy          : 9786050000003 | snackbar Copied 9786050000003
listbox       : role=listbox aria-orientation=horizontal options=All*,Istanbul,Ankara,Izmir
ArrowRight    : active=Istanbul selected=All | 5000 rows
ArrowRight x2 : active=Izmir selected=All | 5000 rows
Space         : active=Izmir selected=Izmir | 1554 rows

Ok tuşları yalnız aktif seçeneği taşıdı, seçim Space ile değişti ve API'ye ?warehouse=Izmir gitti; ok tuşuyla her geçişte istek atılmaması bu filtre için doğru davranış. Aynı ailede CdkMenu (menubar ve context menu), cdkTextareaAutosize (02. yazıda field-sizing ile karşılaştırdık), CdkAccordion, CdkStepper ve CdkTree var. Hepsi davranışı verip görünümü sana bırakıyor; klavye ve ARIA (Accessible Rich Internet Applications; screen reader'a role ve state bildiren attribute'lar) davranışlarını derinlemesine Accessibility (a11y) serisinin "Angular'da Accessible Component'ler: Angular Aria, CDK a11y, Material ve Kendi Combobox'ını Yazmamak" yazısı anlatıyor. Yeni bir component yazacaksan oradaki Angular Aria seçeneğine de bak.

07-stock-sheet-tr.png

Üretimde Dikkat

Virtual scroll DOM'u küçültür, veriyi küçültmez. 5.000 satır tek istekte 627.103 byte geliyor ve hepsi bellekte. Liste on binlere çıkarsa sorun render değil, ağ ve bellek olur; o noktada server-side sayfalama ya da sonsuz kaydırmada parça parça yükleme gerekir.

Sayfanın maliyeti. Stok sayfasının chunk'ı production build'de 20.64 kB (transfer 5.80 kB) ve lazy; virtual scroll, listbox ve clipboard yalnız bu sayfayla geliyor.

Testler. jsdom'da (Node.js içinde çalışan tarayıcı benzeri DOM) layout yok, viewport'un yüksekliği sıfır; bu yüzden birim testlerde satır render etmeyi değil, aramayı ve listbox'ın API isteğini test ettik. Gerçek kaydırma Playwright'ta:

 ✓ src/app/admin/stock/stock-page.spec.ts > StockPage > searches the loaded rows by title or ISBN, because Ctrl+F cannot see rows that are not rendered
 ✓ src/app/admin/stock/stock-page.spec.ts > StockPage > loads one warehouse when it is picked in the listbox
 ✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > offers the bulk actions as a menu on wide screens and as a bottom sheet on handsets
      Tests  30 passed (30)

Sık Düşülen Tuzaklar

Birincisi, değişken yükseklikli kartları sabit itemSize ile göstermek. Ölçümde 22 zıplama ve 219px'lik kayma; çözüm satırı sabitlemek ya da virtual scroll'u bırakmak.

İkincisi, virtual scroll'da Ctrl+F'in çalışacağını sanmak. DOM'da olmayan satır bulunmaz; sayfaya kendi aramasını koy.

Üçüncüsü, BreakpointObserver ile CSS'in yapacağı işi JavaScript'e taşımak. Bir şeyi gizlemek, yan yana dizmek, boyutunu değiştirmek media query ya da container query işidir; JavaScript'teki breakpoint SSR'da (server-side rendering; sayfanın sunucuda render edilmesi) ekran genişliğini bilmediği için yanlış değerle başlar ve tarayıcıda bir anlık kayma üretir; CSS ise ilk çizimde doğru düzeni verir. BreakpointObserver'ı yalnız farklı bir component ya da davranış gerektiğinde kullan.

Dördüncüsü, viewport'a yükseklik vermemek. Yüksekliği içeriğe göre uzayan bir cdk-virtual-scroll-viewport bütün satırları render eder.

Beşincisi, 50 satırlık bir listeye virtual scroll koymak. Ctrl+F'i, screen reader'ın liste boyutunu ve satır yüksekliği esnekliğini kaybedersin, karşılığında ölçülebilir bir şey kazanmazsın.

Küçük Bir Deneme

★☆☆ cdk-virtual-scroll-viewport'a minBufferPx="400" maxBufferPx="800" ver. DOM'daki satır sayısı ve hızlı kaydırmada beyaz alan görünüp görünmediği nasıl değişiyor?

★★☆ Stok listesini server-side'a çevir: API offset ve limit alsın, istemci kendi DataSource'unu yazıp viewport.renderedRangeStream'e göre yalnız görünen aralığı yüklesin. 5.000 satırın tamamı hiç gelmeden listenin sonuna kaydırılabiliyor mu?

★★★ Listeyi screen reader için düzelt: satırlara aria-setsize="5000" ve aria-posinset ver. Playwright'ın ariaSnapshot()'ı ve Chromium'un accessibility tree'si bu bilgiyi gösteriyor mu? Rolü list'ten grid'e çevirmek ne değiştirir?

Material ve CDK'nın component'lerini ve yardımcılarını gördük. Serinin son yazısında görünüme dönüyoruz: Material'ı token override'larıyla Bookstore'un kurumsal portal temasına uyduruyor, density ölçeğini ölçüyor, Material'a uymayan bir ihtiyaç için CDK ile kendi component'imizi yazıyor ve Angular Material 12'den 22'ye neyin değiştiğini, eski .mat-* override'larının neden artık çalışmadığını anlatıyoruz.