background blurbackground mobile blur

1/1/1970

Bagaimana Saya Mengatasi Cascading Hash Changes dengan Import Maps

Halo! Saya sudah punya masalah ini selama 5+ tahun, tapi baru sekarang memutuskan untuk mengatasinya karena sudah sampai pada titik di mana saya tidak bisa mengabaikannya lagi. Ketika saya mengubah satu karakter saja di satu file, separuh file JavaScript dalam build saya akan mendapatkan nama file dengan hash baru, padahal isinya tidak berubah sama sekali. Ini menyebabkan invalidasi cache yang tidak perlu, membuat hampir mustahil untuk melacak apa yang sebenarnya berubah antar build, dan yang paling parah: merusak build Cloudflare Pages saya karena batas jumlah file.

Di bawah ini saya akan menguraikan masalahnya, mengapa solusi yang ada tidak cocok untuk saya, dan bagaimana saya membangun plugin Vite kustom menggunakan Import Maps untuk menyelesaikannya sekali dan untuk selamanya.

Masalahnya: Cascading Hash Changes

Vite menggunakan hashing berbasis konten untuk build produksi. Ketika kamu mem-build aplikasimu, setiap file JavaScript mendapatkan hash di nama filenya berdasarkan kontennya. Jika button.tsx di-compile menjadi button-abc12345.js, dan kontennya berubah, ia menjadi button-def45678.js. Ini bagus untuk cache busting: pengguna mendapatkan file baru saat berubah.

Masalahnya muncul ketika File A meng-import File B. Misalnya kamu punya:

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

Ketika button.tsx berubah, Vite menghasilkan button-def45678.js. Tapi sekarang main.js juga berubah karena ia memuat string "./button-abc12345.js", yang sekarang sudah salah. Jadi main.js juga mendapatkan hash baru, padahal logika di main.js sebenarnya tidak berubah sama sekali.

Ini berkaskade melalui seluruh dependency graph kamu. Ubah satu utility function, dan tiba-tiba separuh file js kamu mendapatkan hash baru. Dalam kasus saya, mengubah satu karakter di useBackgroundMusic.ts menyebabkan lebih dari 500 file di-hash ulang.

Dampak nyatanya cukup signifikan. Kami membundle 8 versi aset build sebelumnya supaya pengguna dengan versi client yang sedikit kedaluwarsa masih bisa menjalankan versi mereka saat kami men-deploy versi baru ke Cloudflare Pages. Namun, Cloudflare Pages memiliki batas 20.000 file yang mulai kami capai karena perubahan i18n kami sebelumnya yang meledakkan jumlah file yang kami buat.

Mengatasi cascading hashes memungkinkan kami menyimpan jauh lebih banyak build sebelumnya tanpa mencapai batas ini, karena sekarang sebagian besar file tidak perlu berubah. Ini juga mengurangi kemungkinan pengguna pada build kedaluwarsa mengalami error, karena jauh lebih mungkin mereka akan meminta file yang sekarang tidak berubah dan kebetulan kami punya.

Mengapa Tidak [Solusi Alternatif]?

Saat pertama kali mempertimbangkan cara mengatasi ini, saya melihat beberapa pendekatan. Tidak ada yang benar-benar pas.

Skrip Pasca-build

Pemikiran awal saya adalah menulis skrip pasca-build yang akan menormalisasi semua import path, melakukan re-hash pada file, dan memperbarui referensinya. Kelihatannya sederhana: tinggal regex replace nama file ber-hash dengan nama stabil, lalu hitung ulang hash-nya.

Saya menolak pendekatan ini karena kekhawatiran "Heisenbugs" dan cache poisoning. Meskipun kami menyimpan build sebelumnya di Cloudflare Pages, risiko inkonsistensi cache tidak sebanding. Skrip yang memodifikasi file setelah build bisa memunculkan bug halus yang hanya muncul di produksi, dan men-debug-nya akan jadi mimpi buruk.

manualChunks Vite

Opsi lain adalah menggunakan konfigurasi manualChunks Vite untuk memisahkan kode stabil (seperti node_modules) dari kode tidak stabil (logika bisnis). Idenya adalah kode vendor akan jarang berubah, sehingga lebih sedikit file yang berkaskade.

Ini sebenarnya tidak menyelesaikan akar masalahnya: hanya menguranginya. Kamu masih akan mendapatkan cascading hashes di dalam chunk logika bisnismu. Saya menginginkan solusi yang menangani inti masalah, bukan sekadar membuatnya sedikit lebih baik.

