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

JavaScript Proxy ve Reflect: Trap'ler, Doğrulama ve Reaktivite

Mert Özen 3 Kas 2026 9 dk 4 görüntülenme
JavaScript Proxy ve Reflect: Trap'ler, Doğrulama ve Reaktivite

Proxy ile bir object'e yapılan okuma, yazma ve silme işlemlerini yakalıyor, Reflect'le varsayılan davranışı koruyoruz. Doğrulama, varsayılan değer ve reaktif arayüz örnekleriyle tuzakları ve maliyeti görüyoruz.

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

Önce Tahmin Et

Bir object'i saran, olmayan property'ler için "none" döndüren küçük bir yapı kuralım. Bu kod konsola sırasıyla ne yazdırır?

const target = { name: "Ada" };

const p = new Proxy(target, {
  get(t, key) {
    return key in t ? t[key] : "none";
  }
});

p.age = 36;
console.log(p.surname);
console.log(target.age);
console.log(p === target);

Okumaya devam etmeden önce üç satır için de bir tahmin yap. Özellikle ortadakine dikkat: biz sadece okumayı yakaladık, yazmaya hiç dokunmadık.

Proxy Nedir? Araya Giren Bir Kapı Görevlisi

Bir şirketin genel müdürüne ulaşmak istediğini düşün. Doğrudan odasına giremezsin; önce asistanıyla konuşursun. Asistan bazı soruları olduğu gibi içeri iletir, bazılarını kendisi cevaplar, bazılarını da kapıda geri çevirir. Müdürün kendisi değişmez, ama onunla kurduğun her temas artık asistanın masasından geçer.

ES2015 ile gelen Proxy tam olarak bu asistandır. new Proxy(target, handler) ile hedef object'i saran yeni bir object oluşturursun. handler, trap adı verilen fonksiyonları taşıyan sıradan bir object'tir: okumayı yakalayan get, yazmayı yakalayan set, in operatörünü yakalayan has, delete'i yakalayan deleteProperty ve birkaç tane daha.

Motorun tarafında olan şu: JavaScript'te bir object'le yaptığın her işlem aslında bir iç işleme (internal method) dönüşür. obj.x yazdığında motor [[Get]]'i, obj.x = 1 yazdığında [[Set]]'i çalıştırır. Proxy bu iç işlemlerin her birine bir kanca takmana izin verir. Handler'da karşılığı olan bir trap yoksa işlem hiçbir şey olmamış gibi hedef object'e iletilir.

İlk Trap'ler: get, set, has, deleteProperty

Dört temel trap'i, her işlemi konsola raporlayan bir proxy ile görelim. Bunu tarayıcının konsoluna yapıştırıp dene:

const user = { name: "Ada", role: "admin" };

