Mert Özen Her Satırda Daha İleri
Frontend JavaScript Orta — #03

JavaScript Scope, Hoisting ve Closure: Değişkenler Nerede Yaşar?

Mert Özen 3 Kas 2026 9 dk 4 görüntülenme
JavaScript Scope, Hoisting ve Closure: Değişkenler Nerede Yaşar?

Global, function ve block scope'u, scope chain'i, hoisting'i ve TDZ'yi; closure'ı da sayaç, private veri ve fonksiyon fabrikası örnekleri ve döngüdeki meşhur tuzakla birlikte öğreniyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Üç satırlık bir döngü ve bir zamanlayıcı:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Bu kod konsola ne yazdırır? Okumaya devam etmeden önce bir tahmin yap. 0 1 2 diyorsan yalnız değilsin; JavaScript mülakatlarının en sevilen sorularından biri tam olarak bu. Cevabı closure'ı anlattıktan sonra, yazının sonlarına doğru vereceğiz.

Scope Nedir? İç İçe Odalar

Scope, bir değişkenin görülebildiği ve kullanılabildiği alandır. Bunu iç içe geçmiş odalar gibi düşün, ama camları tek yönlü olan odalar. İçerideki bir odadan dışarıdaki odaları görebilirsin; dışarıdan içeriye bakamazsın. JavaScript'te üç temel oda tipi var.

Global scope en dıştaki odadır; hiçbir fonksiyonun ya da bloğun içinde olmayan tanımlar burada yaşar ve her yerden görülür. Function scope, bir fonksiyonun içidir; orada tanımlanan değişkenler fonksiyon dışından görülmez. Block scope ise süslü parantezlerle açılan her bloktur: if, for, ya da tek başına bir { }. Daha önce gördüğümüz let ve const ES2015'ten beri bu bloklara saygı duyar. var ise blokları tanımaz, yalnızca fonksiyon sınırında durur.

Bunu tarayıcının konsoluna yapıştırıp dene:

const appName = "Shop";            // global scope

function showCart() {
  const itemCount = 3;              // function scope
  if (itemCount > 0) {
    const message = `${appName}: ${itemCount} items`; // block scope
    var legacy = "var ignores blocks";
    console.log(message);           // "Shop: 3 items"
  }
  console.log(legacy);              // "var ignores blocks"
  console.log(typeof message);      // "undefined", burada görünmez
}

showCart();
console.log(typeof itemCount);      // "undefined", dışarıdan görünmez

message değişkeni if bloğunun içinde doğdu ve blok bitince görünmez oldu. legacy ise var ile tanımlandığı için bloğun dışına sızdı. Bu yüzden modern kodda var yerine let ve const kullanıyoruz. Bir not: module'lerin de kendi scope'u var; onu tarayıcıda module'ler yazısında göreceğiz.

Scope Chain: İçeriden Dışarıya Arama

Engine bir değişken adıyla karşılaştığında önce bulunduğu odaya bakar. Orada yoksa bir dış odaya, sonra bir dışına çıkar ve global scope'a kadar devam eder. Bu zincire scope chain denir. Global'de de bulamazsa ReferenceError fırlatır.

Kritik nokta şu: bu zincir, fonksiyonun nerede çağrıldığına göre değil, kodda nerede yazıldığına göre kurulur. Buna lexical scope denir. Şu örnek bunu net gösteriyor:

const level = "global";

function outer() {
  const level = "outer";
  function inner() {
    console.log(level);
  }
  return inner;
}

function elsewhere() {
  const level = "elsewhere";
  const fn = outer();
  fn();
}

elsewhere(); // "outer"

inner, elsewhere'in içinden çağrıldı ama outer'ın içinde yazıldı. Engine değişkeni ararken yazıldığı yere bakar, bu yüzden sonuç "outer". Aynı isimli değişkenlerin içteki tarafından gölgelenmesine de shadowing denir: outer içindeki level, global olanı gizler. İçeride aynı isimle yeni bir değişken tanımladığında dıştakine bir daha o bloktan ulaşamazsın.

Hoisting: Engine Kodu İki Kez Okur

Engine bir scope'a girdiğinde kodu hemen satır satır çalıştırmaz. Önce bir hazırlık turu yapar ve o scope'taki bütün tanımları not eder: hangi değişkenler var, hangi fonksiyonlar var. Ancak bundan sonra çalıştırmaya başlar. Tanımların kodun en üstüne "kaldırılmış" gibi davranmasına hoisting denir. Kod gerçekten yer değiştirmez; sadece engine isimleri önceden bilir.

console.log(greetUser("Ada")); // "Hello Ada"
console.log(total);            // undefined
var total = 100;
console.log(total);            // 100

function greetUser(name) {
  return `Hello ${name}`;
}

