

1/1/1970
วิธีที่ผมแก้ปัญหา Cascading Hash Changes ด้วย Import Maps
สวัสดีครับ! ปัญหานี้ผมเจอมานานกว่า 5 ปีแล้ว แต่เพิ่งจะลงมือแก้ตอนนี้เพราะมันเริ่มถึงจุดที่ละเลยต่อไปไม่ได้แล้ว เวลาผมเปลี่ยนตัวอักษรแค่ตัวเดียวในไฟล์เดียว ไฟล์ JavaScript ในบิวด์ของผมครึ่งหนึ่งจะได้ชื่อไฟล์แบบ hash ใหม่ ทั้งที่เนื้อหาจริงไม่ได้เปลี่ยนเลย ปัญหานี้ทำให้ cache ถูก invalidate โดยไม่จำเป็น ทำให้แทบเป็นไปไม่ได้ที่จะติดตามว่าอะไรเปลี่ยนไประหว่างบิวด์ และที่แย่ที่สุดคือ มันทำให้บิวด์ Cloudflare Pages ของผมพังเพราะติด file limit
ด้านล่างนี้ผมจะอธิบายปัญหา เหตุผลที่วิธีแก้ที่มีอยู่แล้วไม่เวิร์กสำหรับผม และวิธีที่ผมสร้าง Vite plugin ของตัวเองโดยใช้ Import Maps เพื่อแก้ปัญหานี้ให้จบในครั้งเดียว
ปัญหา: Cascading Hash Changes
Vite ใช้ content-based hashing สำหรับ production builds เวลาคุณบิวด์แอป ไฟล์ JavaScript แต่ละไฟล์จะได้ hash ในชื่อไฟล์ตามเนื้อหา ถ้า button.tsx คอมไพล์เป็น button-abc12345.js แล้วเนื้อหาเปลี่ยน มันจะกลายเป็น button-def45678.js วิธีนี้ดีมากสำหรับ cache busting เพราะผู้ใช้จะได้ไฟล์ใหม่เมื่อมันเปลี่ยน
ปัญหาเกิดเมื่อ File A import File B สมมติว่าคุณมี:
// main.js
import { Button } from "./button-abc12345.js";
เมื่อ button.tsx เปลี่ยน Vite จะสร้าง button-def45678.js แต่ตอนนี้ main.js ก็เปลี่ยนด้วยเพราะมันมี string "./button-abc12345.js" ที่ตอนนี้ผิดไปแล้ว ดังนั้น main.js จึงได้ hash ใหม่ด้วย ทั้งที่ logic จริงใน main.js ไม่ได้เปลี่ยนเลย
ปัญหานี้ลามไปทั้ง dependency graph ของคุณ เปลี่ยน utility function ตัวเดียว แล้วจู่ๆ ไฟล์ js ครึ่งหนึ่งของคุณก็ได้ hash ใหม่ ในกรณีของผม การเปลี่ยนตัวอักษรตัวเดียวใน useBackgroundMusic.ts ทำให้ไฟล์กว่า 500 ไฟล์ถูก re-hash
ผลกระทบในโลกจริงนั้นใหญ่มาก เรา bundle assets ของบิวด์เก่าๆ ไว้ 8 เวอร์ชันเพื่อให้ผู้ใช้ที่ใช้ client เวอร์ชันเก่ากว่าเล็กน้อยยังสามารถรันเวอร์ชันของตัวเองได้เมื่อเรา deploy เวอร์ชันใหม่ขึ้น Cloudflare Pages อย่างไรก็ตาม Cloudflare Pages มี file limit อยู่ที่ 20,000 ไฟล์ ซึ่งเราเริ่มชนเพดานเพราะการเปลี่ยนแปลง i18n ก่อนหน้านี้ ที่ทำให้จำนวนไฟล์ที่เราสร้างเพิ่มขึ้นมหาศาล
การแก้ปัญหา cascading hashes ทำให้เราเก็บบิวด์เก่าๆ ได้มากขึ้นโดยไม่ชน limit เพราะตอนนี้ไฟล์ส่วนใหญ่ไม่จำเป็นต้องเปลี่ยนแล้ว นอกจากนี้ยังลดโอกาสที่ผู้ใช้บนบิวด์เก่าจะ error เพราะมีโอกาสสูงกว่ามากที่พวกเขาจะ request ไฟล์ที่ไม่ได้เปลี่ยนซึ่งบังเอิญเรามีอยู่
ทำไมไม่ใช้ [วิธีแก้แบบอื่น]?
ตอนแรกที่ผมมองหาวิธีแก้ ผมพิจารณาหลายวิธี แต่ไม่มีวิธีไหนที่เข้าทางผมเลย
Post-build Scripts
ความคิดแรกของผมคือเขียน post-build script ที่จะ normalize import paths ทั้งหมด re-hash ไฟล์ และอัปเดต references ดูเหมือนจะตรงไปตรงมา แค่ใช้ regex แทนที่ชื่อไฟล์ที่มี hash ด้วยชื่อแบบ stable แล้วคำนวณ hash ใหม่
ผมตัดวิธีนี้ทิ้งเพราะกังวลเรื่อง "Heisenbugs" และ cache poisoning ถึงแม้เราจะเก็บบิวด์เก่าไว้ใน Cloudflare Pages ความเสี่ยงจาก cache inconsistencies ก็ไม่คุ้ม script ที่แก้ไฟล์หลังบิวด์อาจสร้าง bug ลึกลับที่ปรากฏแค่ใน production ซึ่ง debug มันคงเป็นฝันร้าย
Vite manualChunks
อีกทางเลือกคือใช้ config manualChunks ของ Vite เพื่อแยกโค้ดที่ stable (เช่น node_modules) ออกจากโค้ดที่ไม่ stable (business logic) ไอเดียคือ vendor code จะเปลี่ยนน้อยกว่า ดังนั้นไฟล์ที่ cascade ก็จะน้อยลง
วิธีนี้ไม่ได้แก้ที่ต้นเหตุจริงๆ มันแค่บรรเทาเท่านั้น คุณก็ยังเจอ cascading hashes ภายใน chunks ของ business logic อยู่ดี ผมต้องการวิธีแก้ที่จัดการกับปัญหาที่แท้จริง ไม่ใช่แค่ทำให้มันแย่น้อยลงนิดหน่อย
Import Maps: ทางออกแบบสมัยใหม่
Import Maps เป็น feature ที่ browser รองรับโดยตรง (มี polyfill รองรับ browser เก่า) ที่แยก module specifiers ออกจาก file paths แทนที่ File A จะ import "./button-abc123.js" มันจะ import "button" แทน Browser จะใช้ import map เพื่อ resolve "button" ไปที่ชื่อไฟล์จริงที่มี hash
นี่แหละคือสิ่งที่ผมต้องการพอดี เนื้อหาของ File A จะเหมือนเดิม (มัน import "button" เสมอ) ดังนั้น hash ของมันก็จะเหมือนเดิม มีแค่ import map กับไฟล์ที่เปลี่ยนเท่านั้นที่ได้ hash ใหม่ ผมแอบช็อกที่ยังไม่มีใครทำ plugin ดีๆ สำหรับเรื่องนี้!
การสร้าง Vite Plugin
ผมตัดสินใจสร้าง Vite plugin ที่จะ:
- แปลง relative imports ทั้งหมดให้ใช้ stable module specifiers
- สร้าง import map ที่ map specifiers พวกนั้นไปยังชื่อไฟล์ที่มี hash จริง
- Inject import map ลงใน HTML
Plugin นี้พร้อมใช้งานบน GitHub แล้ว: @foony/vite-plugin-import-map
แนวทางเริ่มต้น
ผมเริ่มต้นด้วย Vite plugin โดยใช้ generateBundle hook ความพยายามครั้งแรกใช้ 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 ระหว่าง phase วิเคราะห์ module ของ Vite แม้แต่ลองใช้ asm.js build ก็ไม่ช่วยหยุด panics
ผมเลยลงเอยที่ Acorn ซึ่งเป็น parser ที่เร็ว เบา และเป็น pure JavaScript ใช้ร่วมกับ acorn-walk สำหรับ AST traversal ทำให้ผมได้ทุกอย่างที่ต้องการโดยไม่มีปัญหา native dependency
ความท้าทายหลักๆ ที่แก้ได้
จัดการ Import ทุกประเภท
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 ยุ่งเป็นพิเศษเพราะผมพลาดไปจนเจอไฟล์ที่ไม่ถูก transform โค้ดมีหน้าตาแบบ 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 Conflict Resolution
เมื่อหลายไฟล์มี base name เดียวกัน (เช่นมีหลายไฟล์ index.tsx ใน directory ต่างกัน) ผมต้องแยกพวกมันออก ผมใช้ "index" กับทุกอันไม่ได้
วิธีแก้ของผม: ถ้ามี conflict ผมจะ hash source path เดิมรวมกับ base name ตัวอย่างเช่น src/client/games/chess/index.tsx:index ถูก hash เพื่อสร้าง index-abc123 วิธีนี้ทำให้ไฟล์เดียวกันได้ module specifier เดียวกันเสมอข้ามบิวด์ ถึงแม้จะมีไฟล์ชื่อเดียวกันถูกเพิ่มหรือลบออก
ผมใช้ chunk.facadeModuleId (entry point) เป็น identifier หลัก โดย fallback ไปที่ chunk.moduleIds[0] ถ้าไม่มี วิธีนี้ให้ source path ที่ stable สำหรับการ hash แบบ deterministic
Source Map Chaining
เมื่อผม transform โค้ด ผมกำลังตัด source map chain source map ที่มีอยู่ map จาก TypeScript source ดั้งเดิม ผ่าน Babel และ minification ไปยังโค้ดปัจจุบัน Transformations ของผมเพิ่มอีกชั้น ดังนั้นผมต้องรักษา chain นั้นไว้
ผมใช้ MagicString เพื่อติดตาม transformations และสร้าง source map ใหม่ จากนั้น merge มันกับ map เดิมโดยรักษา arrays sources และ sourcesContent ดั้งเดิมไว้ วิธีนี้รักษา chain เต็ม: Original Source → (existing map) → Transformed Code
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 Transformed Content
ผมต้องการเนื้อหาไฟล์ที่ stable เพื่อทำสิ่งนี้ ผม transform imports (แทนที่ hashed imports ของ Vite ด้วย stable imports ของผม) แล้วก็ตัด source map comments ออกจากการคำนวณ hash (เพราะมันอ้างถึงชื่อไฟล์เก่า)
หลังจากนั้น ผมคำนวณ hash ใหม่ และอัปเดตทั้งชื่อไฟล์และ entry ใน import map
Implementation สุดท้าย
Plugin ใช้กลยุทธ์ four-pass:
- Count pass: ตรวจหา name collisions โดยนับจำนวนไฟล์ที่มี base name เดียวกัน
- Map pass: สร้าง chunk mapping (hashed filename → module specifier) และ import map เริ่มต้น
- Transform pass: เขียน import paths ใหม่ในโค้ด คำนวณ hashes ใหม่ อัปเดต source maps
- Rename pass: อัปเดตชื่อไฟล์ใน bundle และสรุป import map
นี่คือ logic การ transform หลัก:
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);
}
สำหรับการ inject import map ลง HTML ผมใช้ tag injection API ของ Vite แทนการ manipulate ด้วย 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 วินาที ที่เพิ่มขึ้นในเวลาบิวด์ (trade-off ที่รับได้)
- ~99% reduction ใน hash changes ที่ไม่จำเป็น (ไฟล์ส่วนใหญ่เปลี่ยนเฉพาะตอนเนื้อหาจริงๆ เปลี่ยนเท่านั้น)
- ~340 บรรทัด ของโค้ด plugin (รวม comments และ error handling)
Plugin จัดการกับ edge cases ทั้งหมดที่ผมเจอจนถึงตอนนี้ และกระบวนการ build ก็คาดเดาได้มากขึ้น
บทเรียนที่ได้
ทำไม AST parsing จึงสำคัญ
การใช้ Regex กับโค้ดที่ถูก bundle แล้วเป็นเรื่องอันตราย ถ้า string ในโค้ดของคุณบังเอิญดูเหมือนชื่อไฟล์ regex จะเขียนทับมัน AST parsing รับประกันว่าคุณ transform เฉพาะ import/export statements จริงๆ เท่านั้น
ทำไม Acorn ดีกว่า es-module-lexer
es-module-lexer เร็วกว่าและถูกออกแบบมาเฉพาะกว่า แต่ปัญหา native panic ทำให้ใช้ในบริบท Vite plugin ของผมไม่ได้ Acorn เป็น pure JavaScript ซึ่งหมายความว่าไม่ต้องกังวลเรื่อง native dependencies ผมอยากกลับมาดู es-module-lexer ในอนาคตเพื่อ optimize ความเร็ว แต่ตอนนี้ Acorn ทำงานได้สมบูรณ์แบบ
ทำไม Import Maps ดีกว่าทางเลือกอื่น
Import Maps เป็น web standard ที่ browser รองรับโดยตรง พวกมันเป็นวิธี "ที่ถูกต้อง" ในการแก้ปัญหานี้ Polyfill (es-module-shims) จัดการ browser เก่า (เช่น Safari < 16.4) ได้อย่างราบรื่น และวิธีแก้นี้ก็สะอาดและดูแลรักษาง่าย
บทสรุป
Plugin Import Maps แก้ปัญหา cascading hash changes ในบิวด์ Vite ของผมได้สำเร็จ ตอนนี้ไฟล์จะได้ hash ใหม่เฉพาะตอนเนื้อหาจริงๆ เปลี่ยนเท่านั้น ไม่ใช่ตอน dependencies เปลี่ยน วิธีนี้ทำให้บิวด์คาดเดาได้มากขึ้น ลด cache invalidation ที่ไม่จำเป็น และช่วยให้เราอยู่ภายใต้ file limit ของ 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 ถูกแก้ หรือเพิ่ม support สำหรับ import scenarios ที่ซับซ้อนกว่า แต่ตอนนี้ plugin ทำสิ่งที่ผมต้องการได้ตรงเป๊ะ
แล้วใครจะรู้? บางทีสักวัน Vite อาจจะ support อะไรแบบนี้แบบ native ก็ได้
(อัปเดต: หลังจากลอง plugin บนบิวด์ของ Foony ผู้ใช้บางคนเจอปัญหาที่ไม่คาดคิด ผมเลยปิดมันไปก่อน อาจจะกลับมาดูใหม่ทีหลัง คงนะ ผมยังคิดว่านี่เป็นวิธีแก้ที่เจ๋งอยู่ดี)