Tüm yazılar Yazılım

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.

DT
Demir Taşdemir Mobil Uygulama & Web Geliştirici
— dk okuma

Ş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.

Aynı betik her dosyada tekrar ediyor

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

DT

Demir Taşdemir

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.

Hafif ve hızlı bir site mi lazım?

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.