

1/1/1970
Bagaimana Saya Menyelesaikan Perubahan Hash Bertingkat dengan Import Maps
Hai semua! Saya dah hadapi masalah ini selama 5 tahun lebih, tapi baru sekarang saya nekad nak selesaikannya kerana ia dah sampai ke tahap yang tak boleh diabaikan lagi. Bila saya tukar satu aksara dalam satu fail, separuh daripada fail JavaScript dalam build saya akan dapat nama fail berhash yang baharu, walaupun kandungan sebenar mereka tidak berubah. Ini menyebabkan pembatalan cache yang tidak perlu, hampir mustahil untuk menjejaki apa yang sebenarnya berubah antara build, dan yang paling teruk: ia merosakkan build Cloudflare Pages saya kerana had bilangan fail.
Di bawah ini, saya akan kupas masalah tersebut, kenapa penyelesaian sedia ada tidak sesuai untuk saya, dan bagaimana saya bina plugin Vite tersuai menggunakan Import Maps untuk menyelesaikannya sekali untuk selamanya.
Masalahnya: Perubahan Hash Bertingkat
Vite menggunakan hashing berdasarkan kandungan untuk build produksi. Bila anda build aplikasi anda, setiap fail JavaScript akan mendapat hash dalam nama failnya berdasarkan kandungannya. Jika button.tsx dikompilasi menjadi button-abc12345.js, dan kandungannya berubah, ia akan menjadi button-def45678.js. Ini bagus untuk cache busting: pengguna akan dapat fail baharu apabila ia berubah.
Masalahnya timbul bila Fail A mengimport Fail B. Katakan anda ada:
// main.js
import { Button } from "./button-abc12345.js";
Bila button.tsx berubah, Vite akan jana button-def45678.js. Tapi sekarang main.js pun berubah kerana ia mengandungi string "./button-abc12345.js", yang sekarang sudah salah. Jadi main.js pun dapat hash baharu, walaupun logik sebenar dalam main.js itu tidak berubah langsung.
Ini akan bertingkat melalui keseluruhan graf kebergantungan anda. Tukar satu fungsi utiliti, dan tiba-tiba separuh daripada fail js anda dapat hash baharu. Dalam kes saya, menukar satu aksara sahaja dalam useBackgroundMusic.ts menyebabkan lebih 500 fail di-hash semula.
Kesannya kepada dunia sebenar agak besar. Kami bundle 8 versi aset build lepas supaya pengguna yang menggunakan versi klien yang sedikit lapuk masih boleh jalankan versi mereka apabila kami deploy versi baharu ke Cloudflare Pages. Walau bagaimanapun, Cloudflare Pages mempunyai had 20,000 fail, dan kami mula mencecah had itu kerana perubahan i18n kami sebelum ini yang melonjakkan jumlah fail yang kami hasilkan.
Menyelesaikan masalah hash bertingkat membolehkan kami simpan lebih banyak build lepas tanpa mencecah had ini kerana kebanyakan fail tidak perlu berubah lagi. Ini juga mengurangkan kemungkinan pengguna pada build lapuk akan mengalami ralat, kerana lebih besar kemungkinan mereka akan minta fail yang kini tidak berubah dan kebetulan kami masih ada.
Kenapa Tidak [Penyelesaian Alternatif]?
Bila saya mula-mula fikir nak selesaikan masalah ini, saya pertimbangkan beberapa pendekatan. Tiada satu pun yang benar-benar sesuai.
Skrip Pasca-build
Idea pertama saya adalah menulis skrip pasca-build yang akan menormalkan semua laluan import, hash semula fail, dan kemas kini rujukan. Ini nampak mudah: cuma guna regex untuk gantikan nama fail berhash dengan nama yang stabil, kemudian kira semula hash.
Saya tolak pendekatan ini kerana risiko "Heisenbugs" dan keracunan cache. Walaupun kami simpan build lepas dalam Cloudflare Pages, risiko ketidakkonsistenan cache tidak berbaloi. Skrip yang ubah fail selepas build boleh memperkenalkan pepijat halus yang hanya muncul dalam produksi, dan menyahpepijat semua itu adalah mimpi ngeri.
manualChunks Vite
Pilihan lain adalah menggunakan konfigurasi manualChunks Vite untuk asingkan kod stabil (seperti node_modules) daripada kod tidak stabil (logik perniagaan). Ideanya, kod vendor akan berubah lebih jarang, jadi lebih sedikit fail akan bertingkat.
Ini sebenarnya tidak menyelesaikan masalah utama, ia cuma mengurangkannya. Anda masih akan dapat hash bertingkat dalam chunk logik perniagaan anda. Saya mahu penyelesaian yang menangani isu pokok, bukan sekadar menjadikannya kurang teruk sedikit.
Import Maps: Penyelesaian Moden
Import Maps adalah ciri asli pelayar (dengan sokongan polyfill untuk pelayar lama) yang memisahkan penentu modul daripada laluan fail. Sebaliknya daripada Fail A mengimport "./button-abc123.js", ia mengimport "button". Pelayar akan guna import map untuk menyelesaikan "button" kepada nama fail berhash yang sebenar.
Inilah yang saya perlukan. Kandungan Fail A kekal sama (ia sentiasa import "button"), jadi hashnya kekal sama. Hanya import map dan fail yang berubah sahaja yang dapat hash baharu. Saya agak terkejut tiada siapa yang dah buat plugin yang bagus untuk ini sebelum ini!
Membina Plugin Vite
Saya putuskan untuk bina plugin Vite yang akan:
- Mengubah semua import relatif untuk menggunakan penentu modul yang stabil
- Menjana import map yang memetakan penentu tersebut kepada nama fail berhash sebenar
- Menyuntik import map ke dalam HTML
Plugin ini kini tersedia di GitHub: @foony/vite-plugin-import-map
Pendekatan Awal
Saya bermula dengan plugin Vite menggunakan hook generateBundle. Cubaan pertama saya menggunakan regex untuk cari dan ganti laluan import. Ini mudah untuk dikodkan dan berfungsi untuk pasukan kecil kami di Foony, tetapi rapuh dan pasti tidak akan berfungsi dalam plugin di mana mungkin ada positif palsu yang akan diubah.
Pendekatan regex ada masalah yang jelas: bagaimana jika string dalam kod kebetulan kelihatan seperti nama fail? Bagaimana dengan import dinamik? Bagaimana dengan kenyataan eksport? Saya perlukan penyelesaian yang lebih kukuh jika saya nak bina plugin untuk orang lain.
Penghuraian AST
Saya perlu menghurai kod JavaScript dengan betul untuk cari semua kenyataan import. Cubaan pertama saya adalah es-module-lexer, yang direka khas untuk menghurai modul ES. Malangnya, ia menyebabkan panik asli semasa fasa analisis modul Vite. Walaupun cuba build asm.js pun tidak menghentikan panik tersebut.
Saya pilih Acorn, penghurai JavaScript tulen yang pantas dan ringan. Digabungkan dengan acorn-walk untuk pengembaraan AST, ia berikan saya semua yang saya perlukan tanpa isu kebergantungan asli.
Cabaran Utama yang Diselesaikan
Mengendalikan Semua Jenis Import
Import datang dalam pelbagai bentuk, dan ia dilayan secara berbeza dalam AST. Saya perlu kendalikan:
- Import statik:
import x from "./file.js" - Import dinamik:
import("./file.js") - Eksport semula bernama:
export { x } from "./file.js"(saya terlepas yang ini pada mulanya!) - Eksport semula semua:
export * from "./file.js"
Kes eksport semula sangat tricky kerana saya terlepas ia sehingga saya nampak fail yang tidak diubah. Kodnya ada export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" dan plugin saya langsung mengabaikannya kerana saya hanya cari nod ImportDeclaration dan ImportExpression.
Beginilah cara saya kendalikan semua 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
},
});
Penyelesaian Konflik Deterministik
Bila beberapa fail mempunyai nama asas yang sama (seperti beberapa fail index.tsx dalam direktori berbeza), saya perlu bezakan mereka. Saya tak boleh guna "index" sahaja untuk semuanya.
Penyelesaian saya: jika ada konflik, saya hash laluan sumber asal bersama nama asas. Contohnya, src/client/games/chess/index.tsx:index di-hash untuk hasilkan index-abc123. Ini memastikan fail yang sama sentiasa dapat penentu modul yang sama merentas build, walaupun fail lain dengan nama yang sama ditambah atau dibuang.
Saya gunakan chunk.facadeModuleId (titik masuk) sebagai pengenal utama, dengan chunk.moduleIds[0] sebagai sandaran jika tidak tersedia. Ini berikan saya laluan sumber yang stabil untuk hashing deterministik.
Rangkaian Source Map
Bila saya transformasi kod, saya memutuskan rangkaian source map. Source map sedia ada memetakan dari sumber TypeScript asal melalui Babel dan minifikasi ke kod semasa. Transformasi saya menambah satu lagi lapisan, jadi saya perlu mengekalkan rangkaian itu.
Saya guna MagicString untuk menjejaki transformasi saya dan menjana source map baharu. Kemudian saya gabungkan ia dengan map sedia ada dengan mengekalkan tatasusunan sources dan sourcesContent asal. Ini mengekalkan rangkaian penuh: Sumber Asal → (map sedia ada) → Kod Tertransformasi.
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,
};
Hash Semula Kandungan Tertransformasi
Saya perlukan kandungan fail yang stabil. Untuk ini, saya transformasi import (gantikan import berhash Vite dengan import stabil saya), kemudian saya buang komen source map daripada pengiraan hash (ia merujuk nama fail lama).
Selepas itu, saya kira hash baharu, dan kemas kini kedua-dua nama fail dan entri import map.
Pelaksanaan Akhir
Plugin ini menggunakan strategi empat lintasan:
- Lintasan kira: Kesan perlanggaran nama dengan mengira berapa banyak fail berkongsi setiap nama asas
- Lintasan map: Cipta pemetaan chunk (nama fail berhash → penentu modul) dan import map awal
- Lintasan transformasi: Tulis semula laluan import dalam kod, kira semula hash, kemas kini source map
- Lintasan namakan semula: Kemas kini nama fail bundle dan muktamadkan import map
Inilah logik transformasi terasnya:
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 menyuntik import map ke dalam HTML, saya guna API suntikan tag Vite dan bukannya manipulasi regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Ini jauh lebih boleh dipercayai daripada cuba padankan tag HTML dengan regex.
Mengikut Angka
Untuk berikan anda gambaran tentang apa plugin ini buat:
- ~1,000+ fail JavaScript diproses setiap build
- ~2-3 saat ditambah pada masa build (pertukaran yang boleh diterima)
- ~99% pengurangan dalam perubahan hash yang tidak perlu (kebanyakan fail kini hanya berubah apabila kandungan sebenar mereka berubah)
- ~340 baris kod plugin (termasuk komen dan pengendalian ralat)
Plugin ini mengendalikan semua kes tepi yang saya jumpa setakat ini, dan proses build kini jauh lebih boleh diramal.
Pengajaran yang Diperolehi
Kenapa penghuraian AST itu penting
Regex pada kod yang dibundle adalah berbahaya. Jika string dalam kod anda kebetulan kelihatan seperti nama fail, regex akan menulis semula. Penghuraian AST memastikan anda hanya transformasi kenyataan import/eksport sebenar.
Kenapa Acorn berbanding es-module-lexer
es-module-lexer lebih pantas dan dibina khas, tetapi isu panik asli menjadikannya tidak boleh digunakan dalam konteks plugin Vite saya. Acorn adalah JavaScript tulen, yang bermaksud tiada kebergantungan asli untuk dirisaukan. Saya nak tengok es-module-lexer di masa hadapan sebagai pengoptimuman kelajuan, tapi buat masa ini Acorn berfungsi dengan sempurna.
Kenapa Import Maps berbanding alternatif
Import Maps adalah standard web dengan sokongan asli pelayar. Ia adalah cara yang "betul" untuk menyelesaikan masalah ini. Polyfill (es-module-shims) mengendalikan pelayar lama (cth. Safari < 16.4) dengan baik, dan penyelesaiannya bersih dan boleh diselenggara.
Kesimpulan
Plugin Import Maps berjaya menghalang perubahan hash bertingkat dalam build Vite saya. Fail kini hanya dapat hash baharu apabila kandungan sebenar mereka berubah, bukan apabila kebergantungan mereka berubah. Ini menjadikan build lebih boleh diramal, mengurangkan pembatalan cache yang tidak perlu, dan membantu kami kekal di bawah had fail Cloudflare Pages.
Penyelesaian ini mudah, boleh diselenggara, dan menggunakan standard web moden. Ia contoh yang baik bagaimana kadangkala penyelesaian yang "betul" juga adalah yang paling mudah, sebaik sahaja anda memahami masalah cukup mendalam untuk melihatnya.
Plugin ini sumber terbuka dan tersedia di GitHub: @foony/vite-plugin-import-map. Anda boleh pasang dengan npm install @foony/vite-plugin-import-map dan mula gunakannya dalam projek Vite anda sendiri.
Penambahbaikan masa hadapan mungkin termasuk pengoptimuman dengan es-module-lexer setelah isu panik asli diselesaikan, atau menambah sokongan untuk senario import yang lebih kompleks. Tetapi buat masa ini, plugin ini melakukan tepat apa yang saya perlukan.
Dan siapa tahu? Mungkin satu hari nanti Vite akan menyokong sesuatu seperti ini secara asli.
(Kemas kini: Selepas mencuba plugin ini pada build Foony, sebahagian pengguna mengalami isu yang tidak dijangka, jadi saya lumpuhkannya buat masa ini. Saya akan kembali kepadanya kemudian. Mungkin. Saya masih rasa ini penyelesaian yang menarik.)