Import Maps: Solusi Modern

Import Maps adalah fitur browser native (dengan dukungan polyfill untuk browser lama) yang memisahkan module specifier dari path file. Alih-alih File A meng-import "./button-abc123.js", ia meng-import "button". Browser menggunakan import map untuk meresolusi "button" ke nama file ber-hash yang sebenarnya.

Ini persis yang saya butuhkan. Konten File A tetap identik (ia selalu meng-import "button"), jadi hash-nya tetap sama. Hanya import map dan file yang berubah yang mendapatkan hash baru. Saya cukup kaget belum ada yang membuat plugin yang bagus untuk ini!

Membangun Plugin Vite

Saya memutuskan untuk membangun plugin Vite yang akan:

  1. Mengubah semua relative import agar menggunakan module specifier yang stabil
  2. Menghasilkan import map yang memetakan specifier tersebut ke nama file ber-hash yang sebenarnya
  3. Menyuntikkan import map ke dalam HTML

Plugin-nya sekarang tersedia di GitHub: @foony/vite-plugin-import-map

Pendekatan Awal

Saya mulai dengan plugin Vite menggunakan hook generateBundle. Percobaan pertama saya menggunakan regex untuk mencari dan mengganti import path. Ini mudah dikodekan dan berhasil untuk tim kecil kami di Foony, tapi rapuh dan jelas tidak akan berhasil di plugin yang berpotensi memiliki false-positive yang ikut termutasi.

Pendekatan regex memiliki masalah yang jelas: bagaimana jika string dalam kode kebetulan terlihat seperti nama file? Bagaimana dengan dynamic import? Bagaimana dengan export statement? Saya butuh solusi yang lebih kokoh kalau mau membangun plugin untuk orang lain.

AST Parsing

Saya perlu mem-parse kode JavaScript dengan benar untuk menemukan semua import statement. Percobaan pertama saya adalah es-module-lexer, yang memang dirancang khusus untuk mem-parse modul ES. Sayangnya, ia menyebabkan native panic selama fase analisis modul Vite. Mencoba build asm.js pun tidak membantu menghentikan panic-nya.

Akhirnya saya memilih Acorn, parser JavaScript murni yang cepat dan ringan. Dikombinasikan dengan acorn-walk untuk traversal AST, ia memberikan semua yang saya butuhkan tanpa masalah dependensi native.

Tantangan Utama yang Diselesaikan

Menangani Semua Jenis Import

Import datang dalam banyak bentuk, dan diperlakukan berbeda dalam AST. Saya perlu menangani:

  • Static import: import x from "./file.js"
  • Dynamic import: import("./file.js")
  • Named re-export: export { x } from "./file.js" (saya awalnya melewatkan yang ini!)
  • Re-export all: export * from "./file.js"

Kasus re-export sangat tricky karena saya melewatkannya sampai melihat ada file yang tidak ditransformasi. Kodenya berisi export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" dan plugin saya benar-benar mengabaikannya karena saya hanya mencari node ImportDeclaration dan ImportExpression.

Begini cara saya menangani semuanya sekarang:

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
  },
});

Resolusi Konflik Deterministik

Ketika beberapa file memiliki base name yang sama (seperti beberapa file index.tsx di direktori berbeda), saya perlu mendisambiguasi mereka. Saya tidak bisa hanya menggunakan "index" untuk semuanya.

Solusi saya: jika ada konflik, saya hash path source asli ditambah base name. Misalnya, src/client/games/chess/index.tsx:index di-hash untuk membuat index-abc123. Ini memastikan file yang sama selalu mendapatkan module specifier yang sama di seluruh build, bahkan jika file lain dengan nama yang sama ditambahkan atau dihapus.

Saya menggunakan chunk.facadeModuleId (entry point) sebagai identifier utama, dan beralih ke chunk.moduleIds[0] jika tidak tersedia. Ini memberikan saya path source yang stabil untuk hashing deterministik.

Source Map Chaining

Ketika saya mentransformasi kode, saya memutus rantai source map. Source map yang ada memetakan dari source TypeScript asli melalui Babel dan minifikasi ke kode saat ini. Transformasi saya menambah lapisan lain, jadi saya perlu mempertahankan rantai itu.

Saya menggunakan MagicString untuk melacak transformasi saya dan menghasilkan source map baru. Lalu saya menggabungkannya dengan map yang ada dengan mempertahankan array sources dan sourcesContent asli. Ini menjaga rantai lengkapnya: Source Asli → (map yang ada) → Kode yang Ditransformasi.

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,
};