const watched = new Proxy(user, {
  get(target, key) {
    console.log(`read: ${String(key)}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`written: ${String(key)} = ${value}`);
    target[key] = value;
    return true;
  },
  has(target, key) {
    console.log(`checked: ${String(key)}`);
    return key in target;
  },
  deleteProperty(target, key) {
    console.log(`deleted: ${String(key)}`);
    return delete target[key];
  }
});

watched.name;
watched.age = 36;
"role" in watched;
delete watched.role;
console.log(user); // { name: "Ada", age: 36 }

Her trap ilk argüman olarak hedef object'i, ikinci olarak da property anahtarını alır. Anahtar bir string ya da symbol olabilir; bu yüzden onu template literal'e koymadan önce String() ile sardık, yoksa symbol bir anahtarda kod TypeError verirdi. set ve deleteProperty bir de boolean döndürür: true "işlem başarılı" demek.

Şimdi baştaki tahmine dönelim. p.surname hedefte olmadığı için "none" yazar. p.age = 36 satırında handler'da set trap'i yok, dolayısıyla yazma işlemi doğrudan hedefe iletilir ve target.age 36 olur. Son satır ise false: proxy hedefin kendisi değil, onu saran ayrı bir object'tir.

Reflect: Varsayılan Davranışın Kendisi

Trap içinde "normalde ne olacaksa onu yap" demek için target[key] yazdık. Çoğu zaman işe yarar ama ince bir açığı var. ES2015 ile Proxy'nin yanında gelen Reflect object'i bu açığı kapatır. Reflect'in method'ları trap'lerle birebir aynı isimleri ve aynı parametreleri taşır: Reflect.get, Reflect.set, Reflect.has, Reflect.deleteProperty, Reflect.ownKeys ve diğerleri. Her biri, o iç işlemin varsayılan hâlini çalıştırır.

Farkı bir getter ile görmek en kolayı:

const person = {
  name: "Ada",
  surname: "Lovelace",
  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

const leaky = new Proxy(person, {
  get(target, key) {
    console.log("get:", key);
    return target[key];
  }
});

const solid = new Proxy(person, {
  get(target, key, receiver) {
    console.log("get:", key);
    return Reflect.get(target, key, receiver);
  }
});

leaky.fullName; // get: fullName
solid.fullName; // get: fullName, get: name, get: surname

get trap'inin üçüncü argümanı receiver, okumanın asıl yapıldığı object'tir; burada proxy'nin kendisi. target[key] yazdığında getter this olarak hedefi görür ve this.name okuması proxy'yi hiç uğramadan atlar. Reflect.get(target, key, receiver) ise getter'ı this proxy olacak şekilde çalıştırır, böylece iç okumalar da trap'ten geçer. Reaktivite gibi "her okumayı bilmem lazım" diyen işlerde bu fark hayatidir.

Bir artısı daha var: Reflect.set ve Reflect.deleteProperty hata fırlatmak yerine boolean döndürür. Bu değer, trap'in döndürmesi gereken değerle aynı olduğu için doğrudan return Reflect.set(...) yazabilirsin.

Doğrulama: Yanlış Veriyi Kapıda Durdurmak

Proxy'nin en sık kullanıldığı yerlerden biri, bir object'e giren veriyi kontrol etmek. Kuralları dışarıdan alan küçük bir yardımcı yazalım:

function validated(target, rules) {
  return new Proxy(target, {
    set(t, key, value, receiver) {
      const rule = rules[key];
      if (rule && !rule(value)) {
        throw new TypeError(`Invalid value: ${String(key)} = ${value}`);
      }
      return Reflect.set(t, key, value, receiver);
    }
  });
}

const product = validated({}, {
  name: (v) => typeof v === "string" && v.trim() !== "",
  price: (v) => typeof v === "number" && v >= 0
});

product.name = "Keyboard";
product.price = 750;

try {
  product.price = -5;
} catch (e) {
  console.log(e.message); // Invalid value: price = -5
}
console.log(product.name, product.price); // Keyboard 750

Neden return false yerine hata fırlattık? Çünkü set trap'inden false dönmenin etkisi koda göre değişir. Strict mode'da, yani module'lerde ve class gövdelerinde, motor bunu bir TypeError'a çevirir. Konsol gibi sloppy mode'da çalışan kodda ise atama sessizce yok sayılır; ne hata alırsın ne de değer yazılır. Kendi mesajınla hata fırlatmak her iki durumda da ne olduğunu açıkça gösterir.

Varsayılan Değerler ve Gizli Alanlar

get trap'i "olmayan şeye ne cevap verilsin" sorusunu da sana bırakır. Kullanıcının yalnızca değiştirdiği ayarları tutup gerisini varsayılanlardan okuyan bir ayar object'i:

const defaults = { theme: "light", lang: "en", fontSize: 16 };

function createSettings(userSettings) {
  return new Proxy(userSettings, {
    get(t, key, receiver) {
      if (Reflect.has(t, key)) return Reflect.get(t, key, receiver);
      return defaults[key];
    }
  });
}

const settings = createSettings({ theme: "dark" });
console.log(settings.theme, settings.lang, settings.fontSize); // dark en 16

Birkaç trap'i birlikte kullanınca daha ilginç şeyler çıkar. Alt çizgiyle başlayan property'leri dışarıdan görünmez yapalım: okunamasınlar, in ile bulunamasınlar, Object.keys listesinde çıkmasınlar ve silinemesinler.

function hide(target) {
  const isHidden = (k) => typeof k === "string" && k.startsWith("_");
  return new Proxy(target, {
    get: (t, k, r) => (isHidden(k) ? undefined : Reflect.get(t, k, r)),
    has: (t, k) => (isHidden(k) ? false : Reflect.has(t, k)),
    ownKeys: (t) => Reflect.ownKeys(t).filter((k) => !isHidden(k)),
    deleteProperty: (t, k) => (isHidden(k) ? false : Reflect.deleteProperty(t, k))
  });
}

const account = hide({ owner: "Ada", _internalId: 4821 });
console.log(account._internalId);      // undefined
console.log("_internalId" in account); // false
console.log(Object.keys(account));     // ["owner"]

ownKeys trap'i, Object.keys, for...in ve Reflect.ownKeys gibi anahtar listeleyen her işlemi yakalar. Yine de bunu gerçek bir güvenlik önlemi sanma: hedef object'e elinde referansı olan herkes doğrudan erişebilir. Gerçek gizlilik için daha önce gördüğümüz #private alanlar ya da closure doğru araç.

Reaktivite Fikri: Değişince Ekranı Güncelle

Proxy'yi bu kadar ilgi çekici yapan asıl fikir şu: bir object'e yazıldığını anında bildiğine göre, her yazmada ekranı da güncelleyebilirsin. Elle "şimdi ekranı yenile" demeyi unutma ihtimalin ortadan kalkar. Aşağıdakini bir HTML dosyasına kaydedip tarayıcıda aç:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Reactivity with Proxy</title>
</head>
<body>
  <input id="name" placeholder="Your name">
  <button id="like">Like</button>
  <p id="output"></p>

  <script>
    function reactive(initial, render) {
      const state = new Proxy(initial, {
        set(t, key, value, receiver) {
          const result = Reflect.set(t, key, value, receiver);
          render(state);
          return result;
        }
      });
      render(state);
      return state;
    }

    const output = document.querySelector("#output");

    const state = reactive({ name: "guest", likes: 0 }, (s) => {
      output.textContent = `Hello ${s.name}, ${s.likes} likes`;
    });

    document.querySelector("#name").addEventListener("input", (e) => {
      state.name = e.target.value || "guest";
    });
    document.querySelector("#like").addEventListener("click", () => {
      state.likes++;
    });
  </script>
</body>
</html>

Event listener'ların içinde DOM'a tek satır dokunmadık; yalnızca state object'ini değiştirdik. Ekranı güncelleme işi tamamen set trap'inin sorumluluğunda. Bu küçük örnek, reaktif arayüzlerin temel fikrini taşıyor. Gerçek bir sistemde birkaç adım daha eklenir: aynı anda yapılan birden fazla değişikliği tek bir render'da toplamak, hangi alanın hangi parçayı etkilediğini get ile takip edip sadece onu güncellemek gibi.

Sık Düşülen Tuzaklar

Bazı yerleşik object'ler proxy'ye gelmez. Map, Set, Date ve DOM element'leri verilerini property'lerde değil, internal slot denen iç yuvalarda tutar. Method'ları da this'in gerçek bir Map olmasını bekler. Proxy üzerinden çağırınca this proxy olur ve TypeError alırsın. Çözüm, fonksiyonları hedefe bağlamak:

const map = new Map([["a", 1]]);

const broken = new Proxy(map, {});
try {
  broken.get("a");
} catch (e) {
  console.log(e.constructor.name); // TypeError
}

const working = new Proxy(map, {
  get(t, k) {
    const value = Reflect.get(t, k, t);
    return typeof value === "function" ? value.bind(t) : value;
  }
});
console.log(working.get("a"), working.size); // 1 1

Burada receiver yerine bilerek t verdik; size bir getter ve onun da gerçek Map'i görmesi gerekiyor. Aynı sorun #private alan kullanan class'larda da çıkar: method proxy üzerinden çağrılınca this.#field okuması TypeError verir.

Proxy'nin bozamayacağı kurallar var. Motor, trap'lerin hedefle çelişen cevaplar vermesine izin vermez; bunlara invariant denir. Örneğin hedefte yazılamaz ve ayarlanamaz (non-writable, non-configurable) bir property varsa, get trap'i onun gerçek değerini döndürmek zorundadır. Daha önce gördüğümüz Object.freeze tam olarak böyle property'ler üretir:

const constants = Object.freeze({ pi: 3.14 });
const liar = new Proxy(constants, { get: () => 42 });

try {
  liar.pi;
} catch (e) {
  console.log(e.message); // trap'in kuralı çiğnediğini söyleyen bir TypeError
}

Proxy sığdır. Reaktif örnekte state.address.city = "Izmir" yazarsan dıştaki set trap'i çalışmaz; çünkü bu satır address'i okur, sonra düz bir object'e yazar. İç içe object'ler için get trap'inde iç object'i de proxy'ye sarman gerekir. Son olarak, hedefi doğrudan değiştiren kod trap'lerin hiçbirine uğramaz. Proxy kullanıyorsan dışarıya hedefin değil, proxy'nin referansını ver.

Performans: Proxy'nin Bedeli

Her trap bir fonksiyon çağrısıdır ve motor düz object'lerde kullandığı hızlı yolları proxy'de her zaman uygulayamaz. Boş bir handler bile farkı hissettirir. İlerleyen yazılarda ayrıntılı göreceğimiz performance.now() ile kabaca ölçelim:

const array = Array.from({ length: 1_000_000 }, (_, i) => i);
const wrapped = new Proxy(array, {});

function sum(a) {
  let s = 0;
  for (let i = 0; i < a.length; i++) s += a[i];
  return s;
}

let t0 = performance.now();
sum(array);
console.log("plain array:", (performance.now() - t0).toFixed(1), "ms");

t0 = performance.now();
sum(wrapped);
console.log("proxy:", (performance.now() - t0).toFixed(1), "ms");

Sayılar makineden makineye değişir ama proxy'li sürüm belirgin biçimde yavaş kalır. Bu yüzden proxy'yi sınırlarda kullan: ayar object'leri, bir module'ün dışarıya açtığı yüzey, geliştirme sırasında yapılan kontroller. Saniyede milyonlarca kez dokunulan veri yapılarını proxy'ye sarma.

Son bir araç: Proxy.revocable. Geri alınabilir bir proxy üretir ve { proxy, revoke } döndürür. revoke() çağırdıktan sonra proxy üzerindeki her işlem TypeError verir. Bir object'e geçici erişim verip sonra bu erişimi kesmek istediğinde işe yarar.

Küçük Bir Deneme

★☆☆ Bir array'i saran ve p[-1] ile son element'i, p[-2] ile sondan ikinciyi döndüren bir fromEnd(array) fonksiyonu yaz. İpucu: trap'e gelen anahtar her zaman string'dir, Number(key) ile çevir.

★★☆ Çıktıyı tahmin et: const p = new Proxy({}, { set: () => false }); p.x = 1; console.log(p.x); Önce konsolda çalıştır, sonra aynı kodu bir HTML dosyasındaki <script type="module"> içine koy. İki sonuç neden farklı?

★★★ Reaktif örnekte Like butonuna, aynı anda hem state.likes'ı hem de state.name'i değiştiren bir handler bağla ve render'ın iki kez çalıştığını gör. Daha önce gördüğümüz queueMicrotask ile değişiklikleri toplayıp tek bir render yapan bir sürüm yaz.

Bu yazıda proxy'nin hedef object'i sürekli elinde tuttuğunu, reaktif örnekte de event listener'ların state'i yaşattığını gördük. Peki bir object bellekte ne zaman kalır, ne zaman silinir? Bir sonraki yazıda garbage collection'ın nasıl çalıştığına, sık görülen bellek sızıntılarına ve WeakMap ile WeakRef'e bakacağız.