background blurbackground mobile blur

1/1/1970

Cascading Hash Değişikliklerini Import Map'lerle Nasıl Çözdüm

Selamlar! Bu sorun 5+ yıldır başımdaydı, ama artık görmezden gelemeyeceğim bir noktaya geldiği için ancak şimdi çözmeye karar verdim. Bir dosyada tek bir karakteri değiştirdiğimde, build'imdeki JavaScript dosyalarının yarısı, gerçek içerikleri değişmemiş olmasına rağmen yeni hash'li dosya isimleri alıyordu. Bu durum gereksiz cache geçersiz kılmalarına yol açıyor, build'ler arasında neyin gerçekten değiştiğini takip etmeyi neredeyse imkansız hale getiriyor ve en kötüsü: bir dosya limiti yüzünden Cloudflare Pages build'lerimi bozuyordu.

Aşağıda sorunu, mevcut çözümlerin neden bana uymadığını ve Import Map'leri kullanarak bunu nihayetinde çözen özel bir Vite eklentisini nasıl geliştirdiğimi anlatacağım.

Sorun: Cascading Hash Değişiklikleri

Vite, production build'leri için içerik tabanlı hash kullanır. Uygulamanızı build ettiğinizde, her JavaScript dosyası içeriğine göre dosya adında bir hash alır. Eğer button.tsx, button-abc12345.js olarak derleniyorsa ve içerik değişirse, button-def45678.js olur. Bu cache busting için harika: dosya değiştiğinde kullanıcılar yeni dosyayı alır.

Sorun, A Dosyası B Dosyasını import ettiğinde başlar. Diyelim ki şöyle bir şey var:

// main.js
import { Button } from "./button-abc12345.js";

button.tsx değiştiğinde, Vite button-def45678.js üretir. Ama artık main.js de değişti çünkü içinde "./button-abc12345.js" stringi var ve bu artık yanlış. Yani main.js'in mantığı hiç değişmemiş olmasına rağmen main.js de yeni bir hash alır.

Bu durum, tüm bağımlılık grafiğiniz boyunca cascading şekilde yayılır. Bir yardımcı fonksiyonu değiştirin ve birdenbire JavaScript dosyalarınızın yarısı yeni hash alsın. Benim durumumda, useBackgroundMusic.ts dosyasında tek bir karakter değiştirmek 500'den fazla dosyanın yeniden hash'lenmesine neden oluyordu.

Gerçek dünyadaki etkisi de önemliydi. Kullanıcılarımız client'ın biraz eski sürümlerinde de çalışabilsin diye, yeni sürümü Cloudflare Pages'a deploy ettiğimizde geçmiş build'imizin asset'lerinin 8 sürümünü paketliyoruz. Ancak Cloudflare Pages'in 20.000 dosyalık bir limiti var ve daha önceki i18n değişikliğimizden sonra ürettiğimiz dosya sayısı patladığı için bu limite takılmaya başladık.

Cascading hash'leri çözmek, çok daha fazla geçmiş build'i bu limitlere takılmadan saklamamıza olanak tanıyor çünkü artık dosyaların çoğunun değişmesine gerek kalmıyor. Bu aynı zamanda eski bir build'deki bir kullanıcının hata alma olasılığını da azaltıyor, çünkü artık değişmemiş ve elimizde mevcut bir dosyayı talep etme ihtimalleri çok daha yüksek.

Neden [Alternatif Çözümler] Değil?

Bunu çözmeye ilk baktığımda birkaç yaklaşımı değerlendirdim. Hiçbiri tam oturmadı.

Build Sonrası Script'ler

İlk düşüncem, tüm import yollarını normalleştiren, dosyaları yeniden hash'leyen ve referansları güncelleyen bir build sonrası script yazmaktı. Basit görünüyordu: regex ile hash'li dosya adlarını sabit isimlerle değiştir, sonra hash'leri yeniden hesapla.

Bu yaklaşımı "Heisenbug" ve cache zehirlenmesi endişeleri yüzünden reddettim. Geçmiş build'leri Cloudflare Pages'da saklasak da, cache tutarsızlıkları riski buna değmezdi. Build sonrasında dosyaları değiştiren bir script, yalnızca production'da ortaya çıkan ince hatalar getirebilirdi ve bunları debug etmek bir kabus olurdu.

Vite manualChunks

Diğer bir seçenek, sabit kodu (node_modules gibi) sabit olmayan koddan (iş mantığı) ayırmak için Vite'in manualChunks yapılandırmasını kullanmaktı. Fikir, vendor kodunun daha az sıklıkla değişeceği ve dolayısıyla daha az dosyanın cascade olacağıydı.

Bu aslında temel sorunu çözmüyor, sadece hafifletiyor. İş mantığı chunk'larınızın içinde hâlâ cascading hash'ler alıyorsunuz. Ben yalnızca biraz daha az kötü hale getiren değil, asıl sorunu ele alan bir çözüm istiyordum.

Import Map'ler: Modern Çözüm