Function declaration'lar gövdeleriyle birlikte hazırlanır, bu yüzden tanımdan önce çağrılabilir. var ile tanımlanan değişkenin ise sadece adı hazırlanır ve değeri undefined olarak başlar; atama, kodun o satırına gelindiğinde yapılır. Function expression ve arrow function'lar bir değişkene atandığı için o değişkenin kurallarına uyar:

console.log(typeof sayBye); // "undefined"
var sayBye = function () {
  return "See you";
};
console.log(sayBye());      // "See you"

sayBye'ı tanımdan önce çağırsaydın TypeError: sayBye is not a function alırdın, çünkü o anda değeri undefined. Aynı şeyi const ile yapsaydın hata başka olurdu. Bu bizi bir sonraki konuya getiriyor.

TDZ: let ve const'un Bekleme Odası

let ve const da hoisting'e uğrar; engine onları da hazırlık turunda not eder. Ama onlara bir başlangıç değeri vermez. Bloğun başından tanımın yapıldığı satıra kadar olan bölgeye TDZ (Temporal Dead Zone) denir ve bu bölgede değişkene dokunmak hata verir:

function checkout() {
  console.log(discount); // ReferenceError: Cannot access 'discount' before initialization
  let discount = 10;
}
checkout();

Hata mesajı Chrome'dan; Firefox ve Safari aynı şeyi farklı cümlelerle söyler. "Peki let gerçekten hoist ediliyor mu?" diye sorabilirsin. Şu örnek cevabı veriyor:

const tax = 18;

function calcPrice() {
  console.log(tax); // ReferenceError, 18 değil
  const tax = 20;
}
calcPrice();

Eğer const tax = 20 hoist edilmeseydi, engine fonksiyonun içinde tax'ı bulamaz, scope chain'de dışarı çıkar ve global 18'i yazdırırdı. Ama engine hazırlık turunda içerideki tax'ı zaten not etti, sadece henüz başlatmadı. TDZ bir ceza değil, bir güvenlik önlemi: değişkeni tanımlamadan kullanmak neredeyse her zaman bir hatadır ve var'ın sessiz undefined'ı yerine sana bunu yüksek sesle söyler.

Closure: Fonksiyonun Sırt Çantası

Normalde bir fonksiyon bittiğinde içindeki yerel değişkenler de işini bitirir. Ama ya fonksiyonun içinde doğan başka bir fonksiyon dışarı çıkarsa ve o değişkenlere hâlâ ihtiyaç duyuyorsa? İşte closure burada devreye girer. Her fonksiyon, doğduğu ortamın değişkenlerini bir sırt çantası gibi yanında taşır. Çağrıldığı yer neresi olursa olsun, çantasındaki değişkenlere ulaşabilir.

function createCounter() {
  let count = 0;
  return function () {
    count++;
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

const another = createCounter();
console.log(another()); // 1
console.log(counter()); // 3

createCounter çoktan bitti, ama döndürdüğü fonksiyon count'u kullanmaya devam ediyor. Engine, o değişkene hâlâ referans veren bir fonksiyon olduğunu gördüğü için ortamı silmez. Her createCounter() çağrısı da yeni bir ortam, yani yeni bir çanta oluşturur; bu yüzden another kendi sayacıyla sıfırdan başladı.

Tanımı tek cümleyle yapalım: closure, bir fonksiyonla birlikte o fonksiyonun yazıldığı yerdeki lexical ortamın birleşimidir. Aslında JavaScript'te her fonksiyon bir closure'dır; konu sadece iç fonksiyon dışarı çıktığında görünür hâle gelir. Daha önce map'e verdiğimiz callback'lerin dışarıdaki products array'ine ulaşabilmesinin sırrı da buydu.

Closure Nerelerde İşe Yarar?

İlk kullanım alanı private veri. Closure içindeki bir değişkene dışarıdan doğrudan erişilemez; sadece senin verdiğin fonksiyonlar üzerinden okunur ve değiştirilir:

function createWallet(initial) {
  let balance = initial;
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) return "Insufficient funds";
      balance -= amount;
      return balance;
    },
    getBalance: () => balance
  };
}

const wallet = createWallet(100);
console.log(wallet.deposit(50));   // 150
console.log(wallet.withdraw(500)); // "Insufficient funds"
console.log(wallet.balance);       // undefined, doğrudan erişim yok

Bakiyeyi kimse yanlışlıkla -1000 yapamaz, çünkü ona giden tek kapı deposit ve withdraw. Class'larda aynı işi # ile tanımlanan private alanlar yapar; onları class'lar yazısında göreceğiz.

İkinci kullanım alanı fonksiyon fabrikaları. Bir ayarı bir kez verip ona göre davranan yeni fonksiyonlar üretirsin:

