CLS'i Optimize Etmek: Boyutsuz Görseller, Geç Gelen İçerik, Font Değişimi ve @defer Placeholder'ları
CLS'i düşürmek için sonradan gelen her içeriğin yerini en baştan ayırmak gerekir: boyutlu görsel, son boyutta placeholder ve skeleton. Bookstore'un kitap sayfasına eklediğimiz "Similar books" bloğunun impact × distance hesabını ölçüyor, @defer (on viewport) ile kaymanın scroll anına taşındığını (telefonda 0,1079) gösteriyor ve slot, skeleton ve margin collapse düzeltmesiyle her senaryoda CLS'i 0'a indiriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) ve Playwright 1.65 ile denenmiştir. Bütün sayılar tek bir makinede, yerel build üzerinde, headless Chromium 158 ile alınmış lab ölçümleridir. Tarayıcı destek bilgileri MDN'in (Mozilla'nın web dokümantasyonu) browser-compat-data 8.1.9 sürümünden (2026-11-12).
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kitap detay sayfasına bir "Similar books" bloğu ekliyoruz: aynı kategoriden dört kitap, kitabın bilgileri ile "Details" bölümü arasında. Blok kendi verisini ayrı bir endpoint'ten alıyor:
books.MapGet("/{isbn}/similar", Results<Ok<IEnumerable<Book>>, NotFound> (string isbn, int? count, BookCatalog catalog) =>
catalog.ByIsbn.TryGetValue(isbn, out Book? book)
? TypedResults.Ok(catalog.Similar(book, Math.Clamp(count ?? 4, 1, 8)))
: TypedResults.NotFound());
template: `
@if (similar.value(); as books) {
<h2>Similar books</h2>
<ul class="cards">
@for (book of books; track book.isbn) {
<li>
<a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
<span>{{ book.author }}</span>
<span class="price">{{ book.price | currency: book.currency }}</span>
</li>
}
</ul>
}
Kitap sayfası bloğu, kitabın kendisi geldikten sonra kartın bilgilerinin hemen altına koyuyor. Tahmin et: masaüstünde ve telefonda sayfa yüklenirken CLS (Cumulative Layout Shift; sayfanın ömrü boyunca beklenmedik kaymaların toplam puanı) ne çıkar? Bloğun JavaScript'ini @defer (on viewport) ile ertelersek iyileşir mi, kötüleşir mi?
Bir Kayma Nasıl Puanlanıyor?
Tarayıcı iki frame arasında yeri değişen her görünür element'i "unstable element" sayıyor ve o frame için bir layout shift puanı hesaplıyor: impact fraction ile distance fraction'ın çarpımı. Impact fraction, kayan element'lerin eski ve yeni konumlarının viewport'ta kapladığı birleşik alanın viewport alanına oranı. Distance fraction, herhangi bir element'in yatay ya da dikey olarak en çok yer değiştirdiği mesafenin viewport'un büyük kenarına oranı. Sayfaya yeni bir element'in eklenmesi tek başına kayma değil; kayma, eklenen element'in itip yerinden oynattığı mevcut element'lerden geliyor.
CLS bu puanların düz toplamı değil. web.dev'in tanımına göre kaymalar session window'lara gruplanıyor: aralarında 1 saniyeden az olan kaymalar aynı pencerede, bir pencere en fazla 5 saniye. Sayfanın CLS'i en büyük pencerenin toplamı. Böylece saatlerce açık kalan bir sayfa, küçük kaymaları biriktirip kötü puan almıyor. İkinci kural kullanıcı girdisi: bir tıklama, dokunma ya da tuşa basıştan sonraki 500 ms içindeki kaymalar hadRecentInput işaretiyle geliyor ve hesaba katılmıyor. Kullanıcının açtığı bir menünün altındaki içeriği itmesi beklenen bir kayma. Scroll bu istisnaya girmiyor. Eşikler ilk yazıdaki gibi: 0,1 ve altı "good", 0,25 üstü "poor", p75'te.
Bu hesabı ölçerek görmek için lab'a cls-trace.mjs'i ekledik. Script layout-shift entry'lerini bir PerformanceObserver ile topluyor, her kaymada yerinden oynayan element'leri eski ve yeni dikdörtgenleriyle yazıyor, en büyük kayma için impact ve distance'ı kendisi hesaplıyor ve kaymaları session window'lara ayırıyor:
await page.addInitScript(() => {
window.__shifts = [];
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
window.__shifts.push({
time: Math.round(e.startTime),
value: e.value,
hadRecentInput: e.hadRecentInput,
sources: e.sources.map((s) => ({
node: s.node ? s.node.nodeName.toLowerCase() + (s.node.className ? '.' + String(s.node.className).split(' ')[0] : '') : '(removed)',
prev: [s.previousRect.x, s.previousRect.y, s.previousRect.width, s.previousRect.height].map(Math.round),
curr: [s.currentRect.x, s.currentRect.y, s.currentRect.width, s.currentRect.height].map(Math.round),
})),
});
}
}).observe({ type: 'layout-shift', buffered: true });
});
await page.goto(positionals[0], { waitUntil: 'networkidle', timeout: 120_000 });
// Okumaya devam eden kullanıcı: 400 px'lik adımlarla aşağı scroll (tekerlek hareketi "recent input" sayılmaz)
for (let i = 0; i < Number(values.scroll); i++) {
await page.mouse.wheel(0, 400);
await page.waitForTimeout(600);
}
if (values.click) {
await page.locator(values.click).click();
}
LayoutShift entry'si ve sources listesi yalnız Chromium'da var; browser-compat-data'ya göre Firefox ve Safari'de layout shift API'si yok, yani bu tarayıcılardan gelen RUM (gerçek kullanıcı ölçümü) verisinde CLS hiç olmayacak.
Önce Bir Hatırlatma: Boyutsuz Kapak
Görsel ve font yazısında kitap sayfasındaki kapağın CLS'ini NgOptimizedImage'ın istediği width ve height ile sıfırlamıştık; field simülasyonundaki p75 0,32 idi. Aynı sayfada kapağı tek bir koşu için yeniden boyutsuz bir <img> yaptık ve telefon profilinde izledik:
viewport 412x839
time(ms) score recentInput element: previous [x,y,w,h] -> current
1612 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
1656 0.5422 false div: [16,94,380,253] -> [16,664,380,175]
div.similar-slot: [16,347,380,296] -> [0,0,0,0]
section.details: [16,662,380,177] -> [0,0,0,0]
largest shift at 1656 ms: impact 0.7981 x distance 0.6794 = 0.5422 (browser: 0.5422)
session window 1: 1612-1656 ms, 2 shift(s), 0.5422
CLS = largest session window = 0.5422; excluded (recent input): 0
Görselin boyutu, dosyanın başı indiği anda belli oluyor ve sıfır yükseklikteki <img> bir anda 380×570'e çıkıyor. Altındaki metin bloğu 570 px aşağı iniyor: eski ve yeni konumu viewport'un yaklaşık %80'ini kaplıyor, mesafe de 570/839. Tek bir kayma 0,54, yani "poor". Script'in hesabı (0,7981 × 0,6794) tarayıcının değeriyle aynı çıkıyor. İkinci ve üçüncü satırlardaki [0,0,0,0], viewport'un dışına itilen element'ler: alanları hesaba girmiyor. En üstteki nav kayması da font swap: Inter yüklenince "Bookstore" logosu biraz genişliyor ve yanındaki link 5 px sağa kayıyor. Puanı dört basamakta 0, çünkü element küçük ve az kayıyor; görsel ve font yazısındaki fallback metrikleri işini görüyor.
Benzer Kitaplar Bloğu Sayfayı İtiyor
Şimdi asıl konu. Kapak boyutlu, ilk bloğun eklendiği hâl (A) masaüstünde:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
364 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
407 0.0385 false section.details: [141,569,1068,234] -> [141,735,1068,205]
largest shift at 407 ms: impact 0.3122 x distance 0.1230 = 0.0384 (browser: 0.0385)
session window 1: 364-407 ms, 2 shift(s), 0.0385
CLS = largest session window = 0.0385; excluded (recent input): 0
Kitabın verisi geliyor, sayfa çiziliyor, "Similar books" için ikinci istek gidiyor ve cevap geldiğinde (407. ms) blok 166 px yer kaplıyor. Altındaki "Details" bölümü 569'dan 735'e iniyor; impact 0,31, distance 166/1350 = 0,123, puan 0,0385. Beş koşuda da aynı:
http://127.0.0.1:5300/books/9781000000009 profile=desktop runs=5
run TTFB FCP LCP CLS
1 41 276 396 0.0385
2 41 276 396 0.0385
3 41 284 396 0.0385
4 41 272 392 0.0385
5 41 292 412 0.0385
med 41 276 396 0.0385
LCP subparts (median): ttfb=41 loadDelay=284 loadDuration=54 renderDelay=17
LCP element: html>body>app-root>main>app-book-page>article.book>img
0,0385 "good" sınırının altında, ama tek bir blok bütçenin üçte birini harcıyor ve gerçek bir sayfada çerez banner'ı, reklam ya da geç gelen bir kampanya şeridi aynı session window'a eklenir. Telefonda ise aynı yüklemede CLS 0: kapak ekranın büyük kısmını kaplıyor, "Details" yüklenme anında viewport'un altında kalıyor ve görünmeyen element'lerin kayması sayılmıyor.
@defer Placeholder'ı Gerçek Boyutta Değilse
Blok sayfanın aşağısında; kodunu ve isteğini ilk yüklemeden çıkarmak doğal bir adım. Angular'ın @defer (on viewport) bloğu, placeholder'ı viewport'a girene kadar component'in chunk'ını indirmiyor (B):
@defer (on viewport) {
<app-similar-books [isbn]="b.isbn" />
} @placeholder {
<p>Loading similar books...</p>
}
Build çıktısında similar-books artık 1,54 KB'lık lazy bir chunk. Masaüstünde CLS 0,0385'ten 0,0453'e çıktı, çünkü artık iki kayma var: placeholder kaldırılırken "Details" 40 px yukarı çıkıyor, blok gelince 166 px aşağı iniyor. Telefonda yükleme ölçümü yine 0. Ama kullanıcı yalnız sayfayı açıp beklemez, okur. measure.mjs'e bir --scroll seçeneği ekledik; yüklemeden sonra 400 px'lik üç tekerlek hareketi yapıyor:
viewport 412x839
time(ms) score recentInput element: previous [x,y,w,h] -> current
1604 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
3505 0.0139 false section.details: [16,613,380,226] -> [16,573,380,234]
3673 0.0940 false section.details: [16,536,380,234] -> [16,819,380,20]
largest shift at 3673 ms: impact 0.2792 x distance 0.3373 = 0.0942 (browser: 0.0940)
session window 1: 1604-1604 ms, 1 shift(s), 0.0000
session window 2: 3505-3673 ms, 2 shift(s), 0.1079
CLS = largest session window = 0.1079; excluded (recent input): 0
Placeholder viewport'a girince chunk iniyor, istek gidiyor ve blok, kullanıcının o anda okuduğu "Details" bölümünü 283 px aşağı itiyor. CLS 0,1079, "needs improvement". Yalnız yüklemeye bakan bir lab ölçümü bu sayfa için 0 diyordu. A hâlinde aynı scroll senaryosu 0 çıkıyor, çünkü blok kullanıcı scroll etmeden önce gelmişti: @defer kaymayı yok etmedi, kullanıcının gördüğü ana taşıdı. Angular'ın @defer rehberi de ilk yüklemede görünen içeriği ertelemenin CLS'i artırabileceği konusunda uyarıyor.
RUM tarafında bu tür kaymayı ayırt etmek için ilk yazıdaki rum.ts'e iki alan ekledik:
case 'CLS':
return {
target: metric.attribution.largestShiftTarget,
largestShift: round(metric.attribution.largestShiftValue ?? 0, 4),
// Ne zaman oldu: loadState 'complete' = load event'inden sonra (scroll, geç gelen içerik)
time: round(metric.attribution.largestShiftTime ?? 0),
loadState: metric.attribution.loadState,
};
{
"name": "CLS",
"value": 0.1079,
"rating": "needs-improvement",
"device": "mobile",
"navigationType": "navigate",
"attribution": {
"target": "html>body>app-root>main>app-book-page>section.details",
"largestShift": 0.094,
"time": 3673,
"loadState": "complete"
},
"route": "/books/:isbn"
}
loadState: "complete" ve 3,7 sn'lik time, kaymanın sayfa yüklendikten sonra olduğunu söylüyor. Field verisinde bu ikili çoğunlukla scroll ya da geç gelen içerik demek; lab'da da o senaryoyu kurmak gerekir.
Yer Ayırmak: Slot, Skeleton ve Margin Collapse
Çözüm, bloğun son hâlinin yerini en baştan ayırmak. Kartların yüksekliği sabit (104 px), başlığın yüksekliği belli; bu yüzden bloğu sabit bir min-height'a sahip bir slot'un içine koyuyoruz. Placeholder, veri gelene kadar gösterilen skeleton kartlar ve gerçek kartlar aynı yere sığıyor (C1). İlk deneme masaüstünde tam sıfır çıkmadı:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
334 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
367 0.0058 false section.details: [141,736,1068,204] -> [141,760,1068,180]
div.similar-slot: [141,550,1068,167] -> [141,574,1068,167]
417 0.0035 false section.details: [141,760,1068,180] -> [141,791,1068,149]
largest shift at 367 ms: impact 0.3282 x distance 0.0178 = 0.0058 (browser: 0.0058)
session window 1: 334-417 ms, 3 shift(s), 0.0094
CLS = largest session window = 0.0094; excluded (recent input): 0
İki küçük kayma kaldı ve ikisi de klasik. 367 ms'de slot'un kendisi 24 px aşağı iniyor: component'in ilk element'i olan h2'nin 24 px'lik margin-top'u, padding'i ve border'ı olmayan host element'ten ve slot'tan dışarı taşıyor (margin collapsing) ve slot'u itiyor. 417 ms'de ise "Show more" düğmesi veri gelince kartların altında beliriyor ve "Details"i 31 px daha itiyor. Son hâl (C2) ikisini de kapatıyor: host'ta margin-top yerine padding-top, düğme de başlıkla aynı sabit yükseklikteki satırda:
template: `
<!-- Başlık ve düğme sabit yükseklikli tek satırda: düğme sonradan belirince hiçbir şey kaymaz -->
<div class="head">
<h2>Similar books</h2>
@if (!showAll() && (similar.value()?.length ?? 0) > 4) {
<button type="button" class="more" (click)="showAll.set(true)">Show more</button>
}
</div>
<ul class="cards">
@for (book of visible(); track book.isbn) {
<li>
<a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
<span>{{ book.author }}</span>
<span class="price">{{ book.price | currency: book.currency }}</span>
</li>
} @empty {
<!-- Yüklenirken skeleton: aynı kartlar, aynı yükseklik, veri gelince hiçbir şey kaymaz -->
@for (i of skeleton; track i) {
<li class="skeleton" aria-hidden="true"></li>
}
}
</ul>
`,
styles: `
/* Başlığın margin-top'u yerine padding: ilk child'ın üst margin'i slot'un dışına taşar (margin collapsing) */
:host { display: block; padding-top: 24px; }
.head { display: flex; align-items: center; justify-content: space-between; height: 40px; margin-bottom: 12px; }
h2 { margin: 0; font-size: 22px; }
.cards { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.cards li { display: flex; flex-direction: column; gap: 4px; height: 104px; padding: 12px;
border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: hidden; }
.cards li.skeleton { background: #e2e8f0; }
.price { font-weight: 700; margin-top: auto; }
.more { padding: 6px 12px; font: inherit; }
@media (max-width: 640px) { .cards { grid-template-columns: repeat(2, 1fr); } }
`,
})
export class SimilarBooks {
readonly isbn = input.required<string>();
protected readonly similar = httpResource<Book[]>(() => `/api/books/${this.isbn()}/similar?count=8`);
// Önce 4 kart, kalan 4'ü "Show more" arkasında
protected readonly showAll = signal(false);
protected readonly skeleton = [1, 2, 3, 4];
<!-- Slot bloğun son yüksekliğini korur: placeholder, skeleton ve kartlar aynı alana sığar -->
<div class="similar-slot">
@defer (on viewport) {
<app-similar-books [isbn]="b.isbn" />
} @placeholder {
<div></div>
}
</div>
/* 24 px padding + 40 px başlık satırı + 12 px boşluk + 104 px'lik bir kart satırı; dar ekranda iki satır */
.similar-slot { min-height: 180px; }
.details dl { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; }
.details dt { color: #64748b; }
.details dd { margin: 0; }
@media (max-width: 640px) {
.book { grid-template-columns: 1fr; }
.similar-slot { min-height: 296px; }
}
http://127.0.0.1:5300/books/9781000000009 profile=desktop runs=5
run TTFB FCP LCP CLS
1 41 268 372 0
2 41 276 392 0
3 41 276 372 0
4 41 280 392 0
5 41 268 372 0
med 41 276 372 0
LCP subparts (median): ttfb=41 loadDelay=276 loadDuration=50 renderDelay=13
LCP element: html>body>app-root>main>app-book-page>article.book>img
http://127.0.0.1:5300/books/9781000000009 profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 1336 1904 0
2 151 1352 1916 0
3 151 1340 1908 0
4 150 1332 1904 0
5 151 1336 1904 0
med 151 1336 1904 0
LCP subparts (median): ttfb=151 loadDelay=1349 loadDuration=387 renderDelay=18
LCP element: html>body>app-root>main>app-book-page>article.book>img
| Durum | Masaüstü, yükleme | Telefon, yükleme | Telefon, yükleme + scroll |
|---|---|---|---|
| A: blok, yer ayrılmadan | 0,0385 | 0 | 0 |
B: A + @defer (on viewport), küçük placeholder | 0,0453 | 0 | 0,1079 |
C1: min-height'lı slot + skeleton | 0,0094 | 0 | 0,0101 |
C2: C1 + padding-top, düğme başlık satırında | 0 | 0 | 0 |
Sabit bir min-height içerik değiştiğinde güncellenmesi gereken bir sayı; bu yüzden CSS'te nereden geldiğini yazdık. Satır sayısı değişen bir listede aspect-ratio ya da her kart için sabit yükseklik daha sağlam. Sayfanın uzun ve ekran dışındaki kısımları için bir seçenek daha var: content-visibility: auto ile birlikte contain-intrinsic-size, render edilmeyen bölüme tahmini bir boyut veriyor; boyut yanlışsa scroll sırasında kayma ve scrollbar zıplaması oluyor. Bunu bu yazıda ölçmedik.
500 ms İstisnası: Kullanıcının Açtığı Kayma Sayılmıyor
"Show more" düğmesi dört kart daha açıyor ve "Details"i 116 px itiyor. Bu bilinçli bir kayma; kullanıcı tıkladı ve içeriğin büyümesini bekliyor:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
335 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
1553 0.0138 true section.details: [141,749,1068,191] -> [141,865,1068,75]
largest shift at 335 ms: impact 0.0012 x distance 0.0037 = 0.0000 (browser: 0.0000)
session window 1: 335-335 ms, 1 shift(s), 0.0000
CLS = largest session window = 0.0000; excluded (recent input): 1
Tıklamadan sonraki kayma 0,0138 puanlı ama hadRecentInput: true ve CLS'e girmiyor. Kural 500 ms ile sınırlı: düğme yeni içeriği sunucudan isteseydi ve cevap yavaş bir telefonda yarım saniyeden geç gelseydi, aynı kayma sayılırdı. Tıklamada hemen alan ayırmak (skeleton) ya da içeriği önceden yüklemek bu yüzden güvenli taraf.
Diğer Tipik Kaynaklar ve Angular'a Özgü Olanlar
Sonradan eklenen banner, çerez bildirimi ve reklam alanı aynı mantıkla çözülüyor: ya yeri baştan ayır ya da içeriği mevcut akışın dışında göster (sayfanın altına sabitlenmiş bir bar hiçbir şeyi itmez). Reklam ve embed'lerde yükseklik bilinmiyorsa en sık görülen yükseklik için yer ayırmak, boş alan bırakmaktan daha iyi sonuç veriyor. Animasyonlarda top, left, height ya da margin değiştirmek layout'u etkiliyor ve layout shift üretiyor; transform ile yapılan bir animasyon layout'u değiştirmediği için sayılmıyor.
Angular tarafında üç kaynak var. Birincisi bu yazıdaki: @defer placeholder'ı ve @loading bloğu, yerine gelecek içerikle aynı boyutta olmalı. İkincisi SSR (Server-Side Rendering; sayfanın HTML'inin sunucuda üretilmesi) ya da prerender ile gelen HTML'in hydration sırasında değişmesi. Sunucuda "Sepetinizde 0 kitap" yazan bir başlık, tarayıcıda localStorage'dan okunan sepetle büyüyorsa kayma sayılır. Üçüncüsü saat dilimi: sunucuda UTC (eşgüdümlü evrensel saat) ile biçimlendirilen bir tarih, tarayıcıda kullanıcının yerel saatine göre yeniden render edilince metnin uzunluğu ve satır kırılması değişebilir. İkisinde de çözüm, sunucuyla tarayıcının aynı şeyi render etmesi: kullanıcıya özel kısmı sabit boyutlu bir alanda göstermek ya da tarihi her iki tarafta aynı saat dilimiyle biçimlendirmek. Hydration ayrıntıları Angular SSR yazısında.
Üretimde Dikkat
CLS'i yalnız Chromium ölçüyor; Safari ve Firefox kullanıcılarındaki kaymaları RUM'dan göremezsin. iPhone kullanıcılarının çok olduğu bir sitede Chrome'un dar viewport'lu Android verisi en yakın gösterge. RUM'da CLS'in yanında attribution.target, time ve loadState'i sakla ve en kötü sayfaları bu üçlüye göre grupla: loading ve dom-interactive ilk render'daki kaymayı, complete geç gelen içeriği ya da scroll'u gösterir. Seçicideki (section.details) element itilen taraf; itilen element'in üstünde yeni ne belirdiğine bakmak gerekiyor.
Lab senaryosunu field verisine göre kur. Bu yazıda masaüstü yüklemesi, telefon yüklemesi ve telefonda scroll üç farklı sonuç verdi; hangisinin gerçek kullanıcılara uyduğunu RUM'daki loadState ve cihaz dağılımı söyler. Son olarak, CLS sayfanın bütün ömrünü kapsıyor: SPA'larda (Single Page Application; route değişiminde sayfayı yeniden yüklemeyen uygulama) route değişimleri yeni bir sayfa yüklemesi sayılmıyor ve kaymalar ilk açılan route'a yazılıyor. Soft navigation desteği bunu değiştirecek; ilk yazıda konuştuğumuz reportSoftNavs seçeneği burada da geçerli.
Sık Düşülen Tuzaklar
- CLS'i yalnız sayfa yüklenirken ölçmek. Scroll ile tetiklenen
@defer (on viewport)bloğu ya da lazy görsel, yüklemede 0, kullanıcıda 0,1 üstü verebilir. - Görünür bir bölümü
@deferile erteleyip küçük bir placeholder bırakmak. Erteleme kaymayı yok etmez, zamanını değiştirir. width/height'sız<img>. Tek bir kapak bu sayfada 0,54 üretti.- Skeleton'u "yaklaşık" boyutta yapmak. Birkaç piksellik fark ya da dışarı taşan bir
margin-topyine kayma demek. - Veri geldikten sonra beliren düğme, sayaç ya da "Yeni" etiketi gibi küçük element'leri unutmak.
top/left/heightile animasyon yapmak;transformkullan.- Kullanıcının tıklamasıyla sunucudan içerik çekip 500 ms'den geç göstermek; istisna süresi dolunca kayma sayılır.
Küçük Bir Deneme
★☆☆ cls-trace.mjs'i son hâl için --profile lowend --scroll 3 ile çalıştır. Skeleton kartlar görünürken scroll ettiğinde hiç kayma oluşuyor mu?
★★☆ "Show more" düğmesini, kalan dört kitabı tıklandığında API'den isteyecek şekilde değiştir (?count=8 isteğini tıklamaya bağla). --profile lowend ile tıklamadan sonraki kaymanın hadRecentInput değerine bak; cevap 500 ms'den geç geliyor mu?
★★★ Kitap detay sayfasında bir tarih göster ("Added on ...") ve bunu DatePipe ile kullanıcının saat dilimine göre biçimlendir. Ana sayfadaki gibi bu sayfayı prerender et ve tarayıcının saat dilimini Playwright'ın timezoneId seçeneğiyle değiştirerek hydration sonrası metnin değişip değişmediğini ve CLS'e etkisini ölç.
Kitap sayfasında CLS her senaryoda 0, ama B hâlinin yan etkisini build çıktısında gördük: ilk lazy chunk'la birlikte esbuild ortak kodu ayrı bir chunk'a taşıdı ve başlangıçta inen JavaScript hâlâ 380 KB civarında. Bir sonraki yazıda JavaScript'in maliyetine bakıyoruz: lazy route'larla CDK'yı (Angular'ın Component Dev Kit'i) ve katalog sayfasını ilk yüklemeden çıkaracak, @defer trigger'larını, Angular 22'nin injectAsync'ini ve bundle budget'larını ölçerek kullanacağız.