

1/1/1970
Macam Mana Saya Selesaikan Masalah Perubahan Hash Berantai dengan Import Maps
Hai! Masalah ni dah menghantui saya lebih 5 tahun, tapi baru sekarang saya betul-betul nekad nak hadap sebab ia dah sampai tahap saya tak boleh buat-buat tak nampak lagi. Setiap kali saya ubah satu aksara saja dalam satu fail, separuh fail JavaScript dalam build saya akan dapat nama fail baharu dengan hash lain, walaupun kandungan sebenar fail tu langsung tak berubah. Ini buat cache saya terbatal tanpa sebab, hampir mustahil nak jejak apa sebenarnya berubah antara satu build dengan build seterusnya, dan yang paling teruk: build Cloudflare Pages saya asyik rosak sebab kena had limit bilangan fail.
Di bawah ni saya pecahkan masalah ni satu per satu, kenapa solusi sedia ada tak menjadi untuk saya, dan macam mana saya bina plugin Vite custom guna Import Maps untuk selesaikan benda ni terus.
Masalahnya: Perubahan Hash Berantai
Vite guna hashing berasaskan kandungan untuk production build. Bila anda build app, setiap fail JavaScript akan dapat hash dalam nama fail ikut kandungan dia. Kalau button.tsx di-compile jadi button-abc12345.js, dan kandungannya berubah, ia akan jadi button-def45678.js. Ini memang bagus untuk "cache busting", pengguna akan dapat fail baharu bila ia berubah.
Masalah mula timbul bila Fail A import Fail B. Katakan anda ada:
// main.js
import { Button } from "./button-abc12345.js";
Bila button.tsx berubah, Vite akan hasilkan button-def45678.js. Tapi sekarang main.js pun turut berubah sebab dalamnya ada string "./button-abc12345.js" yang dah tak betul lagi. Jadi main.js pun dapat hash baharu, walaupun logik sebenar dalam main.js langsung tak berubah.
Perkara ni berantai merentasi seluruh graf kebergantungan anda. Ubah satu fungsi utiliti saja, tiba-tiba separuh fail js anda dapat hash baharu. Dalam kes saya, tukar satu aksara saja dalam useBackgroundMusic.ts menyebabkan lebih 500 fail di-hash semula.
Kesan dunia sebenar agak besar. Kami bungkus sekali 8 versi aset build lama supaya pengguna yang masih guna versi klien yang sedikit ketinggalan masa kami deploy versi baharu ke Cloudflare Pages masih boleh jalankan versi mereka. Tapi, Cloudflare Pages ada had 20,000 fail dan kami mula menghampiri had tu disebabkan perubahan i18n kami sebelum ni yang melambung-gandakan jumlah fail yang kami hasilkan.
Bila masalah hash berantai ni selesai, kami boleh simpan jauh lebih banyak build lama tanpa langgar had tu, sebab sekarang kebanyakan fail dah tak perlu berubah lagi. Ini juga kurangkan kebarangkalian pengguna yang masih guna build lama akan jumpa ralat, sebab lebih besar peluang mereka meminta fail yang kini tak berubah dan memang masih ada dalam simpanan kami.
Kenapa Bukan [Solusi Alternatif]?
Masa saya mula-mula nak selesaikan benda ni, saya pertimbangkan beberapa pendekatan berbeza. Tapi tak ada satu pun yang betul-betul kena.
Skrip Selepas Build
Mula-mula saya terfikir nak tulis satu skrip selepas build yang akan normalkan semua laluan import, hash semula fail, dan kemas kini semua rujukan. Nampak macam mudah, cuma guna regex untuk ganti nama fail yang ada hash kepada nama yang stabil, lepas tu kira semula hash.
Saya tolak pendekatan ni sebab risau pasal "Heisenbugs" dan risiko cache rosak. Walaupun kami simpan build lama di Cloudflare Pages, risiko cache yang tak sekata langsung tak berbaloi. Skrip yang ubah fail selepas build boleh perkenalkan bug halus yang cuma muncul di produksi, dan nak debug benda macam tu memang mimpi ngeri.
Vite manualChunks
Pilihan lain ialah guna konfigurasi manualChunks Vite untuk asingkan kod yang stabil (contohnya node_modules) daripada kod yang selalu berubah (business logic). Idea dia, kod vendor biasanya jarang berubah, jadi kurang fail yang akan terkesan secara berantai.
Tapi ni sebenarnya tak selesaikan punca masalah, cuma mengurangkan kesan je. Anda masih akan dapat hash berantai dalam chunk business logic. Saya nak satu solusi yang betul-betul selesaikan masalah teras, bukan sekadar buat keadaan jadi sikit kurang teruk.
Import Maps: Solusi Moden
Import Maps ialah ciri asli pelayar (dengan sokongan polyfill untuk pelayar lebih lama) yang memisahkan nama modul daripada laluan fail. Bukannya Fail A import "./button-abc123.js", ia import "button". Pelayar akan guna import map untuk tukar "button" kepada nama fail sebenar yang ber-hash.
Inilah tepat apa yang saya perlukan. Kandungan Fail A kekal sama (ia sentiasa import "button"), jadi hash dia pun kekal sama. Hanya import map dan fail yang berubah saja akan dapat hash baharu. Saya agak terkejut juga tak ada lagi plugin yang betul-betul bagus untuk benda ni!
Perjalanan Implementasi
Jadi saya putuskan untuk bina satu plugin Vite yang akan:
- Menukar semua import relatif supaya guna nama modul yang stabil
- Menjana satu import map yang memetakan nama modul tu kepada nama fail sebenar yang ber-hash
- Menyuntik import map tu ke dalam HTML
Plugin ni sekarang ada di GitHub: @foony/vite-plugin-import-map
Pendekatan Awal
Saya mulakan dengan plugin Vite yang guna hook generateBundle. Cubaan pertama saya guna regex untuk cari dan ganti laluan import. Dari segi kod memang senang dan berfungsi untuk pasukan kecil kami di Foony, tapi sangat rapuh dan memang tak sesuai untuk plugin umum di mana mungkin ada "false-positive" yang tiba-tiba diubah.
Pendekatan regex ni ada masalah jelas: macam mana kalau ada string dalam kod yang kebetulan nampak macam nama fail? Macam mana dengan dynamic import? Macam mana dengan kenyataan export? Saya perlukan solusi yang lebih kukuh kalau betul-betul nak bina plugin untuk orang lain guna.
Pengehuraian AST
Saya perlu huraikan kod JavaScript dengan betul untuk cari semua kenyataan import. Cubaan pertama saya ialah es-module-lexer, yang memang dibina khas untuk huraikan modul ES. Malangnya, ia menyebabkan "native panic" masa fasa analisis modul Vite. Cuba guna binaan asm.js pun tak membantu hentikan panic tu.
Akhirnya saya pilih Acorn, satu parser JavaScript tulen yang laju dan ringan. Digabungkan dengan acorn-walk untuk jelajah AST, ia bagi semua yang saya perlukan tanpa sebarang isu kebergantungan native.
Cabaran Utama yang Berjaya Diselesaikan
Mengendalikan Semua Jenis Import
Import datang dalam pelbagai bentuk, dan masing-masing diwakili berbeza dalam AST. Saya perlu urus:
- Import statik:
import x from "./file.js" - Import dinamik:
import("./file.js") - Named re-export:
export { x } from "./file.js"(yang ni saya terlepas pada mulanya!) - Re-export semua:
export * from "./file.js"
Kes re-export ni agak rumit sebab saya langsung tak perasan hinggalah saya nampak satu fail yang tak berubah langsung. Dalam kod tu ada export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" dan plugin saya langsung tak sentuh kenyataan tu sebab saya cuma cari node ImportDeclaration dan ImportExpression sahaja.
Macam ni lah cara saya urus semuanya sekarang:
walk(ast, {
ImportDeclaration(node: any) {
// Import statik: import x dari "spec"
const specifier = node.source.value;
// ... logik transformasi
},
ExportNamedDeclaration(node: any) {
// Named export dengan sumber: export { x, y } dari "spec"
if (!node.source?.value) return;
// ... logik transformasi
},
ExportAllDeclaration(node: any) {
// Export semua: export * dari "spec"
if (!node.source?.value) return;
// ... logik transformasi
},
ImportExpression(node: any) {
// Import dinamik: import("spec")
// ... logik transformasi
},
});
Penyelesaian Konflik yang Deterministik
Bila ada banyak fail yang berkongsi nama asas yang sama (contohnya beberapa index.tsx dalam folder berbeza), saya kena bezakan mereka. Saya tak boleh semata-mata guna "index" untuk semuanya.
Solusi saya: kalau ada konflik, saya hash sekali laluan asal sumber bersama nama asas. Contohnya, src/client/games/chess/index.tsx:index akan di-hash untuk hasilkan index-abc123. Ini pastikan fail yang sama sentiasa dapat nama modul yang sama merentasi semua build, walaupun ada fail lain dengan nama sama ditambah atau dibuang.
Saya guna chunk.facadeModuleId (entry point) sebagai pengecam utama, dan kalau itu tak ada, saya jatuh balik kepada chunk.moduleIds[0]. Ini beri saya laluan sumber yang stabil untuk hashing yang deterministik.
Rantaian Source Map
Bila saya transformasi kod, sebenarnya saya memutuskan rantaian source map. Source map sedia ada memetakan sumber TypeScript asal, melalui Babel dan proses minify, sampai ke kod semasa. Transformasi saya tambah satu lapisan lagi, jadi saya kena kekalkan rantaian tu.
Saya guna MagicString untuk jejak semua transformasi dan jana source map baharu. Lepas tu saya gabungkan dengan source map sedia ada dengan mengekalkan array sources dan sourcesContent asal. Ini kekalkan keseluruhan rantaian: Sumber Asal → (map sedia ada) → Kod yang Telah Ditukar.
const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
source: fileName,
file: newFileName,
includeContent: true,
hires: true,
});
// Gabung: guna pemetaan map baharu tapi kekalkan sumber asal
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Hash Semula Kandungan yang Telah Ditukar
Saya perlukan kandungan fail yang stabil. Untuk capai ni, saya transformasi semua import (ganti import ber-hash Vite dengan import stabil saya), dan kemudian saya buang komen source map daripada pengiraan hash (sebab ia merujuk nama fail lama).
Lepas tu, saya kira hash baharu dan kemas kini kedua-dua nama fail dan entri dalam import map.
Implementasi Akhir
Plugin ni guna strategi empat pusingan:
- Pusingan kiraan: Kesan pertembungan nama dengan mengira berapa banyak fail berkongsi setiap nama asas
- Pusingan pemetaan: Cipta pemetaan chunk (nama fail ber-hash → nama modul) dan import map awal
- Pusingan transformasi: Tulis semula laluan import dalam kod, kira semula hash, kemas kini source map
- Pusingan penamaan semula: Kemas kini nama fail bundle dan muktamadkan import map
Inilah logik transformasi terasnya:
import {simple as walk} from 'acorn-walk';
// Huraikan kod untuk dapatkan AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Jelajah AST untuk cari semua import/export
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 untuk langkau tanda petik buka
end: node.source.end - 1, // -1 untuk langkau tanda petik tutup
replacement: moduleSpec,
});
}
},
// ... urus jenis node lain
});
// Laksana transformasi secara terbalik untuk kekalkan posisi
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Untuk menyuntik import map ke dalam HTML, saya guna API suntikan tag Vite dan bukannya ubah suai dengan regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Cara ni jauh lebih boleh dipercayai berbanding cuba padankan tag HTML dengan regex.
Ikut Nombor
Sekadar nak beri gambaran apa yang plugin ni buat:
- ~1,000+ fail JavaScript diproses setiap build
- ~2-3 saat tambahan pada masa build (pertukaran yang masih okay)
- ~99% pengurangan pada perubahan hash yang tak perlu (kebanyakan fail sekarang cuma berubah bila kandungan sebenar mereka berubah)
- ~340 baris kod plugin (termasuk komen dan pengendalian ralat)
Plugin ni setakat ni boleh tangani semua kes tepi yang saya jumpa, dan proses build sekarang jauh lebih mudah dijangka.
Pengajaran yang Saya Dapat
Kenapa pengehuraian AST itu penting
Guna regex atas kod yang sudah dibundel memang bahaya. Kalau ada string dalam kod yang kebetulan nampak macam nama fail, regex akan ubah ia sekali. Pengehuraian AST pastikan anda hanya transform kenyataan import/export yang sebenar.
Kenapa Acorn dan bukannya es-module-lexer
es-module-lexer memang lebih laju dan lebih khusus untuk tugasan ni, tapi isu native panic menjadikannya tak boleh guna dalam konteks plugin Vite saya. Acorn pula 100% JavaScript, jadi tak ada kebergantungan native yang perlu dirisaukan. Saya mungkin akan tengok semula es-module-lexer pada masa depan sebagai pengoptimuman kelajuan, tapi buat masa ni Acorn berfungsi dengan sangat baik.
Kenapa Import Maps dan bukannya alternatif lain
Import Maps ialah standard web dengan sokongan asli dalam pelayar. Ini memang cara yang "betul" untuk selesaikan masalah ni. Polyfill (es-module-shims) urus pelayar lama (contohnya Safari < 16.4) dengan baik, dan solusi keseluruhan kekal bersih serta mudah diselenggara.
Kesimpulan
Plugin Import Maps ni berjaya hentikan perubahan hash berantai dalam build Vite saya. Fail sekarang hanya dapat hash baharu bila kandungan sebenar mereka berubah, bukannya bila kebergantungan mereka berubah. Ini buat build lebih mudah dijangka, kurangkan pembatalan cache yang tak perlu, dan bantu kami kekal di bawah had bilangan fail Cloudflare Pages.
Solusi ni ringkas, mudah diselenggara, dan guna standard web moden. Ia contoh yang bagus bagaimana kadang-kadang solusi yang "betul" juga adalah yang paling ringkas, bila anda betul-betul faham masalah tu sampai ke akar.
Plugin ni adalah sumber terbuka dan tersedia di GitHub: @foony/vite-plugin-import-map. Anda boleh pasang dengan npm install @foony/vite-plugin-import-map dan terus guna dalam projek Vite anda sendiri.
Penambahbaikan masa depan mungkin termasuk mengoptimumkan dengan es-module-lexer bila isu native panic selesai, atau tambah sokongan untuk senario import yang lebih kompleks. Tapi buat masa sekarang, plugin ni dah buat tepat apa yang saya perlukan.
Dan siapa tahu, mungkin satu hari nanti Vite sendiri akan sokong ciri macam ni secara asli.