function multiplier(factor) {
  return (n) => n * factor;
}

const double = multiplier(2);
const triple = multiplier(3);
console.log([1, 2, 3].map(double)); // [2, 4, 6]
console.log(triple(5));             // 15

function once(fn) {
  let done = false;
  return (...args) => {
    if (done) return;
    done = true;
    return fn(...args);
  };
}

const init = once(() => console.log("Initialized"));
init(); // "Initialized"
init(); // hiçbir şey olmaz

once gibi küçük yardımcılar, event handler'lar ve zamanlayıcılar closure'ın günlük hayattaki yüzü. Bir butona tıklama sayısını tutan bir listener yazdığında da sayacı büyük ihtimalle bir closure'da saklarsın.

Döngüde Closure Tuzağı

Şimdi baştaki soruya dönelim. Cevap 3 3 3. Nedenini artık parça parça açıklayabiliriz. var blokları tanımadığı için döngünün tamamında tek bir i vardır. Üç callback de aynı i'yi çantasına koyar. Zamanlayıcılar ise döngü bittikten sonra çalışır; neden sonra çalıştıklarını asenkron yazılarında ayrıntılı göreceğiz. O anda tek i'nin değeri 3'tür ve üç callback de onu okur.

let ile yazdığında ise for döngüsü her tur için yeni bir i oluşturur. Her callback kendi turunun i'sini taşır:

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// 0 1 2

Eski kodlarda bu sorunun IIFE (hemen çağrılan fonksiyon) ile çözüldüğünü görebilirsin. Her tur için yeni bir fonksiyon scope'u açıp değeri parametre olarak kopyalar:

for (var k = 0; k < 3; k++) {
  (function (copy) {
    setTimeout(() => console.log(copy), 0);
  })(k);
}
// 0 1 2

Bugün buna gerek yok; let aynı işi kendiliğinden yapıyor. Ama bu kalıbı tanımak eski bir kodu okurken işine yarar.

Sık Düşülen Tuzaklar

Tanımsız atama. Klasik bir script'te let, const ya da var yazmadan result = 5 dersen, engine scope chain'in sonuna kadar gider, bulamaz ve sessizce global bir değişken oluşturur. Strict mode'da (ES5'ten beri var, module'lerde varsayılan) aynı satır ReferenceError verir. Yazım hatası yapılmış bir değişken adının sessizce global olması, saatlerce aranan hataların klasiğidir.

Global var ile window'u ezmek. Klasik bir script'in en üstünde var ile tanımlanan değişken window object'inin property'si olur; let ve const olmaz. Bu, tarayıcının kendi property'leriyle çakışabilir. Mesela window.name her zaman string tutar:

var name = 42;
console.log(typeof name); // "string", window.name onu dönüştürdü

Aynı konsolda kodu ikinci kez çalıştırmak. Bir örneği tekrar yapıştırdığında "has already been declared" türünde bir hata görebilirsin; aynı scope'ta aynı isim iki kez tanımlanamaz. Sayfayı yenilemen ya da kodu bir { } bloğunun içine alman yeter.

Closure'ın gereğinden fazlasını tutması. Bir closure, kullandığı değişkenleri canlı tutar. Kocaman bir array'i tutan bir closure'ı uzun ömürlü bir event listener'a bağlarsan o array de bellekte kalır. Bellek yönetimini İleri seride ayrıntılı ele alacağız; şimdilik closure'ların bedava olmadığını aklında tut.

Küçük Bir Deneme

★☆☆ Çalıştırmadan önce tahmin et: function f() { console.log(x); var x = 1; console.log(x); } f(); Sonra var'ı let ile değiştir ve ne değiştiğini açıkla.

★★☆ createCounter'ı genişlet: increment, decrement ve reset method'ları olan bir object döndürsün. count'a dışarıdan doğrudan ulaşılamadığını konsolda göster.

★★☆ makeGreeting(greeting) adında bir fonksiyon fabrikası yaz. makeGreeting("Hi")("Ada") çağrısı "Hi, Ada!" döndürsün.

★★★ Bir sayfaya üç buton koyan küçük bir HTML dosyası yaz. Butonları var kullanan bir for döngüsüyle oluştur ve her birine "I am number 1" gibi kendi sırasını gösteren bir click listener ekle. Önce bozuk hâlini gözlemle, sonra iki farklı yöntemle düzelt.

Closure'lar değişkenleri yazıldıkları yerden yakalıyor. Ama JavaScript'te bu kurala uymayan, değeri fonksiyonun nerede yazıldığına değil nasıl çağrıldığına göre belirlenen özel bir kelime var: this. Bir sonraki yazıda this'in nasıl belirlendiğini, method'ların neden this'ini kaybettiğini ve call, apply, bind ile onu nasıl kontrol altına alacağını göreceğiz.