

1/1/1970
Import Maps ile Zincirleme Hash Değişikliklerini Nasıl Çözdüm
Selam! Bu sorunla 5+ yıldır uğraşıyorum, ama ancak şimdi üzerine gitmeye karar verdim; çünkü artık görmezden gelemeyeceğim bir noktaya geldi. Bir dosyada tek bir karakteri değiştirdiğimde, build içindeki JavaScript dosyalarının yarısı yeni hash'lenmiş dosya adları alıyordu, üstelik içerikleri aslında hiç değişmemiş oluyordu. Bu durum gereksiz cache geçersizleştirmelerine yol açıyor, build'ler arasındaki gerçek değişiklikleri takip etmeyi neredeyse imkânsız hale getiriyor ve en kötüsü de dosya sınırı yüzünden Cloudflare Pages build'lerimi bozuyordu.
Aşağıda sorunu, neden mevcut çözümlerin benim için işe yaramadığını ve Import Maps kullanarak bunu kökünden çözmek için nasıl özel bir Vite eklentisi yazdığımı adım adım anlatacağım.
Sorun: Zincirleme Hash Değişiklikleri
Vite, production build'ler için içerik tabanlı hash'leme kullanıyor. Uygulamanı build ettiğinde, her JavaScript dosyası içeriğine göre dosya adında bir hash alıyor. Eğer button.tsx, button-abc12345.js dosyasına derleniyorsa ve içerik değişirse, button-def45678.js haline geliyor. Bu, cache'i kırmak için harika bir şey; dosya değiştiğinde kullanıcılar yeni dosyayı alıyor.
Sorun, A Dosyası B Dosyası'nı import ettiğinde ortaya çıkıyor. Diyelim ki şöyle bir şeyin var:
// main.js
import { Button } from "./button-abc12345.js";
button.tsx değiştiğinde, Vite button-def45678.js üretir. Ama şimdi main.js de değişmiş olur, çünkü içinde artık yanlış olan "./button-abc12345.js" string'i duruyor. Dolayısıyla main.js de yeni bir hash alıyor, oysa main.js içindeki gerçek mantık hiç değişmemiş oluyor.
Bu etki, bütün bağımlılık grafiğine zincirleme şekilde yayılıyor. Tek bir yardımcı fonksiyonu değiştiriyorsun, bir bakıyorsun js dosyalarının yarısı yeni hash almış. Benim durumda, useBackgroundMusic.ts içinde tek bir karakter değiştirmem 500'den fazla dosyanın yeniden hash'lenmesine sebep oluyordu.
Gerçek hayattaki etkisi de oldukça büyüktü. Eski build varlıklarımızın (assets) 8 farklı sürümünü paketliyoruz ki, istemcimizin biraz eski bir versiyonunu kullanan kullanıcılar, biz yeni sürümü Cloudflare Pages'e deploy ettiğimizde kendi sürümlerini hâlâ çalıştırabilsin. Ama Cloudflare Pages'in 20.000 dosyalık bir sınırı var ve daha önce yaptığımız i18n değişikliği yüzünden üretmeye başladığımız dosya sayısı patlayınca bu sınıra çarpmaya başladık.
Zincirleme hash sorununu çözmek, bu limitlere takılmadan çok daha fazla eski build saklayabilmemizi sağlıyor; çünkü artık dosyaların çoğunun değişmesine gerek kalmıyor. Bu aynı zamanda eski bir build kullanan bir kullanıcının hata alma ihtimalini de düşürüyor; çünkü artık büyük ihtimalle bizde hâlâ bulunan ve değişmemiş bir dosyayı isteyecekler.
Neden [Alternatif Çözümler] Değil?
Bu işi çözmeye ilk baktığımda birkaç farklı yaklaşımı düşündüm. Ama hiçbiri tam olarak uymadı.
Build Sonrası Script'ler
İlk aklıma gelen, tüm import path'lerini normalize eden, dosyaları yeniden hash'leyen ve referansları güncelleyen bir build sonrası script yazmaktı. Kulağa basit geliyordu: hash'lenmiş dosya adlarını regex ile sabit adlarla değiştir, sonra hash'leri yeniden hesapla, bitti gitti.
Bu yaklaşımı, “Heisenbug” denilen hayalet bug'lar ve cache zehirlenmesi riskleri yüzünden eledim. Geçmiş build'leri Cloudflare Pages'de saklıyor olsak da, cache tutarsızlığı riski buna değmezdi. Build'den sonra dosyaları değiştiren bir script, sadece production'da ortaya çıkan, ince ve sinir bozucu bug'lar üretebilirdi ve bunları debug etmek tam bir kabus olurdu.
Vite manualChunks
Başka bir seçenek de, Vite'in manualChunks ayarını kullanarak sabit kalan kodu (örneğin node_modules) oynak koddan (iş mantığı) ayırmaktı. Fikir şuydu: vendor kodu daha seyrek değişir, böylece daha az dosya zincirleme etkilenir.
Bu aslında kök sorunu çözmüyor, sadece etkisini biraz hafifletiyor. İş mantığı chunk'ları içinde hâlâ zincirleme hash değişiklikleri yaşamaya devam ediyorsun. Ben, sorunun özüne dokunan bir çözüm istiyordum; sadece durumu biraz daha az kötü hale getiren bir yama değil.
Import Maps: Modern Çözüm
Import Maps, tarayıcıya yerleşik bir özellik (eski tarayıcılar için polyfill desteğiyle) ve modül belirteçlerini (module specifier) dosya path'lerinden ayırıyor. A Dosyası "./button-abc123.js" import etmek yerine "button"u import ediyor. Tarayıcı ise import map'i kullanarak "button"u gerçek hash'lenmiş dosya adına çözüyor.
Benim ihtiyacım olan şey tam olarak buydu. A Dosyası'nın içeriği aynı kalıyor (her zaman "button"u import ediyor), bu yüzden hash'i de aynı kalıyor. Sadece import map ve değişen dosya yeni hash alıyor. Bunun için iyi bir eklentinin hâlâ yazılmamış olmasına biraz şaşırdım açıkçası!
Uygulama Yolculuğu
Şöyle çalışan bir Vite eklentisi yazmaya karar verdim:
- Tüm göreli import'ları, sabit modül belirteçleri kullanacak şekilde dönüştürmek
- Bu belirteçleri gerçek hash'lenmiş dosya adlarına eşleyen bir import map üretmek
- Import map'i HTML'e enjekte etmek
Eklenti artık GitHub'da yayında: @foony/vite-plugin-import-map
İlk Yaklaşım
generateBundle hook'unu kullanan bir Vite eklentisiyle başladım. İlk denememde, import path'lerini bulup değiştirmek için regex kullandım. Kodlaması kolaydı ve bizim küçük ekibimizin Foony içindeki projesi için işe yaradı; ama kırılgandı ve yanlış pozitifleri değiştirip bozabileceği için, başkalarının da kullanacağı bir eklenti olarak kesinlikle güvenilir olmazdı.
Regex yaklaşımının bariz sorunları vardı: Ya kod içindeki sıradan bir string tesadüfen dosya adına benziyorsa? Dynamic import'lar ne olacak? Export ifadeleri ne olacak? Başkalarının da kullanacağı bir eklenti yapacaksam, çok daha sağlam bir çözüme ihtiyacım vardı.
AST Parse Etme
Tüm import ifadelerini bulmak için JavaScript kodunu doğru düzgün parse etmem gerekiyordu. İlk denemem, özellikle ES modüllerini parse etmek için tasarlanmış olan es-module-lexer oldu. Ne yazık ki, Vite'in modül analiz aşamasında native panic'lere sebep oldu. asm.js build'ini denemek bile bu panic'leri engellemeye yetmedi.
Sonunda hızlı, hafif ve tamamen JavaScript ile yazılmış bir parser olan Acornda karar kıldım. AST üzerinde gezinmek için acorn-walk ile birleştirdiğimde, native bağımlılık sorunları yaşamadan ihtiyacım olan her şeyi sağlamış oldu.
Çözdüğüm Başlıca Zorluklar
Tüm Import Türlerini Ele Almak
Import'lar birçok farklı biçimde gelebiliyor ve AST içinde hepsi farklı tipte düğümler olarak görünüyor. Şunların hepsini ele almam gerekiyordu:
- Statik import'lar:
import x from "./file.js" - Dinamik import'lar:
import("./file.js") - İsimli yeniden export'lar:
export { x } from "./file.js"(bunu başta gözden kaçırmışım!) - Tümünü yeniden export etme:
export * from "./file.js"
Re-export durumu özellikle zorluydu, çünkü dönüştürülmeyen bir dosya görene kadar bunu tamamen kaçırmıştım. Kodda export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" vardı ve eklentim bunu tamamen görmezden geliyordu; çünkü sadece ImportDeclaration ve ImportExpression düğümlerine bakıyordum.
Şu an hepsini şöyle ele alıyorum:
walk(ast, {
ImportDeclaration(node: any) {
// Statik import'lar: import x from "spec"
const specifier = node.source.value;
// ... transform logic
},
ExportNamedDeclaration(node: any) {
// Kaynakla birlikte isimli export'lar: export { x, y } from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ExportAllDeclaration(node: any) {
// Tümünü export et: export * from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ImportExpression(node: any) {
// Dinamik import'lar: import("spec")
// ... transform logic
},
});
Deterministik Çakışma Çözümü
Birden fazla dosya aynı temel ada sahipse (farklı klasörlerdeki bir sürü index.tsx gibi), bunları birbirinden ayırmam gerekiyor. Hepsi için "index" kullanamam.
Benim çözümüm şöyle: Bir çakışma varsa, orijinal kaynak path'ini ve temel adı birlikte hash'liyorum. Örneğin src/client/games/chess/index.tsx:index için index-abc123 gibi bir değer üretiliyor. Böylece, aynı dosya, aynı ada sahip başka dosyalar eklenip silinse bile tüm build'lerde daima aynı modül belirtecini alıyor.
Birincil tanımlayıcı olarak giriş noktası olan chunk.facadeModuleId'yi kullanıyorum; o yoksa chunk.moduleIds[0]'a geri düşüyorum. Bu da deterministik hash'leme için bana sabit bir kaynak path'i sağlıyor.
Source Map Zincirini Korumak
Kodu dönüştürdüğümde, source map zincirini kırmış oluyorum. Var olan source map, orijinal TypeScript kaynağından, Babel ve minification adımlarından geçerek şu anki koda kadar uzanıyor. Benim yaptığım dönüşümler bu zincire yeni bir katman daha ekliyor, dolayısıyla bu zinciri korumam gerekiyor.
MagicString kullanarak yaptığım dönüşümleri takip ediyor ve yeni bir source map üretiyorum. Sonra bunu, orijinal sources ve sourcesContent dizilerini koruyarak mevcut map ile birleştiriyorum. Böylece tam zincir korunmuş oluyor: Orijinal Kaynak → (mevcut map) → Dönüştürülmüş Kod.
const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
source: fileName,
file: newFileName,
includeContent: true,
hires: true,
});
// Birleştir: yeni map'in mapping'lerini kullan ama orijinal kaynakları koru
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Dönüştürülmüş İçeriği Yeniden Hash'lemek
Dosya içeriğinin sabit kalması gerekiyor. Bunun için önce import'ları dönüştürüyorum (Vite'in hash'lenmiş import'larını kendi sabit import'larımla değiştiriyorum), sonra da hash hesabına source map yorumlarını katmıyorum (çünkü onlar eski dosya adlarına referans veriyor).
Bundan sonra yeni bir hash hesaplıyor, hem dosya adını hem de import map içindeki ilgili girdiyi güncelliyorum.
Son Uygulama
Eklenti, dört geçişli bir strateji kullanıyor:
- Sayım geçişi: Hangi temel adı kaç dosyanın paylaştığını sayarak ad çakışmalarını tespit et
- Haritalama geçişi: Chunk eşlemesini oluştur (hash'lenmiş dosya adı → modül belirteci) ve ilk import map'i kur
- Dönüştürme geçişi: Kod içindeki import path'lerini yeniden yaz, hash'leri tekrar hesapla, source map'leri güncelle
- Yeniden adlandırma geçişi: Bundle dosya adlarını güncelle ve import map'i son haline getir
Çekirdek dönüşüm mantığı şöyle:
import {simple as walk} from 'acorn-walk';
// AST elde etmek için kodu parse et
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Tüm import/export'ları bulmak için AST üzerinde yürü
walk(ast, {
ImportDeclaration(node: any) {
const specifier = node.source.value;
const filename = specifier.split('/').pop()!;
const moduleSpec = chunkMapping.get(filename);
if (moduleSpec) {
importsToTransform.push({
start: node.source.start + 1, // Açılış tırnağını atlamak için +1
end: node.source.end - 1, // Kapanış tırnağını atlamak için -1
replacement: moduleSpec,
});
}
},
// ... diğer düğüm tiplerini de ele al
});
// Pozisyonları korumak için dönüşümleri tersten uygula
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Import map'i HTML'e enjekte etmek için, regex ile HTML kurcalamak yerine Vite'in etiket enjeksiyon API'sini kullanıyorum:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Bu, HTML etiketlerini regex ile yakalamaya çalışmaktan çok daha güvenilir.
Sayılarla
Bu eklentinin ne yaptığını biraz sayılarla anlatmak gerekirse:
- Build başına ~1.000+ JavaScript dosyası işleniyor
- Build süresine ~2-3 saniye ekleniyor (katlanılabilir bir bedel)
- Gereksiz hash değişikliklerinde ~%99 azalma (artık çoğu dosya sadece gerçek içeriği değiştiğinde değişiyor)
- ~340 satır eklenti kodu (yorumlar ve hata yönetimi dahil)
Eklenti şu ana kadar karşılaştığım tüm köşe durumlarını halledebiliyor ve build süreci artık çok daha öngörülebilir.
Çıkarımlarım
Neden AST parse etmek şart
Bundle'lanmış kod üzerinde regex kullanmak tehlikeli. Kodundaki bir string tesadüfen dosya adına benziyorsa, regex onu da değiştirebilir. AST parse etmek, sadece gerçekten import/export ifadesi olan yerleri dönüştürdüğünden emin olmanı sağlıyor.
Neden es-module-lexer yerine Acorn
es-module-lexer daha hızlı ve bu iş için özel üretilmiş, ama native panic sorunları onu benim Vite eklentisi bağlamımda kullanılmaz hale getirdi. Acorn tamamen JavaScript ile yazılmış, yani dert edeceğin native bağımlılıklar yok. İleride hız optimizasyonu için es-module-lexera tekrar bakmak isteyebilirim ama şimdilik Acorn gayet güzel iş görüyor.
Neden alternatifler yerine Import Maps
Import Maps, tarayıcıların yerel olarak desteklediği bir web standardı. Bu sorunu çözmenin “doğru” yolu aslında bu. Polyfill (es-module-shims), eski tarayıcıları (örneğin Safari < 16.4) gayet güzel idare ediyor ve ortaya çıkan çözüm hem temiz hem de bakımı kolay.
Sonuç
Import Maps eklentisi, Vite build'lerimdeki zincirleme hash değişikliklerini başarıyla engelliyor. Dosyalar artık sadece gerçek içerikleri değiştiğinde yeni hash alıyor, bağımlılıkları değiştiğinde değil. Bu da build'leri daha öngörülebilir kılıyor, gereksiz cache geçersizleştirmelerini azaltıyor ve Cloudflare Pages'in dosya sınırlarının altında kalmamıza yardımcı oluyor.
Çözüm basit, bakımı kolay ve modern web standartlarını kullanıyor. Bu, bazen “doğru” çözümün aynı zamanda en basit çözüm olduğuna dair güzel bir örnek; tabii sorunu yeterince derinlemesine anlayabildiğinde.
Eklenti açık kaynak ve GitHub'da mevcut: @foony/vite-plugin-import-map. npm install @foony/vite-plugin-import-map komutuyla kurup kendi Vite projelerinde kullanmaya başlayabilirsin.
İleride, native panic sorunları çözüldüğünde es-module-lexer ile optimize etmek ya da daha karmaşık import senaryolarına destek eklemek gibi geliştirmeler olabilir. Ama şu an için eklenti tam olarak ihtiyacım olan şeyi yapıyor.
Kim bilir? Belki bir gün Vite böyle bir şeyi doğrudan kendisi destekler.