Re-hashing Konten yang Ditransformasi

Saya butuh konten file yang stabil. Untuk melakukan ini, saya mentransformasi import (mengganti import ber-hash Vite dengan import stabil saya), lalu saya menghapus komentar source map dari perhitungan hash (mereka mereferensikan nama file lama).

Setelah itu, saya menghitung hash baru, dan memperbarui baik nama file maupun entri import map.

Implementasi Final

Plugin ini menggunakan strategi empat pass:

  1. Count pass: Mendeteksi konflik nama dengan menghitung berapa banyak file yang berbagi setiap base name
  2. Map pass: Membuat chunk mapping (nama file ber-hash → module specifier) dan import map awal
  3. Transform pass: Menulis ulang import path di kode, menghitung ulang hash, memperbarui source map
  4. Rename pass: Memperbarui nama file bundle dan memfinalisasi import map

Berikut logika transformasi intinya:

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);
}

Untuk menyuntikkan import map ke HTML, saya menggunakan API tag injection Vite alih-alih manipulasi regex:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

Ini jauh lebih andal daripada mencoba mencocokkan tag HTML dengan regex.

Dalam Angka

Untuk memberi gambaran apa yang dilakukan plugin ini:

  • ~1.000+ file JavaScript diproses per build
  • ~2-3 detik ditambahkan ke waktu build (trade-off yang dapat diterima)
  • ~99% pengurangan perubahan hash yang tidak perlu (sebagian besar file sekarang hanya berubah saat kontennya benar-benar berubah)
  • ~340 baris kode plugin (termasuk komentar dan error handling)

Plugin ini menangani semua edge case yang saya temui sejauh ini, dan proses build sekarang jauh lebih dapat diprediksi.

Pelajaran yang Didapat

Mengapa AST parsing itu penting

Regex pada kode yang sudah di-bundle itu berbahaya. Jika ada string dalam kode kamu yang kebetulan terlihat seperti nama file, regex akan menulis ulangnya. AST parsing memastikan kamu hanya mentransformasi statement import/export yang sebenarnya.

Mengapa Acorn dibanding es-module-lexer

es-module-lexer lebih cepat dan lebih dirancang khusus, tapi masalah native panic membuatnya tidak dapat digunakan dalam konteks plugin Vite saya. Acorn adalah JavaScript murni, yang berarti tidak ada dependensi native yang perlu dikhawatirkan. Saya ingin melihat es-module-lexer di masa depan sebagai optimasi kecepatan, tapi untuk saat ini Acorn berfungsi dengan sempurna.

Mengapa Import Maps dibanding alternatif

Import Maps adalah standar web dengan dukungan browser native. Mereka adalah cara yang "benar" untuk mengatasi masalah ini. Polyfill (es-module-shims) menangani browser lama (misalnya Safari < 16.4) dengan baik, dan solusinya bersih serta mudah dipelihara.

Kesimpulan

Plugin Import Maps berhasil mencegah cascading hash changes pada build Vite saya. File sekarang hanya mendapatkan hash baru saat kontennya benar-benar berubah, bukan saat dependensinya berubah. Ini membuat build lebih dapat diprediksi, mengurangi invalidasi cache yang tidak perlu, dan membantu kami tetap di bawah batas file Cloudflare Pages.

Solusinya sederhana, mudah dipelihara, dan menggunakan standar web modern. Ini contoh bagus bagaimana terkadang solusi yang "benar" juga yang paling sederhana, setelah kamu memahami masalahnya cukup mendalam untuk melihatnya.

Plugin ini open source dan tersedia di GitHub: @foony/vite-plugin-import-map. Kamu bisa memasangnya dengan npm install @foony/vite-plugin-import-map dan mulai menggunakannya di proyek Vite kamu sendiri.

Peningkatan di masa depan mungkin mencakup optimasi dengan es-module-lexer setelah masalah native panic teratasi, atau menambahkan dukungan untuk skenario import yang lebih kompleks. Tapi untuk saat ini, plugin ini melakukan persis apa yang saya butuhkan.

Dan siapa tahu? Mungkin suatu hari Vite akan mendukung sesuatu seperti ini secara native.

(Update: Setelah mencoba plugin ini pada build Foony, beberapa pengguna mengalami masalah tak terduga, jadi saya menonaktifkannya untuk sementara. Saya akan meninjaunya kembali nanti. Mungkin. Saya masih merasa ini solusi yang keren.)

8 Ball Pool online multiplayer billiards icon