1/7/2026
ผมแก้ปัญหา Cascading Hash Changes ด้วย Import Maps ได้อย่างไร
สวัสดีครับ! ปัญหานี้ผมเจอมา 5 ปีกว่าแล้ว แต่เพิ่งจะลงมือแก้จริงจังตอนนี้เพราะมันมาถึงจุดที่ผมเพิกเฉยต่อไปไม่ได้แล้ว เวลาที่ผมเปลี่ยนตัวอักษรเพียงตัวเดียวในไฟล์หนึ่ง ครึ่งหนึ่งของไฟล์ JavaScript ในบิวด์ของผมจะได้ชื่อไฟล์ที่มี hash ใหม่ ทั้งที่เนื้อหาของมันไม่ได้เปลี่ยนเลย เรื่องนี้ทำให้แคชถูก invalidate โดยไม่จำเป็น แทบจะเป็นไปไม่ได้เลยที่จะติดตามว่ามีอะไรเปลี่ยนแปลงไประหว่างบิวด์ และที่แย่ที่สุดคือ มันทำให้บิวด์ของผมบน Cloudflare Pages พังเพราะติดข้อจำกัดจำนวนไฟล์
ด้านล่างนี้ผมจะอธิบายปัญหา ทำไมโซลูชันที่มีอยู่ถึงไม่เหมาะกับผม และผมสร้าง Vite plugin ขึ้นมาเองโดยใช้ Import Maps แก้ปัญหานี้ให้จบแบบถาวรได้อย่างไร
ปัญหา: Cascading Hash Changes
Vite ใช้ content-based hashing สำหรับ production builds เวลาที่คุณบิวด์แอป ไฟล์ JavaScript แต่ละไฟล์จะได้ hash ในชื่อไฟล์โดยอิงจากเนื้อหาของมัน ถ้า button.tsx ถูกคอมไพล์เป็น button-abc12345.js แล้วเนื้อหาเปลี่ยน ก็จะกลายเป็น button-def45678.js ซึ่งดีมากสำหรับการ bust cache เพราะผู้ใช้จะได้ไฟล์ใหม่เมื่อมันเปลี่ยน
ปัญหาเกิดขึ้นเมื่อไฟล์ A import ไฟล์ B สมมติว่าคุณมี:
// main.js
เมื่อ button.tsx เปลี่ยน Vite จะสร้าง button-def45678.js แต่ตอนนี้ main.js ก็เปลี่ยนตามไปด้วยเพราะมันมี string "./button-abc12345.js" ซึ่งตอนนี้ผิดแล้ว ดังนั้น main.js ก็เลยได้ hash ใหม่ด้วย ทั้งที่ตรรกะใน main.js ไม่ได้เปลี่ยนแม้แต่นิดเดียว
ปัญหานี้จะลุกลามไปทั่วทั้ง dependency graph ของคุณ เปลี่ยน utility function เดียว แล้วจู่ๆ ครึ่งหนึ่งของไฟล์ js ของคุณก็ได้ hash ใหม่หมด ในกรณีของผม การเปลี่ยนตัวอักษรตัวเดียวใน useBackgroundMusic.ts ทำให้ไฟล์มากกว่า 500 ไฟล์ถูก re-hash
ผลกระทบในการใช้งานจริงรุนแรงมาก เรา bundle assets ของบิวด์เก่าไว้ 8 เวอร์ชัน เพื่อให้ผู้ใช้ที่ยังใช้ client เวอร์ชันเก่าอยู่นิดหน่อยยังคงรันเวอร์ชันของพวกเขาได้เมื่อเรา deploy เวอร์ชันใหม่ขึ้น Cloudflare Pages อย่างไรก็ตาม Cloudflare Pages มีข้อจำกัดที่ 20,000 ไฟล์ ซึ่งเราเริ่มชนเพดานเพราะการเปลี่ยน i18n ก่อนหน้านี้ ที่ทำให้จำนวนไฟล์ที่เราสร้างพุ่งสูงขึ้นมาก
การแก้ปัญหา cascading hashes ทำให้เราสามารถเก็บบิวด์เก่าได้มากขึ้นโดยไม่ชนเพดาน เพราะตอนนี้ไฟล์ส่วนใหญ่ไม่จำเป็นต้องเปลี่ยนอีกต่อไป สิ่งนี้ยังลดโอกาสที่ผู้ใช้บนบิวด์เก่าจะเจอ error อีกด้วย เพราะมีโอกาสสูงกว่ามากที่พวกเขาจะร้องขอไฟล์ที่ไม่ได้เปลี่ยนซึ่งเรามีอยู่พอดี
ทำไมไม่ใช้ [โซลูชันทางเลือก]?
ตอนแรกที่ผมมองหาทางแก้ ผมพิจารณาหลายแนวทาง แต่ไม่มีอันไหนที่เหมาะเลย
Post-build Scripts
ความคิดแรกของผมคือเขียน post-build script ที่จะ normalize import paths ทั้งหมด re-hash ไฟล์ และอัปเดต references ดูเหมือนตรงไปตรงมา แค่ใช้ regex แทนชื่อไฟล์ที่มี hash ด้วยชื่อที่คงที่ แล้วคำนวณ hash ใหม่
ผมปฏิเสธวิธีนี้เพราะกังวลเรื่อง "Heisenbugs" และ cache poisoning แม้ว่าเราจะเก็บบิวด์เก่าไว้ใน Cloudflare Pages ความเสี่ยงเรื่อง cache ไม่สอดคล้องกันก็ไม่คุ้มที่จะรับ Script ที่แก้ไขไฟล์หลังบิวด์อาจสร้างบั๊กเล็กๆ ที่ปรากฏแค่ใน production และการ debug มันจะเป็นฝันร้ายแน่นอน
Vite manualChunks
อีกทางเลือกคือใช้ค่า manualChunks ของ Vite เพื่อแยกโค้ดที่นิ่ง (เช่น node_modules) ออกจากโค้ดที่ไม่นิ่ง (business logic) แนวคิดคือโค้ด vendor จะเปลี่ยนน้อยกว่า ดังนั้นไฟล์ที่จะ cascade ก็จะน้อยลง
แต่วิธีนี้ไม่ได้แก้ปัญหาที่ต้นตอจริงๆ มันแค่บรรเทาเท่านั้น คุณก็ยังเจอ cascading hashes ภายใน chunks ของ business logic อยู่ดี ผมต้องการโซลูชันที่จัดการกับปัญหาหลัก ไม่ใช่แค่ทำให้มันเลวร้ายน้อยลงนิดหน่อย
Import Maps: โซลูชันสมัยใหม่
Import Maps เป็นฟีเจอร์ native ของเบราว์เซอร์ (พร้อมรองรับ polyfill สำหรับเบราว์เซอร์เก่า) ที่แยก module specifiers ออกจาก file paths แทนที่ไฟล์ A จะ import "./button-abc123.js" มันจะ import "button" แทน เบราว์เซอร์จะใช้ import map ในการ resolve "button" ไปยังชื่อไฟล์ที่มี hash จริง
นี่คือสิ่งที่ผมต้องการเป๊ะๆ เนื้อหาของไฟล์ A ยังคงเหมือนเดิม (มัน import "button" เสมอ) ดังนั้น hash ของมันก็ยังคงเดิม มีแค่ import map และไฟล์ที่เปลี่ยนเท่านั้นที่ได้ hash ใหม่ ผมแอบช็อกเลยว่าทำไมยังไม่มีใครทำ plugin ดีๆ แบบนี้ออกมา!
การสร้าง Vite Plugin
ผมตัดสินใจสร้าง Vite plugin ที่จะ:
- แปลง relative imports ทั้งหมดให้ใช้ module specifiers ที่นิ่ง
- สร้าง import map ที่ map specifiers เหล่านั้นไปยังชื่อไฟล์ที่มี hash จริง
- ฉีด import map เข้าไปใน HTML
Plugin นี้พร้อมใช้งานบน GitHub แล้ว: @foony/vite-plugin-import-map
แนวทางเริ่มต้น
ผมเริ่มด้วย Vite plugin ที่ใช้ hook generateBundle ความพยายามแรกของผมใช้ regex หาและแทนที่ import paths วิธีนี้เขียนง่ายและใช้ได้กับทีมเล็กๆ ของเราที่ Foony แต่มันเปราะบางและคงไม่เหมาะกับ plugin ที่อาจมี false-positives ถูกเปลี่ยนแปลงโดยไม่ตั้งใจ
วิธีใช้ regex มีปัญหาชัดเจน: ถ้า string ในโค้ดบังเอิญดูเหมือนชื่อไฟล์ล่ะ? แล้ว dynamic imports ล่ะ? แล้ว export statements ล่ะ? ผมต้องการโซลูชันที่แข็งแรงกว่านี้ถ้าจะสร้าง plugin ให้คนอื่นใช้
AST Parsing
ผมต้อง parse โค้ด JavaScript อย่างถูกต้องเพื่อหา import statements ทั้งหมด ความพยายามแรกคือใช้ es-module-lexer ซึ่งออกแบบมาเพื่อ parse ES modules โดยเฉพาะ น่าเสียดายที่มันทำให้เกิด native panics ระหว่างขั้นตอน module analysis ของ Vite ลองใช้ asm.js build แล้วก็ยังแก้ panics ไม่ได้
ผมเลยมาลงตัวที่ Acorn ซึ่งเป็น parser JavaScript ที่เร็ว เบา และเป็น pure JavaScript ใช้ร่วมกับ acorn-walk สำหรับการเดิน AST มันให้ทุกอย่างที่ผมต้องการโดยไม่มีปัญหาเรื่อง native dependency
ความท้าทายหลักที่แก้ได้
จัดการกับ Imports ทุกประเภท
Imports มีหลายรูปแบบ และถูกจัดการต่างกันใน AST ผมต้องจัดการกับ:
- Static imports:
import x from "./file.js" - Dynamic imports:
import("./file.js") - Named re-exports:
export { x } from "./file.js"(ตอนแรกผมพลาดอันนี้ไป!) - Re-export all:
export * from "./file.js"
กรณี re-export นี่ยุ่งเป็นพิเศษเพราะผมพลาดมันไปจนกระทั่งเห็นไฟล์ที่ไม่ถูกแปลง โค้ดมี export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" และ plugin ของผมก็เพิกเฉยต่อมันเลยเพราะผมมองหาแค่ ImportDeclaration กับ ImportExpression nodes
ตอนนี้ผมจัดการทุกแบบแบบนี้:
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
},
});
การแก้ปัญหาชื่อชนกันแบบ Deterministic
เมื่อหลายไฟล์มี base name เดียวกัน (เช่นมีหลายไฟล์ index.tsx ในไดเรกทอรีต่างกัน) ผมต้องแยกแยะมัน จะใช้ "index" กับทุกไฟล์ไม่ได้
โซลูชันของผม: ถ้ามีการชนกัน ผมจะ hash จาก source path เดิมบวกกับ base name ตัวอย่างเช่น src/client/games/chess/index.tsx:index จะถูก hash เป็น index-abc123 วิธีนี้ทำให้มั่นใจได้ว่าไฟล์เดียวกันจะได้ module specifier เดียวกันเสมอข้ามบิวด์ แม้ว่าจะมีการเพิ่มหรือลบไฟล์อื่นที่มีชื่อเดียวกัน
ผมใช้ chunk.facadeModuleId (entry point) เป็นตัวระบุหลัก โดยใช้ chunk.moduleIds[0] แทนถ้าไม่มี วิธีนี้ทำให้ผมได้ source path ที่นิ่งสำหรับการ hash แบบ deterministic
Source Map Chaining
เมื่อผมแปลงโค้ด ผมกำลังทำลาย source map chain Source map เดิม map จาก TypeScript source ดั้งเดิมผ่าน Babel และการ minify มาจนถึงโค้ดปัจจุบัน การแปลงของผมเพิ่มอีกชั้นหนึ่ง ดังนั้นผมต้องรักษา chain นั้นไว้
ผมใช้ MagicString เพื่อติดตามการแปลงและสร้าง source map ใหม่ จากนั้นรวมมันกับ map เดิมโดยรักษา arrays sources และ sourcesContent เดิมไว้ วิธีนี้รักษา chain ทั้งหมด: Source ดั้งเดิม → (map เดิม) → โค้ดที่แปลงแล้ว
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 เนื้อหาที่แปลงแล้ว
ผมต้องการให้เนื้อหาไฟล์นิ่ง วิธีคือผมแปลง imports (แทนที่ imports ที่มี hash ของ Vite ด้วย imports ที่นิ่งของผม) จากนั้นตัด source map comments ออกจากการคำนวณ hash (เพราะมันอ้างอิงชื่อไฟล์เก่า)
หลังจากนั้น ผมคำนวณ hash ใหม่และอัปเดตทั้งชื่อไฟล์และ entry ใน import map
การ Implement สุดท้าย
Plugin ใช้กลยุทธ์ 4 รอบ:
- รอบนับ: ตรวจหาชื่อชนกันโดยนับว่ามีไฟล์กี่ไฟล์ที่ใช้ base name เดียวกัน
- รอบ Map: สร้าง chunk mapping (ชื่อไฟล์ที่มี hash → module specifier) และ import map เริ่มต้น
- รอบ Transform: เขียน import paths ในโค้ดใหม่ คำนวณ hash ใหม่ อัปเดต source maps
- รอบ Rename: อัปเดตชื่อไฟล์ใน bundle และทำให้ import map สำเร็จ
นี่คือ logic การแปลงหลัก:
// 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 เข้าไปใน HTML ผมใช้ tag injection API ของ Vite แทนการจัดการด้วย regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
วิธีนี้น่าเชื่อถือกว่าการพยายาม regex match HTML tags มาก
ดูจากตัวเลข
เพื่อให้คุณเห็นภาพว่า plugin นี้ทำอะไร:
- ~1,000+ ไฟล์ JavaScript ถูกประมวลผลต่อบิวด์
- ~2-3 วินาที ที่เพิ่มเข้าไปในเวลาบิวด์ (แลกเปลี่ยนได้)
- ~99% ลดลง ในการเปลี่ยน hash ที่ไม่จำเป็น (ไฟล์ส่วนใหญ่ตอนนี้เปลี่ยนเฉพาะเมื่อเนื้อหาเปลี่ยนจริง)
- ~340 บรรทัด ของโค้ด plugin (รวมความคิดเห็นและการจัดการข้อผิดพลาด)
Plugin จัดการกรณีพิเศษทั้งหมดที่ผมเจอจนถึงตอนนี้ และกระบวนการบิวด์ก็คาดเดาได้มากขึ้นเยอะ
บทเรียนที่ได้
ทำไม AST parsing ถึงสำคัญ
การใช้ regex บนโค้ดที่ bundle แล้วเป็นเรื่องอันตราย ถ้า string ในโค้ดของคุณบังเอิญดูเหมือนชื่อไฟล์ regex จะเขียนทับมันทันที AST parsing ทำให้มั่นใจว่าคุณแปลงเฉพาะ import/export statements จริงๆ เท่านั้น
ทำไมเลือก Acorn แทน es-module-lexer
es-module-lexer เร็วกว่าและถูกสร้างมาเฉพาะทางมากกว่า แต่ปัญหา native panic ทำให้ใช้ใน Vite plugin context ของผมไม่ได้ Acorn เป็น pure JavaScript ซึ่งหมายความว่าไม่ต้องกังวลเรื่อง native dependencies ผมอยากกลับไปดู es-module-lexer ในอนาคตเพื่อ optimize ด้านความเร็ว แต่ตอนนี้ Acorn ทำงานได้สมบูรณ์แบบ
ทำไมเลือก Import Maps แทนทางเลือกอื่น
Import Maps เป็น web standard ที่มี native browser support พวกมันเป็นวิธีที่ "ถูกต้อง" ในการแก้ปัญหานี้ Polyfill (es-module-shims) จัดการเบราว์เซอร์เก่า (เช่น Safari < 16.4) ได้อย่างราบรื่น และโซลูชันก็สะอาดและดูแลรักษาได้ง่าย
บทสรุป
Plugin Import Maps สามารถป้องกัน cascading hash changes ในบิวด์ Vite ของผมได้สำเร็จ ตอนนี้ไฟล์จะได้ hash ใหม่เฉพาะเมื่อเนื้อหาจริงๆ เปลี่ยน ไม่ใช่เมื่อ dependencies เปลี่ยน สิ่งนี้ทำให้บิวด์คาดเดาได้มากขึ้น ลดการ invalidate แคชที่ไม่จำเป็น และช่วยให้เราอยู่ภายใต้ขีดจำกัดจำนวนไฟล์ของ Cloudflare Pages
โซลูชันนี้เรียบง่าย ดูแลรักษาง่าย และใช้ web standards สมัยใหม่ มันเป็นตัวอย่างที่ดีว่าบางครั้งโซลูชันที่ "ถูกต้อง" ก็เป็นโซลูชันที่ง่ายที่สุดด้วย เมื่อคุณเข้าใจปัญหาลึกพอที่จะมองเห็นมัน
Plugin เป็น open source และพร้อมใช้งานบน GitHub: @foony/vite-plugin-import-map คุณสามารถติดตั้งได้ด้วย npm install @foony/vite-plugin-import-map แล้วเริ่มใช้ในโปรเจ็กต์ Vite ของคุณเองได้เลย
การปรับปรุงในอนาคตอาจรวมถึงการ optimize ด้วย es-module-lexer เมื่อปัญหา native panic ถูกแก้ไข หรือเพิ่มการรองรับสถานการณ์ import ที่ซับซ้อนกว่านี้ แต่ตอนนี้ plugin ทำสิ่งที่ผมต้องการพอดีๆ
แล้วใครจะรู้? บางทีสักวันหนึ่ง Vite อาจรองรับอะไรแบบนี้แบบ native ก็ได้
(อัปเดต: หลังจากลองใช้ plugin บนบิวด์ของ Foony มีผู้ใช้บางคนเจอปัญหาที่ไม่คาดคิด ผมเลยปิดมันไว้ก่อน จะกลับไปดูอีกที บางที ผมก็ยังคิดว่านี่เป็นโซลูชันที่เจ๋งอยู่ดี)