JavaScript Module'leri Tarayıcıda: import, export, Dynamic import() ve Import Maps
type="module" ile ilk module'ü yazıyor, file:// neden çalışmaz görüyor, named ve default export'u, live binding'i, dynamic import()'u, import maps'i ve ES2025'in JSON module'lerini öğ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
İki küçük dosya düşün. Birincisi bir sayaç tutuyor ve onu dışarı açıyor, ikincisi bu sayacı içeri alıp kullanıyor:
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from "./counter.js";
increment();
increment();
console.log(count);
count = 10;
İki soru var. console.log ne yazdırır? Son satır ne yapar? Okumaya devam etmeden önce tahminini yap. "İçeri aldığım şey bir kopyadır" diye düşünüyorsan ilk cevabın 0 olacaktır; module'lerin davranışını gördüğümüz bölümde bu düşünceyi birlikte sınayacağız.
Neden Module?
Daha önce bir sayfaya birden çok <script> etiketiyle dosya eklediğinde hepsi aynı global scope'u paylaşıyordu. Bir dosyadaki let total ile başka bir dosyadaki let total çakışır, bir dosyanın fonksiyonu ötekinden önce yüklenmezse kod patlar. Dosya sayısı arttıkça "hangi script hangisine bağlı, sıralama neden önemli" sorusu tek başına bir işe dönüşür.
Tek odalı bir ev düşün: herkesin eşyası ortada, kimin neyi nereye koyduğu belli değil. Module'ler evi odalara böler. Her module kendi odasıdır, içindeki değişkenler dışarıdan görünmez. Bir şeyi paylaşmak istersen kapıya koyarsın (export), başka odadan bir şey istersen adını söyleyerek alırsın (import). Bağımlılıklar artık sıralamayla değil, dosyanın en üstündeki açık bildirimlerle kurulur. JavaScript'in kendi module sistemi ES2015 ile geldi ve bugün bütün güncel tarayıcılarda yerleşik olarak çalışıyor.
İlk Module'ün ve Yerel Sunucu
Bir klasör aç ve içine üç dosya koy. Bir HTML, bir yardımcı module ve bir giriş module'ü:
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>First module</title>
<script type="module" src="./main.js"></script>
</head>
<body>
<p id="result"></p>
</body>
</html>
// math.js
export const PI = 3.14159;
export function circleArea(radius) {
return PI * radius ** 2;
}
// main.js
import { circleArea } from "./math.js";
document.querySelector("#result").textContent =
`Area of a circle with radius 2: ${circleArea(2).toFixed(2)}`;
Şimdi index.html'e çift tıklayıp aç. Büyük ihtimalle sayfa boş kalacak ve konsolda CORS ile ilgili kırmızı bir hata göreceksin. Sebebi şu: tarayıcı module dosyalarını sıradan script'ler gibi değil, CORS kurallarına tabi bir istekle yükler. Dosyayı diskten açtığında adres file:// ile başlar ve böyle bir sayfanın gerçek bir origin'i yoktur. Tarayıcı, origin'i belli olmayan bir sayfanın diskteki başka dosyaları module olarak çekmesine güvenlik gereği izin vermez; aksi hâlde indirdiğin herhangi bir HTML dosyası bilgisayarındaki dosyaları okumaya kalkabilirdi.
Çözüm, klasörü küçük bir yerel sunucu üzerinden açmak; editörünün Live Server gibi bir eklentisi ya da herhangi bir basit statik sunucu işini görür. Adres http://localhost:... ile başladığında sayfa yerine gelir. Bir ayrıntı daha: sunucu .js dosyalarını JavaScript MIME type'ıyla, yani text/javascript ile göndermelidir. Hazır statik sunucuların hepsi bunu zaten yapar.
export ve import: Named ve Default
Bir module'den birden çok şeyi isimleriyle dışarı açabilirsin. Bunlara named export denir. İçeri alırken aynı isimleri süslü parantez içinde yazarsın; istersen as ile yeniden adlandırırsın:
// format.js
export function formatPrice(value) {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(value);
}
function slugify(text) {
return text.toLowerCase().trim().replaceAll(" ", "-");
}
export { slugify as toSlug };
// main.js
import { formatPrice, toSlug as makeSlug } from "./format.js";
import * as format from "./format.js";
console.log(formatPrice(1499.9)); // "₺1.499,90"
console.log(makeSlug("Summer Sale")); // "yaz-fırsatı"
console.log(format.formatPrice(10)); // namespace üzerinden erişim
import * as format bütün named export'ları tek bir object'te toplar; buna namespace import denir. Bir de default export var: bir module'ün "asıl" değerini isim vermeden dışarı açar ve her module'de en fazla bir tane olabilir. İçeri alırken süslü parantez kullanmazsın ve istediğin ismi verirsin:
// Cart.js
export default class Cart {
#items = [];
add(item) { this.#items.push(item); }
get size() { return this.#items.length; }
}
// main.js
import Cart from "./Cart.js";
import ShoppingCart from "./Cart.js"; // aynı class, başka isim
const cart = new Cart();
cart.add({ id: 1 });
console.log(cart.size); // 1
Default export'un esnekliği aynı zamanda zayıflığıdır: herkes ona farklı bir isim verebilir ve editörün aramasında izini kaybedersin. Pek çok ekip bu yüzden named export'u varsayılan tercih yapar. Bir kural daha: tarayıcıda yol yazarken ./ ya da ../ ile başlamalı ve dosya uzantısını yazmalısın. import { x } from "math" gibi çıplak bir isim, aşağıda göreceğimiz import maps olmadan çalışmaz.
Module'ler Neden Farklı Davranır?
Module script'leri sıradan script'lerden birkaç önemli noktada ayrılır. Bunları bilmek, "kod aynı ama neden çalışmıyor" anlarının çoğunu açıklar.
Module'ler her zaman strict mode'da çalışır; daha önce gördüğümüz "use strict"'i yazmana gerek yoktur. Her module'ün kendi scope'u vardır: en üst seviyede tanımladığın let, const, function ve hatta var bile window'a eklenmez. En üst seviyedeki this window değil, undefined'dır. Module script'leri varsayılan olarak defer gibi davranır: HTML ayrıştırması bitene kadar beklerler ve sayfadaki sıralarıyla çalışırlar, o yüzden head içine koyduğumuz script DOM'u rahatça bulabildi. Daha önce async/await yazısında gördüğümüz top-level await da yalnızca module'lerde çalışır.
Bir module, sayfada kaç yerden import edilirse edilsin yalnızca bir kez çalışır. Tarayıcı her module'ü adresine göre bir haritada tutar; ikinci import yeni bir kopya oluşturmaz, aynı instance'ı verir. Bir module'ün kendi adresine ihtiyacın olursa import.meta.url onu söyler.
Şimdi baştaki soruya dönelim. console.log(count) 2 yazar. Import edilen şey bir kopya değil, canlı bir bağlantıdır (live binding): counter.js içindeki count değiştikçe main.js'teki count da aynı değeri gösterir. Bunu bir pencereye benzetebilirsin; yan odaya bakıyorsun ama içeriyi düzenleyemiyorsun. Son satır bu yüzden TypeError fırlatır: import edilen bağlantılar içeri alan module için salt okunurdur. Değeri değiştirmek istiyorsan, sahibi olan module'ün sunduğu increment gibi bir fonksiyonu çağırırsın.
Dynamic import(): İhtiyaç Olunca Yükle
Dosyanın en üstündeki import satırları statiktir: sayfa açılırken hepsi yüklenir ve bir if bloğunun içine yazılamazlar. Bazı kodlara ise ancak kullanıcı bir şey yaptığında ihtiyaç duyarsın: büyük bir grafik kütüphanesi, nadiren açılan bir ayarlar paneli. import() fonksiyon gibi çağrılan ve bir promise döndüren dinamik bir import'tur:
// main.js
document.querySelector("#show-report").addEventListener("click", async () => {
const { drawReport } = await import("./report.js");
drawReport(document.querySelector("#report"));
});
// Dile göre çeviri dosyası seçmek
const lang = navigator.language.startsWith("tr") ? "tr" : "en";
const { default: messages } = await import(`./i18n/${lang}.js`);
console.log(messages.welcome);
import()'un döndürdüğü promise, module'ün namespace object'iyle çözülür; named export'ları destructuring ile, default export'u da default adıyla alırsın. Yolun bir değişkenden üretilebilmesi, statik import'un yapamadığı bir şeydir. Ayrıca import() module olmayan sıradan bir <script> içinde de çalışır; eski bir sayfaya module yazılmış bir kodu eklemenin en kolay yolu budur.
Import Maps: Kısa İsimler
Bir yardımcı dosyayı on farklı klasörden import ettiğini düşün: kimi yerde "./utils.js", kimi yerde "../../lib/utils.js". Dosyayı taşıdığın gün hepsini düzeltmen gerekir. Import map, tarayıcıya "bu kısa ismi gördüğünde şu adrese git" diyen bir rehberdir:
<script type="importmap">
{
"imports": {
"utils": "./lib/utils.js",
"ui/": "./lib/ui/"
}
}
</script>
<script type="module">
import { debounce } from "utils";
import { showToast } from "ui/toast.js";
</script>
"utils" gibi bir anahtar tek bir dosyayı eşler. Sonu / ile biten "ui/" gibi bir anahtar ise bir önek tanımlar: "ui/toast.js", "./lib/ui/toast.js"'e dönüşür. Değer olarak bir CDN adresi de verebilirsin. Import map'i, onu kullanan module script'lerinden önce sayfaya koy; geç eklenen bir eşleme, tarayıcının çoktan çözdüğü import'ları değiştirmez. Import maps bugün bütün güncel tarayıcılarda geniş destekle çalışıyor.
JSON Module'leri ve Import Attributes
Ayar dosyası ya da sabit veri gibi JSON dosyalarını yıllarca fetch ile okuyup response.json() ile çözdük. ES2025 ile gelen import attributes sayesinde bir JSON dosyasını doğrudan module olarak içeri alabilirsin:
// config.json
{ "apiUrl": "https://api.example.com", "pageSize": 20 }
// main.js
import config from "./config.json" with { type: "json" };
console.log(config.pageSize); // 20
// Dinamik hâli
const { default: settings } = await import("./config.json", {
with: { type: "json" }
});
with { type: "json" } kısmı süs değil. Tarayıcı bir import'u varsayılan olarak JavaScript sayar ve çalıştırır. Sadece veri beklediğin bir dosyanın yerine sunucu bir gün çalıştırılabilir kod gönderirse, attribute olmadan o kod senin sayfanda çalışırdı. Attribute, tarayıcıya "bu dosya JSON olmalı, değilse reddet" der; sunucu dosyayı application/json dışında bir MIME type ile gönderirse import başarısız olur. Attribute'u unutursan da tarayıcı dosyayı JavaScript sanıp MIME type hatası verir.
İki ayrıntı. JSON module'lerinin yalnızca default export'u vardır; import { pageSize } from "./config.json" çalışmaz. Eski makalelerde assert { type: "json" } yazımını görürsen, o standart dışı kalmış önceki syntax'tır; bugün with kullanılıyor. Bu özellik 2025'ten beri Chrome, Edge, Firefox ve Safari'nin güncel sürümlerinde var.
Sık Düşülen Tuzaklar
Module'ü file:// ile açmak. Konsolda CORS hatası görürsen ilk şüphelin bu olsun; yerel bir sunucu kullan.
Uzantıyı ya da ./'ı unutmak. import "./math" ya da import "math.js" tarayıcıda çalışmaz. Tarayıcı dosya aramaz; ona tam yolu ya da import map'te tanımlı bir ismi verirsin.
HTML'de onclick="save()" yazıp module'deki fonksiyonu çağırmaya çalışmak. Module'ün fonksiyonları global değildir, inline handler onları göremez ve save is not defined hatası alırsın. Zaten daha önce inline handler'ı önermemiştik; addEventListener'ı module'ün içinde kullan.
Statik import'u koşula bağlamaya çalışmak. if içinde import satırı bir syntax hatasıdır. Koşullu yükleme gerekiyorsa import() kullan.
Küçük Bir Deneme
★☆☆ İlk örnekteki math.js'e circlePerimeter adında ikinci bir named export ekle ve main.js'te ikisini birden kullan. Sonra import * as math yazımıyla aynı işi yap.
★★☆ Çıktıyı tahmin et, sonra dene: bir module'ün en üstüne console.log("loaded") koy ve aynı module'ü iki farklı module'den import et. Konsolda kaç kez "loaded" görürsün? Ardından module'ün en üstüne console.log(this) ekle; ne yazar?
★★★ tr.json ve en.json adında iki çeviri dosyası hazırla. Sayfadaki bir dil seçici değiştiğinde doğru dosyayı import() ve with { type: "json" } ile yükleyip başlık ve butonların metnini güncelle. Aynı dile ikinci kez geçildiğinde dosyanın yeniden indirilmediğini DevTools'un Network sekmesinden doğrula.
Elinde artık kodu düzgün parçalara bölmenin bir yolu var. Bu parçalardan birini dates.js yapıp tarih yardımcıları yazmaya kalktığında ise şunu fark edeceksin: tarih ve saat, göründüğünden çok daha çetrefilli bir konu. Bir sonraki yazıda saat dilimleri, UTC, ISO 8601, Intl ile ileri biçimlendirme ve "3 gün önce" gibi göreli zamanlar var.