Framework’süz JavaScript: Bu Siteyi Yazarken Öğrendiklerim
Bu sitede tek bir JavaScript kütüphanesi yok. Temayı ilk boyamadan önce uygulamayı, IntersectionObserver'ı, olay delegasyonunu ve kendi kodumda bulduğum kaydırma hatasını anlatıyorum.
Şu an okuduğunuz sitede tek bir JavaScript kütüphanesi yok. Build adımı da yok, paket yöneticisi
de. Sunucuda duran şey birkaç HTML dosyası, bir CSS dosyası ve iki JavaScript dosyası:
main.js ile blog sayfalarını besleyen blog.js. Bu yazı, o iki dosyayı
yazarken öğrendiklerimi ve bir yerde kendi kodumda bulduğum hatayı anlatıyor.
Neden framework kurmadım
Karar ideolojik değildi. Bu site büyük ölçüde metin: birkaç sabit sayfa, bir proje listesi, bir de blog. Ekranlar arasında paylaşılan bir durum yok, sunucudan sürekli akan veri yok. Böyle bir işte React kurmak, çözdüğü sorundan daha fazla parça getiriyordu.
İkinci sebep daha kişisel. Mobil tarafta üç yıldır Swift, Java ve Kotlin yazıyorum; orada platformun kendi API'lerini öğrenmek zorundasınız, arada kalkan yok. Web tarafında da aynı şeyi istedim: tarayıcının bana ne verdiğini önce kendim görmek. Bir davranış bozulduğunda kaynak dosyayı açıp okuyabiliyorum, aradaki katmanı değil.
Bu kararın bir bedeli var ve yazının sonunda ona da geleceğim. Önce işe yarayan kısımlar.
Tema geçişinde ekranın yanıp sönmesi
Sitenin varsayılan teması beyaz. Ziyaretçi koyuya geçerse tercihi localStorage
içinde dt-tema anahtarıyla saklanıyor. İlk yazdığımda tema uygulama kodunu
main.js içine koymuştum ve koyu tema seçmiş biri sayfayı her açtığında beyaz bir
parlama görüyordu.
Sebep basit: main.js sayfanın sonunda, DOMContentLoaded sonrasında
çalışıyor. Tarayıcı o ana kadar sayfayı bir kez boyamış oluyor. Yani beyaz ekran gerçekten
çiziliyor, sonra üstüne koyu tema biniyor. Buna FOUC deniyor ve çözümü, temayı ilk boyamadan
önce uygulamak.
Bu yüzden her HTML dosyasının bölümünde, stil dosyasından da önce
çalışan küçük bir satır içi betik var:
<head>
<script>
(function () {
try {
var kayitli = localStorage.getItem('dt-tema');
document.documentElement.dataset.theme = kayitli || 'light';
} catch (e) { /* gizli mod: varsayılan açık tema kalır */ }
})();
</script>
<link rel="stylesheet" href="css/style.css">
</head>
Bu betik bilerek satır içi ve bilerek engelleyici. Ayrı dosya olsaydı ek bir istek beklenecekti;
defer koysaydım zaten geç çalışacaktı. Birkaç satır olduğu için engellemesi de
önemsiz. Kritik nokta, etiketine data-theme özniteliğini
yazması: CSS değişkenleri o özniteliğe bağlı, yani ilk boyama doğrudan doğru renklerle yapılıyor.
try/catch süsleme değil. Bazı gizli sekme ve çerez kısıtlaması durumlarında
localStorage okuması istisna fırlatıyor. Yakalamazsanız o istisna betiği düşürür ve
data-theme hiç yazılmaz. Temayı yazarken ayrıca meta[name="theme-color"]
etiketini ve documentElement.style.colorScheme değerini de güncelliyorum; ikincisi
tarayıcının kaydırma çubuğunu ve form elemanlarını doğru renkte çizmesi için gerekli.
Otuza yakın HTML dosyasının hepsinde bu beş satır kopya hâlinde duruyor. Framework kullanmamanın faturasının en görünür kalemi bu ve yazının sonunda üstüne konuşacağım.
Kaydırma animasyonunu scroll ile değil, gözlemciyle yapmak
Sayfadaki bölümler görüş alanına girdiğinde yumuşakça beliriyor. İlk aklıma gelen yöntem klasik
olan: scroll dinleyicisi bağla, her olayda elemanların
getBoundingClientRect() değerini oku, görünürse sınıf ekle. Bu yöntem çalışır ama
kaydırma boyunca ana iş parçacığında sürekli ölçüm yapar.
IntersectionObserver bu ilişkiyi tersine çeviriyor: siz sormuyorsunuz, tarayıcı
haber veriyor.
const ogeler = document.querySelectorAll('[data-reveal]');
// Hareket kısıtlaması açıkken ya da API yoksa: her şey doğrudan görünür olsun
if (azHareket || !('IntersectionObserver' in window)) {
ogeler.forEach((el) => el.classList.add('is-visible'));
return;
}
const gozlemci = new IntersectionObserver((girisler, gzl) => {
girisler.forEach((giris) => {
if (!giris.isIntersecting) return;
giris.target.classList.add('is-visible');
gzl.unobserve(giris.target); // bir kez belirdi, artık dinlemeye gerek yok
});
}, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' });
ogeler.forEach((el) => gozlemci.observe(el));
Buradaki üç ayrıntı, kodun geri kalanını da belirledi. Birincisi unobserve: animasyon
bir kere oynayacaksa gözlemciyi orada bırakmanın anlamı yok. İkincisi
prefers-reduced-motion kontrolü; hareket hassasiyeti olan bir kullanıcı için animasyon
kapanmalı, ama içerik de kaybolmamalı. Üçüncüsü yedek yol: API desteklenmiyorsa her şeyi görünür
yapıyorum.
Üçüncü madde önemli, çünkü belirme animasyonunun CSS tarafı elemanı opacity: 0 ile
başlatıyor. JavaScript herhangi bir sebeple çalışmazsa sayfa bomboş kalır. Kural şu:
yedek davranış içeriği daima görünür yapmalı, asla gizli bırakmamalı. Aynı sebeple
ana sayfadaki sayıların gerçek değerleri doğrudan HTML'de yazılı; sayaç animasyonu sadece üstüne
yazıyor. JavaScript kapalıyken de, arama motoru sayfayı tararken de doğru rakam orada duruyor.
Menüdeki aktif bağlantıyı da aynı API ile işaretliyorum, ama farklı bir ayarla:
rootMargin: '-45% 0px -50% 0px'. Bu değer görüş alanını üstten ve alttan kırpıp
ekranın ortasında ince bir şerit bırakıyor. Hangi bölüm o şeride değiyorsa “şu an okunan bölüm”
odur. Yüzdelerle oynayarak sonucu doğrudan görebiliyorsunuz; benim için bu, kaydırma pozisyonunu
elle hesaplamaya çalışmaktan çok daha anlaşılır oldu.
Kendi kodumda bulduğum hata
Bu yazıyı hazırlarken main.js dosyasını baştan okudum ve düzeltmem gereken bir yer
buldum. Üstteki okuma ilerleme çubuğunu güncelleyen fonksiyon, her scroll olayında
document.documentElement.scrollHeight değerini okuyor ve hemen ardından çubuğun
style.width değerini yazıyor.
Sorun şu: scrollHeight bir düzen okumasıdır, genişlik yazmak ise düzeni geçersiz
kılar. Bunu saniyede onlarca kez tekrarladığınızda tarayıcıyı gereksiz yere yeniden hesap yapmaya
zorlarsınız. Üstelik sayfa yüksekliği kaydırma sırasında zaten değişmiyor. Doğrusu, güncellemeyi
kareye bağlamak ve ölçümü yalnızca gerçekten değişebileceği anda yenilemek:
let bekliyor = false;
window.addEventListener('scroll', () => {
if (bekliyor) return; // bu karede zaten sırada bir iş var
bekliyor = true;
requestAnimationFrame(() => {
guncelle();
bekliyor = false;
});
}, { passive: true });
// Ölçüm gerektiren işler için: son olaydan 150 ms sonra bir kez çalış
function geciktir(fn, ms = 150) {
let zaman;
return function (...args) {
clearTimeout(zaman);
zaman = setTimeout(() => fn(...args), ms);
};
}
window.addEventListener('resize', geciktir(olcumleriYenile, 150));
requestAnimationFrame ile kare başına en fazla bir güncelleme yapılıyor; ekran zaten
bundan daha sık boyanmıyor. resize tarafında ise debounce mantığı işe yarıyor, çünkü
pencere sürüklenirken olay yağmuru geliyor ve bize sadece sürükleme bittikten sonraki son ölçüm
lazım.
Doğru yaptığım tek şey { passive: true } eklemekti. Bu, dinleyicinin
preventDefault() çağırmayacağını tarayıcıya baştan söylüyor; tarayıcı da kaydırmayı
JavaScript'in kararını beklemeden sürdürebiliyor.
Olay delegasyonu: kaç dinleyici bağlamalı
Şu an menüdeki her bağlantıya ve her filtre butonuna ayrı bir tıklama dinleyicisi bağlıyorum. Bir avuç eleman için bu tamamen makul; delegasyon bir kural değil, bir denge meselesi.
Ama delegasyonun tercih olmaktan çıkıp zorunluluğa döndüğü bir durum var: sayfaya sonradan giren
içerik. Blog yazılarının altındaki “ilgili yazılar” bloğu, blog listesini fetch ile
çekip DOMParser ile ayrıştırdıktan sonra üretiliyor. O elemanlar
DOMContentLoaded anında ortada yok; o anda bağlanan bir dinleyici onlara asla
ulaşmaz. Orada şansım yaver gitti, çünkü üretilen şey düz bağlantı ve dinleyici gerekmiyor. Kod
bloklarına eklediğim kopyalama butonlarında ise dinleyiciyi butonu oluşturduğum satırda bağlamak
zorunda kaldım. Delegasyon her ikisini de tek bir kuralla çözüyor: dinleyiciyi hep var olan bir
üst elemana bağla, gerçek hedefi olay anında bul.
document.querySelector('.filtre-serit').addEventListener('click', (e) => {
const btn = e.target.closest('.filter');
if (!btn) return; // butonların arasındaki boşluğa tıklandı
uygula(btn.dataset.filter);
});
Buradaki asıl ders closest() tarafında. Başta e.target.dataset.filter
yazmıştım ve buton bazen tepki vermiyordu. Sebep: e.target tıklanan en derindeki
eleman. Butonun içinde bir ikon varsa ve kullanıcı tam ikonun üstüne
tıklarsa, e.target o svg oluyor, buton değil. closest()
yukarı doğru tırmanıp gerçek butonu buluyor. Kenarına tıkladığımda çalışıp ortasına tıkladığımda
çalışmayan bir buton, bu hatanın tipik göstergesi.
Türkçe arama ve “İ” meselesi
Blog listesindeki arama kutusunda beklenmedik bir sorun çıktı: “istanbul” yazınca “İSTANBUL”
geçen yazı bulunmuyordu. Sebep, 'İ'.toLowerCase() çağrısının tek bir karakter değil,
“i” artı ayrı bir birleşen nokta işareti üretmesi. Ekranda “i” gibi görünüyor ama karşılaştırmada
eşleşmiyor.
function sadelestir(metin) {
const harita = { 'ç':'c','ğ':'g','ı':'i','ö':'o','ş':'s','ü':'u',
'İ':'i','I':'i' };
return String(metin)
.replace(/[çğıöşüİI]/g, (h) => harita[h] || h) // önce noktalı İ ve büyük I
.toLocaleLowerCase('tr')
.replace(/[çğıöşü]/g, (h) => harita[h] || h) // küçültmeden dönenleri temizle
.trim();
}
İki aşamalı olmasının sebebi şu: noktalı büyük İ küçültme işleminden önce yakalanmazsa geriye birleşen nokta kalıyor. Kalan Türkçe harfler ise küçültmeden sonra sadeleşiyor. Aynı fonksiyondan hem kartların metnini hem de kullanıcının yazdığını geçiriyorum, böylece iki taraf aynı alfabede buluşuyor.
Performans tarafında da küçük bir düzenleme var: her kartın aranabilir metnini açılışta bir kez
hesaplayıp elemanın üstünde saklıyorum. Aksi hâlde her tuş vuruşunda tüm kartların
textContent değerini yeniden okumak gerekirdi. Kartların ayrıca bir
data-ara özniteliği var; başlıkta geçmeyen ama insanların araması muhtemel kelimeleri
oraya yazıyorum.
Erişilebilirlik: framework'ün sizin yerinize yapmadığı iş
Menü düğmesi gerçek bir
Sayfanın ilk odaklanabilir elemanı bir “İçeriğe geç” bağlantısı; klavyeyle gezen biri her sayfada
menüyü baştan dolaşmak zorunda kalmasın diye. Dekoratif tüm Mobil menü açıkken odak hâlâ arkadaki içeriğe kaçabiliyor; menüye odak tuzağı koymadım. Arama sonucu sayısını yazan alan da
En büyük bedel tekrar. Otuza yakın blog dosyasının her birinde aynı
Bu tekrarı bir yerde kırdım: “ilgili yazılar” bloğu veriyi kendi içinde tutmuyor, blog listesini
Bir de görünmeyen kayıplar var: tip güvenliği yok, kullanılmayan kodu ayıklayan bir araç yok,
birim testi yazmak için bir düzen kurmam gerekiyor. Uygulama karmaşıklaştıkça bunların hepsi
birer maliyete dönüşür.
Bu yüzden çizgim şurada: birbirine bağlı çok sayıda alan içeren formlar, sürekli değişen listeler
ve ekranlar arasında paylaşılan durum varsa framework'e geçerim. Mobil tarafta 2026'da Flutter'a
geçmemin sebebi de aynıydı. Ama bir portföy sitesi için tarayıcının kendi araçları fazlasıyla
yetiyor, üstelik bu yolla öğrendiklerim herhangi bir sürümle birlikte eskimiyor.
Mobil Uygulama & Web Geliştirici
2018'den beri yazılım geliştiriyorum. App Store ve Google Play'de 11 uygulama
yayınladım; şu an 6 mobil uygulama, 1 e-ticaret platformu ve 1 masaüstü oyun üzerinde
çalışıyorum.
Framework yığını olmadan da hızlı, erişilebilir ve arama motoruna uygun siteler yazıyorum. Projenizi anlatın, birlikte nasıl kuracağımıza bakalım.. Tıklama dinleyicisi olan bir
aria-expanded değerini güncelliyor, aria-label metnini “Menüyü kapat”
olarak değiştiriyorum. Escape tuşu menüyü kapatıyor, bir bağlantıya tıklandığında da kapanıyor.
svg ikonlarda
aria-hidden="true" var, çünkü ekran okuyucunun onları okuması sadece gürültü.
aria-live değil, yani ekran okuyucu sonucun değiştiğini duyurmuyor. İkisi de yapılacaklar listemde.Framework kullanmamanın bedeli
,
aynı üst menü, aynı alt bilgi duruyor. Menüye bir bağlantı eklemek, bütün dosyalara dokunmak
demek. Bileşen sistemlerinin çözdüğü sorun tam olarak budur ve bunu kabullenerek çalışıyorum.
fetch ile okuyup DOMParser ile ayrıştırıyor. Böylece yazı başlıkları tek
yerde duruyor. Hoşuma giden bir çözüm ama bedava değil: fazladan bir istek getiriyor ve dosyayı
file:// ile açtığınızda çalışmıyor, yani yerel geliştirmede küçük bir sunucu şart.
Demir Taşdemir
Hafif ve hızlı bir site mi lazım?