Import Map'ler, modül tanımlayıcılarını dosya yollarından ayıran, tarayıcıda yerel olarak desteklenen (eski tarayıcılar için polyfill desteğiyle) bir özelliktir. A Dosyası "./button-abc123.js" import etmek yerine, sadece "button" import eder. Tarayıcı, "button"'ı gerçek hash'li dosya adına çözmek için import map'i kullanır.

Tam ihtiyacım olan şey buydu. A Dosyasının içeriği aynı kalıyor (her zaman "button" import ediyor), yani hash'i de aynı kalıyor. Sadece import map ve değişen dosya yeni hash alıyor. Kimsenin bunun için iyi bir eklenti yapmamış olmasına biraz şaşırdım açıkçası!

Vite Eklentisini Geliştirmek

Şunları yapacak bir Vite eklentisi geliştirmeye karar verdim:

  1. Tüm göreceli import'ları sabit modül tanımlayıcılarını kullanacak şekilde dönüştürmek
  2. Bu tanımlayıcıları gerçek hash'li dosya adlarına eşleyen bir import map oluşturmak
  3. Import map'i HTML'e enjekte etmek

Eklenti artık GitHub'da mevcut: @foony/vite-plugin-import-map

İlk Yaklaşım

generateBundle hook'unu kullanan bir Vite eklentisiyle başladım. İlk denemem, import yollarını bulup değiştirmek için regex kullanıyordu. Kodlaması kolaydı ve Foony'deki küçük ekibimiz için işe yaradı, ama kırılgandı ve mutasyona uğrayan false-positive'lerin olabileceği bir eklentide kesinlikle çalışmazdı.

Regex yaklaşımının bariz sorunları vardı: ya koddaki bir string tesadüfen bir dosya adına benzerse? Ya dynamic import'lar? Ya export ifadeleri? Başkaları için bir eklenti geliştireceksem daha sağlam bir çözüme ihtiyacım vardı.

AST Parse Etmek

Tüm import ifadelerini bulmak için JavaScript kodunu düzgünce parse etmem gerekiyordu. İlk denemem, ES modüllerini parse etmek için özel olarak tasarlanmış es-module-lexer idi. Maalesef Vite'in modül analizi aşamasında native panic'lere neden oldu. asm.js build'ini denemek bile panic'leri durdurmaya yardımcı olmadı.

Hızlı, hafif ve saf JavaScript bir parser olan Acorn'a karar verdim. AST traversal için acorn-walk ile birleştirildiğinde, native bağımlılık sorunları olmadan ihtiyacım olan her şeyi sağladı.

Çözülen Temel Zorluklar

Tüm Import Türlerini İşlemek

Import'lar birçok şekilde gelir ve AST'de farklı şekilde ele alınırlar. Şunları işlemem gerekiyordu:

  • Static import'lar: import x from "./file.js"
  • Dynamic import'lar: import("./file.js")
  • Named re-export'lar: export { x } from "./file.js" (Bunu başlangıçta kaçırmıştım!)
  • Hepsini re-export: export * from "./file.js"

Re-export durumu özellikle zorluydu çünkü dönüştürülmeyen bir dosya görene kadar onu 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 node'larına bakıyordum.

İşte şu an hepsini nasıl işlediğim:

walk(ast, {
  ImportDeclaration(node: any) {
    // Static imports: import x from "spec"
    const specifier = node.source.value;
    // ... transform logic
  },
  ExportNamedDeclaration(node: any) {
    // Named exports with source: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ExportAllDeclaration(node: any) {
    // Export all: export * from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ImportExpression(node: any) {
    // Dynamic imports: import("spec")
    // ... transform logic
  },
});

Deterministik Çakışma Çözümü

Birden fazla dosya aynı temel ada sahip olduğunda (farklı dizinlerdeki birden fazla index.tsx gibi), bunları ayırt etmem gerekiyor. Hepsi için "index" kullanamam.

Çözümüm: bir çakışma varsa, orijinal kaynak yolunu artı temel adı hash'liyorum. Örneğin, src/client/games/chess/index.tsx:index, index-abc123 oluşturmak için hash'leniyor. Bu, aynı isme sahip başka dosyalar eklense veya kaldırılsa bile, aynı dosyanın build'ler arasında her zaman aynı modül tanımlayıcısını almasını sağlıyor.

Birincil tanımlayıcı olarak chunk.facadeModuleId (giriş noktası) kullanıyorum, eğer mevcut değilse chunk.moduleIds[0]'a düşüyorum. Bu bana deterministik hash'leme için sabit bir kaynak yolu veriyor.

Source Map Zincirleme

Kodu dönüştürdüğümde, source map zincirini kırıyorum. Mevcut source map, orijinal TypeScript kaynağından Babel ve minification yoluyla mevcut koda eşleme yapıyor. Dönüşümlerim başka bir katman ekliyor, dolayısıyla bu zinciri korumam gerekiyor.

Dönüşümlerimi takip etmek ve yeni bir source map oluşturmak için MagicString kullanıyorum. Sonra orijinal sources ve sourcesContent dizilerini koruyarak mevcut map ile birleştiriyorum. Bu, tam zinciri korur: 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,
});

