Image ve Media: img, picture, srcset/sizes, video, audio, track ve iframe
Her img'e width ve height yaz, alt'ı görselin sayfadaki işine göre seç, ekran boyutu değişen görselde srcset'i sizes ile ver ve ilk ekrandaki görsele asla loading="lazy" koyma. Bu yazıda bu kuralları Chromium ve WebKit'te ölçüyoruz: boyutsuz görselin 0.14'lük layout shift'i, sizes="auto", picture'da AVIF/WebP/JPEG XL sırası, lazy LCP görselinin yarım saniyelik gecikmesi, track ile altyazı ve iframe title.
Sürüm notu: Bu yazı HTML (HyperText Markup Language) Living Standard'ın ve Baseline'ın Kasım 2026 durumuna göre yazıldı; örnekler Chrome 158, WebKit 27.2 (Playwright 1.65) ve Nu Html Checker 26.11.9 ile denenmiştir, Firefox ve Edge desteği web-features verisinden ve tarayıcıların duyurularından alınmıştır.
Önce Tahmin Et
Bookstore'un kitap detay sayfasında aynı kapak görseli iki kez var. Birincisinde width ve height yazılmamış, ikincisinde yazılmış; CSS (Cascading Style Sheets) ikisini de max-width: 100%; height: auto ile esnek yapıyor:
{{site/cls.html}}
Görseller yavaş bir bağlantıda 800 ms sonra geliyor. Kullanıcı o sırada fiyatı okuyor. Görseller gelince fiyat satırı yerinde kalır mı, kayar mı? Kayarsa kaç piksel? Ve "In stock" satırı, ikinci görselin yeri baştan ayrıldığı için yerinde kalır mı?
Bir önceki yazı olan "Native Interaktif Element'ler 2: select, datalist, Customizable Select ve output/progress/meter" ile etkileşimli element'leri bitirdik. Bu yazıda sayfanın genellikle en ağır kısmına geçiyoruz: görseller, video, ses ve iframe. Hepsinin HTML tarafı birkaç attribute'tan ibaret, ama yanlış yazıldıklarında sayfanın kaymasına, kullanıcının yanlış görseli indirmesine ya da ekran okuyucunun "image" deyip geçmesine yol açıyorlar. Görsel formatı seçimi, CDN (Content Delivery Network) dönüşümü ve LCP (Largest Contentful Paint) stratejisi ilerideki Web Performansı serisinde; burada markup var.
width ve height: Layout Shift'i Önlemek
Ölçüm için, site klasörünü servis eden ve istediğim uzantıya yapay gecikme ekleyen küçük bir Node sunucusu yazdım. Yazının bütün script'leri onu kullanıyor:
const http = require('http');
const fs = require('fs');
const path = require('path');
const types = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.jpg': 'image/jpeg', '.webp': 'image/webp',
'.avif': 'image/avif', '.jxl': 'image/jxl', '.webm': 'video/webm', '.mp4': 'video/mp4', '.vtt': 'text/vtt',
};
// site klasörünü servis eder; uzantıya göre yapay gecikme ekler ve her isteği kaydeder
module.exports = function serve(port, { delays = {}, log = [] } = {}) {
const server = http.createServer((req, res) => {
const file = path.join(__dirname, 'site', decodeURIComponent(new URL(req.url, 'http://x').pathname));
log.push(req.url);
if (!fs.existsSync(file)) { res.statusCode = 404; return res.end(); }
res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
setTimeout(() => res.end(fs.readFileSync(file)), delays[path.extname(file)] ?? 0);
});
return new Promise(resolve => server.listen(port, () => resolve(server)));
};
const { chromium } = require('playwright');
const serve = require('./serve');
(async () => {
const server = await serve(18884, { delays: { '.jpg': 800 } });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('http://localhost:18884/cls.html', { waitUntil: 'commit' });
await page.waitForSelector('#stock');
const top = () => page.evaluate(() => ['price', 'stock'].map(id =>
`${id}.top=${Math.round(document.getElementById(id).getBoundingClientRect().top)}`).join(' '));
console.log('before images:', await top());
await page.waitForLoadState('load');
console.log('after images: ', await top());
// Chromium'un Layout Instability API'si: her shift'in skoru
const shifts = await page.evaluate(() => new Promise(resolve =>
new PerformanceObserver(list => resolve(list.getEntries().map(e => e.value.toFixed(4))))
.observe({ type: 'layout-shift', buffered: true })));
console.log('layout shifts:', shifts.join(', '));
await browser.close();
server.close();
})();
before images: price.top=114 stock.top=768
after images: price.top=700 stock.top=1354
layout shifts: 0.1407
Fiyat satırı 114. pikselden 700. piksele, yani 586 piksel aşağı kaydı. Sebep birinci görsel: tarayıcı HTML'i okurken onun boyutunu bilmiyordu, ona sıfır yükseklik verdi ve görsel gelince altındaki her şeyi aşağı itti. "In stock" satırı da aynı miktarda kaydı, ama dikkat: fiyat ile stok arasındaki mesafe önce de sonra da 654 piksel. İkinci görselin width="400" height="600" attribute'ları sayesinde tarayıcı onun yerini görsel gelmeden ayırdı; o görsel hiçbir şeyi kaydırmadı.
Bunun mekanizması, tarayıcıların width ve height attribute'larından bir aspect-ratio türetmesi. CSS'te height: auto olduğu için görsel ekrana sığacak kadar küçülse bile 2:3 oranı korunur ve yer doğru ayrılır. Yani bu attribute'lar görseli sabit boyuta kilitlemez; tarayıcıya oranı söyler. Responsive bir tasarımda bile yazılmalı.
Son satır Chromium'un Layout Instability API'sinin (Application Programming Interface) ölçtüğü skor: 0.1407. Core Web Vitals'taki CLS (Cumulative Layout Shift) metriği bu skorlardan hesaplanır ve web.dev'in eşiğine göre 0.1'in altı "iyi" sayılır. Tek bir boyutsuz görsel, 1280×800'lük bir ekranda sayfayı bu eşiğin üstüne çıkardı. Metriğin ayrıntısı Web Performansı serisinde.
alt Metni: Bilgi Veren, Dekoratif ve Fonksiyonel Görsel
alt görselin yerine geçen metindir: ekran okuyucu onu okur, görsel yüklenemezse tarayıcı onu gösterir, arama motoru onu görselin anlamı olarak kullanır. Ama doğru alt görselin ne olduğuna değil, sayfada ne işe yaradığına bağlıdır. Altı varyant:
{{site/alt.html}}
const pw = require('playwright');
const serve = require('./serve');
(async () => {
const server = await serve(18889);
for (const name of ['chromium']) {
const browser = await pw[name].launch();
const page = await browser.newPage();
await page.goto('http://localhost:18889/alt.html');
await page.waitForLoadState('networkidle');
console.log(`--- ${name} ${browser.version()}`);
// Playwright'ın ARIA snapshot'ı: accessibility tree'de ne kalıyor?
console.log(await page.locator('main').ariaSnapshot());
console.log('broken:', await page.locator('#broken').evaluate(i =>
`${i.currentSrc.split('/').pop()} naturalWidth=${i.naturalWidth}`));
await browser.close();
}
server.close();
})();
--- chromium 158.0.8127.18
- main:
- heading "Clean Architecture" [level=1]
- img "Cover of Clean Architecture"
- img
- img "image"
- link "Cart, 2 items":
- /url: /cart
- img "Cart, 2 items"
- img "Cover of Refactoring"
broken: refactoring-200.avif naturalWidth=0
Bilgi veren görsel (kitap kapağı) içeriği anlatan bir alt alır: "Cover of Clean Architecture". Dekoratif görsel alt="" alır ve çıktıda görüldüğü gibi accessibility tree'den tamamen düşer; ekran okuyucu onu atlar. Bir çizgi, bir arka plan süsü ya da yanında metni zaten yazan bir ikon böyledir. alt'ı hiç yazılmamış görsel ise ikisinin arasında, en kötü yerde kalır: adı olmayan bir img. Bazı ekran okuyucular bu durumda dosya adını okur, ve "clean-architecture-200 jpg" kimseye bir şey anlatmaz.
alt="image" ise varlığıyla yanıltan bir alt: ekran okuyucu zaten "görsel" der, üstüne "image" eklemek bilgi değil gürültüdür. "Resim", "fotoğraf", "kapak görseli" gibi kelimeler de aynı kategoride; görselin ne gösterdiğini yaz, ne olduğunu değil.
Fonksiyonel görselde kural değişir. Sepet ikonu bir link'in tek içeriği; çıktıda link'in adı doğrudan görselin alt'ından geliyor: "Cart, 2 items". Burada alt görseli değil, link'in ne yaptığını anlatmalı. "Shopping cart icon" yazsaydın link'in adı o olurdu.
Son satır bir sonraki bölüme köprü: picture içindeki img adını korudu ama yüklenemedi (naturalWidth=0). Nedenini aşağıda göreceğiz.
srcset ve sizes: Density mi, Width mi?
Aynı kapağı 1x bir masaüstü ekranına ve 3x bir telefona aynı dosyayla göndermek ya biri için bulanık ya da diğeri için gereksiz büyük olur. srcset tarayıcıya aday dosyaları verir, seçimi tarayıcı yapar. İki tür descriptor var:
{{site/responsive.html}}
const pw = require('playwright');
const serve = require('./serve');
const cases = [
{ width: 1280, height: 800, dpr: 1 },
{ width: 1280, height: 800, dpr: 2 },
{ width: 390, height: 844, dpr: 3 },
];
(async () => {
const server = await serve(18885);
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
console.log(`--- ${name} ${browser.version()}`);
for (const { width, height, dpr } of cases) {
// her durumda yeni context: cache'teki büyük görsel seçimi etkilemesin
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: dpr });
const page = await context.newPage();
await page.goto('http://localhost:18885/responsive.html');
await page.waitForLoadState('networkidle');
const picked = await page.evaluate(() => ['density', 'width', 'format'].map(id =>
`${id}=${document.getElementById(id).currentSrc.split('/').pop()}`).join(' '));
const decoded = await page.locator('#format').evaluate(i => i.naturalWidth);
console.log(`${width}px @${dpr}x ${picked} (naturalWidth=${decoded})`);
await context.close();
}
await browser.close();
}
server.close();
})();
--- chromium 158.0.8127.18
1280px @1x density=clean-architecture-200.jpg width=clean-architecture-400.jpg format=clean-architecture-400.jxl (naturalWidth=400)
1280px @2x density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
390px @3x density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
--- webkit 27.2
1280px @1x density=clean-architecture-200.jpg width=clean-architecture-400.jpg format=clean-architecture-400.jxl (naturalWidth=400)
1280px @2x density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
390px @3x density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
Density descriptor (1x, 2x) görselin ekrandaki boyutu sabitse kullanılır: katalogdaki 200 piksellik küçük kapak gibi. 1x ekranda 200'lük, 2x ekranda 400'lük dosya seçildi; 3x telefonda en iyi aday 2x olduğu için yine 400. Daha büyüğü listede yoksa tarayıcı onu uyduramaz.
Width descriptor (200w, 400w, 800w) görselin boyutu ekrana göre değişiyorsa kullanılır ve sizes ile birlikte gelir. sizes tarayıcıya "bu görsel ekranda ne genişlikte çizilecek" sorusunun cevabını verir, çünkü tarayıcı dosyayı seçerken CSS'i henüz uygulamamıştır. Bizim değerimiz "1000 pikselden geniş ekranda 300 piksel, değilse ekranın yarısı". 1280 piksel 1x ekranda gereken 300 piksel; 400w seçildi. Aynı ekran 2x olunca gereken 600 piksel; 800w seçildi. 390 piksellik 3x telefonda görsel ekranın yarısı, yani 195 CSS pikseli, bu da 585 fiziksel piksel demek; yine 800w. Chromium ve WebKit aynı kararı verdi.
sizes'ı yazmazsan varsayılan 100vw'dir: tarayıcı görselin ekranın tamamını kapladığını varsayar ve en büyük adayı indirir. Bu yüzden Nu Html Checker width descriptor'ı olan ama sizes'ı olmayan bir srcset'i error olarak işaretliyor; çıktısını "Üretimde Dikkat"te göreceksin.
picture ile Format Fallback: AVIF, WebP ve JPEG XL
picture'ın ikinci işi format seçimi. Yukarıdaki sayfada kapak dört formatta sunuluyor: JPEG (Joint Photographic Experts Group) ailesinin yeni formatı JPEG XL, AVIF (AV1 Image File Format), WebP ve klasik JPEG. Tarayıcı source'ları yukarıdan aşağı okur ve type'ını desteklediği ilk kaynağı seçer; hiçbirini desteklemezse img'in src'ine düşer. Çıktıda iki motor da ilk sıradaki JPEG XL'i seçti ve dosyayı çözdü (naturalWidth=400).
Destek durumu Kasım 2026'da ilginç. AVIF Temmuz 2026'dan beri widely available, WebP çok daha önceden. JPEG XL ise Safari'de 17'den beri kısmi olarak vardı; Chrome 155 ve Firefox 158 ile Ekim 2026'da varsayılan açıldı. Edge ise Chromium tabanlı olmasına rağmen henüz göstermiyor, dolayısıyla JPEG XL Baseline değil. Edge kullanıcısı bu sayfada JPEG XL'i atlayıp AVIF'e düşer. picture tam olarak bu durum için var: destekleyen yeni formatı alır, desteklemeyen sessizce bir sonrakine geçer.
İki ince nokta var. Birincisi, tarayıcı en küçük dosyayı değil, desteklediği ilk dosyayı seçer. Bu yazıdaki örnek dosyalarda JPEG XL'i JPEG'den kayıpsız dönüştürdüğüm için 2464 byte, AVIF ise 527 byte; yani bu sırayla iki motor da daha büyük dosyayı indirdi. Sırayı formatın adına değil, kendi görsellerinde ölçtüğün boyuta göre ver. İkincisi ve daha tehlikelisi: seçilen kaynak yüklenemezse tarayıcı bir sonraki source'a geçmez. alt.html'deki "Refactoring" kapağında AVIF dosyası sunucuda yoktu; tarayıcı AVIF'i desteklediği için onu seçti, 404 aldı ve JPEG'e hiç dönmedi. Fallback dosya yokluğuna karşı değil, format desteğine karşı çalışır. Build pipeline'ın her formatı her boyutta ürettiğini bir testle doğrula.
loading="lazy" ve sizes="auto"
loading="lazy" görselin indirilmesini, kullanıcı ona yaklaşana kadar erteler. Ekranın çok altındaki "Customers also bought" görselleri için doğru seçim; ilk açılışta indirilmeleri ilk ekrandaki işlerle bant genişliği yarıştırır. Hem img hem iframe için çalışır ve Haziran 2026'dan beri widely available.
Lazy görsellerin bir avantajı daha var: tarayıcı onları layout'tan sonra indirdiği için görselin ekrandaki gerçek genişliğini biliyor. sizes="auto" bunu kullanır: sizes'ı elle hesaplamak yerine tarayıcı layout'taki genişliği alır. Katalogdaki 150 piksellik küçük kapaklar için iki sayfa denedim, biri sizes'sız, biri sizes="auto, 150px" ile:
{{site/thumbs-auto.html}}
const pw = require('playwright');
const serve = require('./serve');
(async () => {
const server = await serve(18886);
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
console.log(`--- ${name} ${browser.version()}`);
for (const file of ['thumbs-nosizes.html', 'thumbs-auto.html']) {
for (const dpr of [1, 2]) {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: dpr });
const page = await context.newPage();
await page.goto(`http://localhost:18886/${file}`);
await page.waitForLoadState('networkidle');
const src = await page.locator('img').evaluate(i => i.currentSrc.split('/').pop());
console.log(`${file.padEnd(20)} @${dpr}x -> ${src}`);
await context.close();
}
}
await browser.close();
}
server.close();
})();
--- chromium 158.0.8127.18
thumbs-nosizes.html @1x -> clean-architecture-800.jpg
thumbs-nosizes.html @2x -> clean-architecture-800.jpg
thumbs-auto.html @1x -> clean-architecture-200.jpg
thumbs-auto.html @2x -> clean-architecture-400.jpg
--- webkit 27.2
thumbs-nosizes.html @1x -> clean-architecture-800.jpg
thumbs-nosizes.html @2x -> clean-architecture-800.jpg
thumbs-auto.html @1x -> clean-architecture-200.jpg
thumbs-auto.html @2x -> clean-architecture-400.jpg
sizes'sız sayfada iki motor da 150 piksellik bir görsel için 800 piksellik dosyayı indirdi, çünkü varsayılan 100vw. auto ile 1x ekranda 200w, 2x ekranda 400w seçildi. Listedeki ikinci değer (150px) auto'yu bilmeyen tarayıcılar için bir fallback. sizes="auto" yalnız loading="lazy" olan görsellerde çalışır. Chrome 126 ve Firefox 150'den sonra Safari 27 ile geldi ve web.dev'in Eylül 2026 özeti onu Baseline newly available ilan etti. Ama web-features verisi Kasım 2026 itibarıyla Safari'yi yeniden desteksiz sayıyor: Safari 27'de sizes="auto" SVG (Scalable Vector Graphics) görsellerin object-fit ile çizimini bozuyor (WebKit bug 324661) ve düzeltme henüz yayınlanmış bir sürümde değil. Yani resmi durum şu an limited availability; listedeki fallback değeri bu yüzden önemli.
Lazy'nin bedeli de var ve onu ölçmek kolay. Aynı büyük kapağı bir sayfada normal, diğerinde loading="lazy" ile koydum; iki sayfada da 500 ms'de gelen render-blocking bir stylesheet var ve görseller 800 ms'de geliyor:
const { chromium } = require('playwright');
const serve = require('./serve');
(async () => {
const server = await serve(18887, { delays: { '.css': 500, '.jpg': 800 } });
const browser = await chromium.launch();
for (const file of ['hero-eager.html', 'hero-lazy.html']) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`http://localhost:18887/${file}`);
await page.waitForLoadState('networkidle');
const r = await page.evaluate(() => new Promise(resolve => {
const img = performance.getEntriesByType('resource').find(e => e.name.includes('.jpg'));
new PerformanceObserver(list => {
const lcp = list.getEntries().at(-1);
resolve({ requestStart: Math.round(img.startTime), lcp: Math.round(lcp.startTime), element: lcp.element?.tagName });
}).observe({ type: 'largest-contentful-paint', buffered: true });
}));
console.log(`${file.padEnd(16)} image request at ${r.requestStart} ms, LCP ${r.element} at ${r.lcp} ms`);
await page.close();
}
await browser.close();
server.close();
})();
hero-eager.html image request at 6 ms, LCP IMG at 820 ms
hero-lazy.html image request at 520 ms, LCP IMG at 1336 ms
Normal görselin isteği 6 ms'de başladı: tarayıcının preload scanner'ı onu HTML'i okurken buldu ve stylesheet'i beklemeden indirmeye başladı. Lazy görselin isteği ise 520 ms'de, yani stylesheet geldikten ve layout hesaplandıktan sonra başladı. Sayfanın en büyük içeriği olan kapak yarım saniye geç çizildi: LCP 820 ms'den 1336 ms'ye çıktı. İlk ekranda görünen, özellikle LCP olabilecek görsele asla loading="lazy" koyma.
video, audio ve track: Altyazı
Kitap sayfasında yazarla yapılmış kısa bir röportaj videosu, iki dilde altyazısıyla:
{{site/book.html}}
controls tarayıcının oynatma kontrollerini gösterir; yazmazsan kullanıcı videoyu durduramaz ve klavyeyle erişemez, yani kendi kontrollerini yazmadıysan her zaman yaz. preload="metadata" tarayıcıya yalnız süre ve boyut gibi bilgileri almasını söyler; poster oynatılmadan önce gösterilen görseldir. width ve height burada da yer ayırır. Birden fazla source varsa tarayıcı picture'daki gibi oynatabildiği ilk formatı seçer. En sondaki paragraf, video'yu hiç desteklemeyen tarayıcılar için bir fallback.
track WebVTT (Web Video Text Tracks) formatında bir metin dosyasını videoya bağlar. Dosya şöyle:
{{site/media/author-interview.tr.vtt}}
kind="subtitles" konuşmanın çevirisi içindir; işitme engelli kullanıcı için konuşmanın yanında ses efektlerini ve konuşanı da yazan bir metin varsa doğru tür captions'tır. srclang dili, label menüde görünen adı, default da varsayılan olarak açılacak track'i söyler. Sayfayı yükleyen, sonra aşağı kaydıran ve istekleri kaydeden script:
const pw = require('playwright');
const serve = require('./serve');
(async () => {
for (const name of ['chromium', 'webkit']) {
const log = [];
const server = await serve(18888, { log });
const browser = await pw[name].launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
console.log(`--- ${name} ${browser.version()}`);
await page.goto('http://localhost:18888/book.html');
await page.waitForTimeout(1000);
console.log('requests on load:', log.splice(0).join(' '));
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(1000);
console.log('after scroll: ', log.splice(0).join(' '));
const video = await page.locator('video').evaluate(v => ({
src: v.currentSrc.split('/').pop(),
tracks: [...v.textTracks].map(t => `${t.language}/${t.mode}/cues=${t.cues ? t.cues.length : null}`),
firstCue: v.textTracks[0].cues?.[0]?.text,
}));
console.log('video:', JSON.stringify(video));
await browser.close();
server.close();
}
})();
--- chromium 158.0.8127.18
requests on load: /book.html /covers/clean-architecture-400.jpg /media/author-interview-poster.jpg /media/author-interview.en.vtt /media/author-interview.webm /embed/store-map.html
after scroll: /covers/clean-architecture-200.jpg?b=1 /covers/clean-architecture-200.jpg?b=2
video: {"src":"author-interview.webm","tracks":["en/showing/cues=2","tr/disabled/cues=null"],"firstCue":"I started this book after twenty years of maintenance work."}
--- webkit 27.2
requests on load: /book.html /covers/clean-architecture-400.jpg /media/author-interview-poster.jpg /media/author-interview.webm
after scroll: /covers/clean-architecture-200.jpg?b=1 /covers/clean-architecture-200.jpg?b=2 /embed/store-map.html
video: {"src":"author-interview.webm","tracks":["en/disabled/cues=null","tr/disabled/cues=null"]}
Chromium İngilizce track'i default olduğu için açtı (showing) ve WebVTT dosyasını indirdi; ilk cue doğru okunuyor. Türkçe track disabled ve dosyası hiç indirilmedi: tarayıcı kullanıcı o dili seçene kadar beklemiş. WebKit ise İngilizce track'i de açmadı. Spec'e göre default bir garanti değil: kullanıcının tercihleri başka bir track'i daha uygun gösteriyorsa ya da altyazıyı kapattıysa tarayıcı ona uyar. Safari altyazı kararını işletim sisteminin erişilebilirlik ayarlarıyla birlikte veriyor. Altyazının açık geleceğini varsayan bir tasarım yapma.
Track dosyası başka bir origin'den (ör. bir CDN'den) geliyorsa video'ya crossorigin attribute'u ve sunucuda CORS (Cross-Origin Resource Sharing) header'ı gerekir; yoksa altyazı sessizce yüklenmez. audio aynı modeli izler: controls, preload, birden fazla source. Podcast ya da sesli kitap örneği için transkripti sayfada metin olarak vermek, track'ten daha erişilebilir bir çözümdür.
Autoplay hakkında: tarayıcılar sesli videonun kendiliğinden başlamasını, kullanıcı sayfayla etkileşime girmeden engeller. Sessiz (muted) ya da ses track'i olmayan video ise otomatik oynayabilir; Safari'de satır içinde oynaması için playsinline da gerekir. Yani arka plan videosu için doğru üçlü autoplay muted playsinline, ve kullanıcıya bir durdurma kontrolü bırakmak hâlâ senin işin.
iframe: title ve loading="lazy"
Kitap sayfasının en altında mağaza haritası bir iframe içinde. İki attribute önemli. title iframe'in accessible name'idir; ekran okuyucu kullanıcısı sayfadaki frame'leri listelediğinde gördüğü şey budur. "Map of the Bookstore shop" yerine "iframe" ya da hiçbir şey olmamalı.
loading="lazy" iframe'de görselden daha da değerlidir, çünkü bir harita ya da video embed'i kendi script'lerini, fontlarını ve görsellerini getirir. Ama ölçüm bir sürpriz gösterdi: Chromium görselleri kaydırmaya kadar bekletti, iframe'i ise ilk yüklemede istedi. WebKit ikisini de kaydırmadan sonra istedi. Lazy, "görününce yükle" değil "yaklaşınca yükle" demektir ve "yaklaşma" mesafesi tarayıcıya, kaynağın türüne ve bağlantı hızına göre değişir. Sayfayı 400vh yerine 800vh uzunluğunda yapınca Chromium iframe'i de kaydırmaya kadar bekletti. Lazy bir ipucudur, bir zamanlama garantisi değil.
Üçüncü parti bir iframe'in neye izin verdiği (sandbox, allow) ve rel güvenliği Frontend Güvenliği serisinin "Üçüncü Parti Script'ler, iframe'ler ve postMessage" yazısında.
Üretimde Dikkat
Yazıdaki bütün sayfaları Nu Html Checker'dan geçirdim:
docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only --skip-non-html /site
"file:/site/thumbs-nosizes.html":16.9-20.72: error: When the “srcset” attribute has any image candidate string with a width descriptor, the “sizes” attribute must also be specified.
"file:/site/alt.html":12.9-12.94: error: An “img” element must have an “alt” attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.
İki error, iki gerçek sorun: sizes'ı olmayan width descriptor'lı srcset ve alt'ı olmayan img. Checker alt="image"'ı yakalamıyor, çünkü syntax açısından geçerli; kötü alt metni ancak insan gözüyle ya da içerik incelemesiyle bulunur. CMS'ten (Content Management System) gelen görsellerde alt alanını zorunlu yapmak ve editörlere "görsel ne gösteriyor?" sorusunu sormak, checker'ın göremediği yarıyı kapatır.
srcset bir talimat değil, bir öneri listesidir. İlk denememde aynı sayfada iki görsel aynı dosyaları paylaşıyordu ve Chromium, cache'te daha büyük bir aday zaten olduğu için 1x ekranda 200w yerine 400w'yi kullandı; WebKit 200w'yi seçti. Bu yazıdaki ölçümleri bu yüzden her durum için yeni bir browser context'iyle yaptım. Kendi testlerinde de "hangi dosya iniyor" sorusunu temiz bir cache ile sor.
ASP.NET Core tarafında: width/height ve srcset'i Razor'da elle yazmak hataya açık. Görsel metadata'sı (genişlik, yükseklik, üretilen boyutlar) CMS'ten ya da build sırasında üretilen bir manifest'ten geliyorsa, bir Tag Helper ya da partial view bu attribute'ları tek yerden üretebilir. Tag Helper yazmanın ayrıntısı ASP.NET Core MVC (Model-View-Controller) ve Razor Pages serisinde; Angular'da aynı işi büyük ölçüde NgOptimizedImage directive'i yapıyor.
Sık Düşülen Tuzaklar
Birincisi, LCP görseline loading="lazy" koymak. Ölçtüğümüz gibi isteği yarım saniye geciktirdi. Lazy'yi bir component'in varsayılanı yaptıysan, sayfanın üstündeki görsel için onu kapatabilmen gerekir.
İkincisi, alt'a "resim", "image" ya da dosya adı yazmak. Görselin ne gösterdiğini ya da (link içindeyse) ne yaptığını yaz; dekoratifse alt="".
Üçüncüsü, width/height'ı "responsive tasarımı bozar" diye silmek. Bozmaz; CSS'te height: auto varken yalnız oranı bildirir.
Dördüncüsü, width descriptor'ı sizes'sız kullanmak. Tarayıcı 100vw varsayar ve küçük bir görsel için en büyük dosyayı indirir.
Beşincisi, picture'ın eksik dosyaya karşı fallback yaptığını sanmak. Yalnız format desteğine karşı yapar.
Altıncısı, iframe'e title vermemek ve videoyu controls'süz, altyazısız yayınlamak.
Küçük Bir Deneme
★☆☆ cls.html'deki ilk görsele width="400" height="600" ekle ve cls.js'i tekrar çalıştır. Fiyat satırının konumunu ve layout shift skorunu karşılaştır. Sonra CSS'teki height: auto'yu silip görselin nasıl gerildiğine bak.
★★☆ responsive.html'deki sizes'ı (min-width: 1000px) 300px, 100vw yap ve responsive.js'i çalıştır. 390 piksellik telefonda hangi dosyanın seçildiğini ve neden seçildiğini hesapla. Ardından picture'daki source sırasını değiştirip AVIF'i öne al.
★★★ Projendeki ürün ya da içerik sayfalarından birinde, ilk ekrandaki en büyük görseli bul (Chrome DevTools'un Performance panelindeki LCP işareti işini görür). loading, width/height, srcset/sizes ve alt'ını kontrol et; sonra sayfanın altındaki görsellerin ve iframe'lerin lazy olup olmadığına bak. Bulduklarını media.js benzeri bir script'le, ilk yüklemedeki istek listesine bakarak doğrula.
Görseller ve media sayfanın gövdesini doldurdu. Bir sonraki yazıda sayfanın görünmeyen ama her paylaşımda, her arama sonucunda ve her mobil cihazda sonucu değiştiren kısmına geçiyoruz: head. charset, viewport, title, description, Open Graph, theme-color ve link rel'lerin kısa haritası.