Fontu alt kümele, yedek font metriğini eşle, kaymayı bitir
127 karakter basan bir sayfa 889 glyph indirmemeli. Build'de alt kümeleyin, yedek fontun metriğini eşleyin, swap anındaki kayma sıfırlansın.
Sıradan bir içerik sayfası yüz ile iki yüz arasında farklı karakter basar. Bunun için indirdiği font dosyası ise genelde birkaç yüz glyph taşır, birkaç ağırlıkta, ve bütün olarak çekilir. Sonra font geldiğinde metin yeniden akar, paragraf yükseklikleri değişir ve ilk ekranın altındaki her şey zıplar. İkisi de build adımında çözülüyor ve sayılar bir öğleden sonrayı hak edecek kadar büyük.
Gerçekte ne oluyor
Web font, yüz başına tek ve bölünemez bir dosyadır. Range request yok, kısmi decode yok, tarayıcının yalnızca ihtiyacı olan harfleri alması diye bir şey yok. Regular, bold ve italic istediyseniz bu üç indirmedir ve her biri tasarımcının gönderdiği bütün karakter setini taşır, hiç basmayacağınız Kiril ve Yunan blokları dahil.
Layout shift ise aynı anda gelen ama ayrı bir mekanizma. font-display: swap, tarayıcıya "hemen bir yedek fontla boya, gerçek font gelince değiştir" der. Okunabilirlik açısından doğru tercih budur ve kaymanın sebebi de budur: yedek fontun kendi ortalama karakter genişliği, kendi ascent ve descent değerleri, kendi satır yüksekliği vardır. Aynı paragraf yedek fontta gerçek fonttakinden farklı sayıda satıra oturur, swap anında da altındaki her blok oynar. Metriğin adı cumulative layout shift ve font swap bunun iki klasik sebebinden biri; diğeri en büyük elementi oynatan bir giriş animasyonu.
Dosyayı küçültmek tek başına ikisini de çözmez. Küçük dosya daha erken swap olur, yani zıplamanın görülme ihtimali azalır; metriği eşlenmiş yedek font ise zıplamayı imkânsız kılar. İkisini birden yapın.
Nasıl görülür
Baytlarla başlayın, çünkü üzerinde tartışılması en kolay sayı o:
ls -l dist/assets/fonts/*.woff2 | awk '{ t += $5; printf "%8d %s\n", $5, $9 } END { printf "%8d toplam\n", t }'Sonra kaymaları oluştukları anda izleyin. Layout shift kayıtları oynayan node'ları da taşır, yani tek bir skoru element listesine çevirir:
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue;
console.log(e.value.toFixed(4), e.sources.map((s) => s.node?.nodeName).join(' '));
}
}).observe({ type: 'layout-shift', buffered: true });Sayfayı yenilemeden önce ağı yavaşlatın, yoksa font ilk boyamadan önce gelir ve hiçbir şey ölçmemiş olursunuz. Burada lab koşulları skorun kendisinden daha önemli: hızlı bağlantı hatayı tamamen gizler ve kendi makinenizdeki temiz bir sayı, yolda okuyan biri hakkında hiçbir şey söylemez.
Çözüm
Karakter havuzunu Unicode aralığından değil içerikten üretin. Aralık bir tahmindir, içerik ise cevabın kendisi:
import subsetFont from 'subset-font';
const pool = new Set();
const add = (s) => { for (const ch of s) pool.add(ch); };
const walk = (v) => {
if (typeof v === 'string') { add(v); add(v.toLocaleUpperCase('tr')); add(v.toUpperCase()); }
else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
};
walk(content);
for (let c = 0x20; c <= 0x7e; c++) add(String.fromCharCode(c)); // düz ASCII, her zaman
add('ĞğİıŞşÇçÖöÜüéÉ‘’“”…'); // noktalama ve yumuşak tire
for (const loc of ['en-US', 'tr-TR']) // tarih formatlayıcının basabileceği her şey
for (let m = 0; m < 12; m++)
add(new Intl.DateTimeFormat(loc, { month: 'long' }).format(new Date(Date.UTC(2026, m, 15))));
const chars = [...pool].join('');
const woff2 = await subsetFont(await fs.readFile(file), chars, { targetFormat: 'woff2' });Buradaki iki ayrıntı süs değil. Büyük harf hallerini eklemek gerekir, çünkü kapitalle dizilmiş bir başlık kaynak stringde hiç geçmeyen glyph'lere ihtiyaç duyar ve Türkçe hali locale'den bağımsız halinden farklıdır, yani büyük harf i tuzağı font pipeline'ına kadar uzanır. Tarih adlarını eklemek gerekir, çünkü formatlanmış tarih basan bir sayfa sitedeki hiçbir stringde geçmeyen karakterler üretebilir.
Bir sitede havuz, kaynak fonttaki 889 karaktere karşılık 127 karakterde kapandı:
| font yüzü | tam woff2 | alt küme woff2 |
|---|---|---|
| metin regular | 63,5 KB | 17,1 KB |
| metin bold | 64,7 KB | 17,3 KB |
| metin italic | 68,5 KB | 18,8 KB |
| başlık | 24,5 KB | 6,9 KB |
| logo, iki harf | 74,6 KB | 2,5 KB |
| toplam | 295,9 KB | 62,6 KB |
Sıra yedek fontta. Az önce ürettiğiniz alt kümenin gerçek metriklerini okuyun, okuyucunun makinesinde zaten kurulu bir fontun metrikleriyle karşılaştırın ve override'ları yazın:
import { fromBuffer } from '@capsizecss/unpack';
import georgia from '@capsizecss/metrics/georgia';
const m = await fromBuffer(await subsetFont(src, chars, { targetFormat: 'truetype' }));
const sizeAdjust = m.xWidthAvg / m.unitsPerEm / (georgia.xWidthAvg / georgia.unitsPerEm);
const pct = (v) => `${((v / (m.unitsPerEm * sizeAdjust)) * 100).toFixed(2)}%`;Bir metin yüzü için Georgia'ya karşı çıkan sonuç şu oldu:
@font-face {
font-family: 'Text Fallback';
src: local('Georgia');
size-adjust: 98.92%;
ascent-override: 77.03%;
descent-override: 24.06%;
line-gap-override: 23.86%;
}
body { font-family: 'Text', 'Text Fallback', Georgia, serif; }Son olarak yalnızca ilk ekranda boyanan yüzleri preload edin. Yukarıdaki sitede bu beş yüzden dördü, yani 43,8 KB; italik bekler:
<link rel="preload" href="/assets/fonts/text.293e1fd9f0.woff2" as="font" type="font/woff2" crossorigin>crossorigin özniteliği aynı origin'deki bir font için bile zorunlu, çünkü fontlar CORS modunda çekilir. Onu yazmazsanız tarayıcı dosyayı iki kez indirir. Dosyaları üçüncü taraf yerine kendi sunucunuzdan vermek kendi gerekçeleri olan ayrı bir karar, ama hash'li dosya adını ve süresiz cache başlığını mümkün kılan şey de o.
Çalıştığını nasıl doğrularsınız
Baytlar kolay. Kayma ise ağ zamanlamasına bağlı olmayan bir teste ihtiyaç duyuyor, o yüzden yedek fontu zorlayıp yükseklikleri doğrudan karşılaştırın:
const article = document.querySelector('article');
const measure = (family) => {
article.style.fontFamily = family;
return article.getBoundingClientRect().height;
};
console.log(measure("'Text', 'Text Fallback', Georgia, serif"),
measure("'Text Fallback', Georgia, serif"));
// 4112 4112Bu iki sayı eşitse, bağlantı ne yaparsa yapsın web fontun gelmesi hiçbir şeyi oynatamaz. Tek bir koşuda iyi skor almaktan çok daha güçlü bir ifadedir ve saklayabileceğiniz bir testtir.
Nelere dikkat etmeli
- Alt kümeleme, ihtiyacınız olduğunu bilmediğiniz glyph'leri siler. Fiyattaki bir para birimi işareti, havuzun dışında bir harf taşıyan bir isim, geçen ay eklediğiniz bir locale'in ay adı: her biri cümlenin ortasında başka bir fonta düşer ve render hatası gibi görünür. Havuzu otomatik üretin ve alt kümede olmayan bir karakter içeriğe girdiğinde build'i düşüren bir kontrol ekleyin.
size-adjustyüzü ölçekler, yani yedek fonta göreemile yaptığınız her ölçü onunla birlikte oynar. Override'ları yalnızca yedek font yüzüne yazın, gerçek yüze asla.local()ancak o font kuruluysa çözülür. Georgia Windows ve macOS'ta var, Android'de yok; Android'de stack jenerik bir serif'e düşer ve eşleşme yaklaşık kalır. Ya bunu kabul edin ya da o platformda bulunan bir font için ikinci bir override bloğu ekleyin.- Her şeyi preload etmek hiçbir şeyi preload etmemekten kötüdür. Her preload yüksek öncelikle çekilir ve dokümanla, en büyük görselle yarışır.
- Variable fontlarda alt kümeleme sırasında eksen korunmalı ya da bir instance sabitlenmeli. Ekseni unutulmuş bir variable font alt kümesi her yerde yanlış ağırlıkta render edilir.
Buradan kalması gereken alışkanlık belirli CSS özellikleri değil, sayıların nereden geldiği. İki harflik bir logonun yetmiş kilobayt glyph taşıdığını kimse tahmin etmez, metrikler uyuştuğunda swap'ın bedava olduğunu da kimse tahmin etmez. İkisi de tek bir ölçüm uzaklıkta ve ikisi de runtime'da değil build'de çözülüyor, yani çözülmüş kalıyor. Font, doğru cevabın zaten diskinizde duran içerik tarafından tamamen belirlendiği ender performans problemlerinden biri.
Sorular ve cevaplar
- font-display: swap layout shift'e sebep olur mu?
- Tek başına hayır. Kayma, yedek fontun aynı metni farklı genişlik ve yükseklikte dizmesinden çıkar; gerçek font gelince satır sonları değişir ve altındaki her şey oynar. Yedek fontun metriğini override edip iki fontun aynı satır kutusunu üretmesini sağlarsanız swap bedava hale gelir ve metnin hemen okunabilir olmasından da vazgeçmemiş olursunuz.
- Alt kümede hangi karakterleri tutacağıma nasıl karar veririm?
- Unicode aralığı seçmek yerine listeyi build sırasında içerikten üretin. Sitenin basabileceği her stringi dolaşın; bir yeri büyük harfle diziyorsanız büyük hallerini, düz ASCII'yi, kullandığınız noktalama işaretlerini, yumuşak tireyi ve desteklediğiniz her locale'de tarih formatlayıcının üretebileceği bütün ay ve gün adlarını ekleyin. Dışarıda bıraktığınız her karakter sessizce başka bir fonttan render edilir.
- size-adjust tam olarak ne yapıyor?
- Bir font yüzünün glyph'lerini, bildirilen font-size'ı değiştirmeden yüzde olarak ölçekler; böylece yedek fontun ortalama karakter genişliğini web fontunkiyle aynı yapabilirsiniz. Satır kutusu yüksekliğini fontun kendi metriğinden bağımsız belirleyen ascent-override ve descent-override ile birleşince, kullanıcının makinesinde kurulu bir font web fontunun yerine piksel piksel geçebilir.
- Kaç font preload etmeliyim?
- Yalnızca ilk ekranda gerçekten boyanan yüzleri, ki bu genelde bir ya da iki tanedir. Preload, tarayıcıya "bu dosya hemen lazım" demektir ve her biri HTML ile en büyük görselden bant genişliği çalar. İtalik, küçük kapital ve ilk ekranın altındaki hiçbir şey preload edilmemeli.