// Merge: use new map's mappings but preserve original sources
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

Sabit dosya içeriğine ihtiyacım var. Bunu yapmak için import'ları dönüştürüyorum (Vite'in hash'li import'larını kendi sabit import'larımla değiştirerek) ve sonra hash hesaplamasından source map yorumlarını çıkarıyorum (eski dosya adlarına referans veriyorlar).

Ondan sonra yeni bir hash hesaplıyorum ve hem dosya adını hem de import map girdisini güncelliyorum.

Nihai Uygulama

Eklenti dört aşamalı bir strateji kullanıyor:

  1. Sayma aşaması: Her temel adı kaç dosyanın paylaştığını sayarak isim çakışmalarını tespit et
  2. Eşleme aşaması: Chunk eşlemesini (hash'li dosya adı → modül tanımlayıcısı) ve ilk import map'i oluştur
  3. Dönüşüm aşaması: Koddaki import yollarını yeniden yaz, hash'leri yeniden hesapla, source map'leri güncelle
  4. Yeniden adlandırma aşaması: Bundle dosya adlarını güncelle ve import map'i sonlandır

İşte temel dönüşüm mantığı:

import {simple as walk} from 'acorn-walk';

// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// Traverse the AST to find all imports/exports
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, // +1 to skip opening quote
        end: node.source.end - 1,     // -1 to skip closing quote
        replacement: moduleSpec,
      });
    }
  },
  // ... handle other node types
});

// Apply transformations in reverse order to preserve positions
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 manipülasyonu yerine Vite'in tag 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 eşleştirmeye çalışmaktan çok daha güvenilir.

Rakamlarla

Bu eklentinin neler yaptığına dair bir fikir vermek için:

  • Build başına işlenen ~1.000+ JavaScript dosyası
  • Build süresine eklenen ~2-3 saniye (kabul edilebilir bir takas)
  • Gereksiz hash değişikliklerinde ~%99 azalma (artık dosyaların çoğu yalnızca gerçek içerikleri değiştiğinde değişiyor)
  • ~340 satır eklenti kodu (yorumlar ve hata işleme dahil)

Eklenti, şu ana kadar karşılaştığım tüm uç durumları işliyor ve build süreci artık çok daha öngörülebilir.

Çıkarılan Dersler

AST parse etmek neden önemli

Bundle edilmiş kod üzerinde regex tehlikeli. Kodunuzdaki bir string tesadüfen bir dosya adına benziyorsa, regex onu yeniden yazar. AST parse, yalnızca gerçek import/export ifadelerini dönüştürmenizi sağlar.

Neden es-module-lexer yerine Acorn

es-module-lexer daha hızlı ve daha amaca yönelik, ama native panic sorunları onu Vite eklenti bağlamımda kullanılamaz hale getirdi. Acorn saf JavaScript, yani endişelenecek native bağımlılık yok. Gelecekte hız optimizasyonu olarak es-module-lexer'a tekrar bakmak isteyeceğim, ama şimdilik Acorn mükemmel çalışıyor.

Import Map'ler neden alternatiflerden daha iyi

Import Map'ler, native tarayıcı desteğine sahip bir web standardı. Bu sorunu çözmenin "doğru" yolu. Polyfill (es-module-shims) eski tarayıcıları (örn. Safari < 16.4) sorunsuz şekilde işliyor ve çözüm temiz ve sürdürülebilir.

Sonuç

Import Map eklentisi, Vite build'lerimde cascading hash değişikliklerini başarıyla önlüyor. Dosyalar artık yalnızca gerçek içerikleri değiştiğinde yeni hash alıyor, bağımlılıkları değiştiğinde değil. Bu, build'leri daha öngörülebilir hale getiriyor, gereksiz cache geçersiz kılmalarını azaltıyor ve Cloudflare Pages'in dosya limitlerinin altında kalmamıza yardımcı oluyor.

Çözüm basit, sürdürülebilir ve modern web standartlarını kullanıyor. Bazen "doğru" çözümün aynı zamanda en basit çözüm olduğuna dair iyi bir örnek: yeter ki sorunu görebilecek kadar derinden anlayın.

Eklenti açık kaynak ve GitHub'da mevcut: @foony/vite-plugin-import-map. npm install @foony/vite-plugin-import-map ile kurabilir ve kendi Vite projelerinizde kullanmaya başlayabilirsiniz.

Gelecekteki iyileştirmeler arasında, native panic sorunları çözüldüğünde es-module-lexer ile optimizasyon veya daha karmaşık import senaryoları için destek eklemek olabilir. Ama şimdilik eklenti tam olarak ihtiyacım olanı yapıyor.

Ve kim bilir? Belki bir gün Vite, böyle bir şeyi natively destekler.

(Güncelleme: Eklentiyi Foony'nin build'inde denedikten sonra bazı kullanıcılar beklenmedik sorunlar yaşıyordu, bu yüzden şimdilik devre dışı bıraktım. İleride tekrar bakacağım. Belki. Yine de bunun şık bir çözüm olduğunu düşünüyorum.)

8 Ball Pool online multiplayer billiards icon