

1/1/1970
ผมแก้ปัญหา Cascading Hash Changes ด้วย Import Maps ยังไง
สวัสดีทุกคน! ผมอยู่กับปัญหานี้มามากกว่า 5 ปีแล้ว แต่เพิ่งจะตัดสินใจลุยจริงจังตอนที่มันเริ่มหนักถึงขั้นมองข้ามไม่ได้อีกต่อไป เวลาแก้ตัวอักษรแค่ตัวเดียวในไฟล์เดียว อยู่ดี ๆ ไฟล์ JavaScript ครึ่งหนึ่งใน build ก็ได้ชื่อไฟล์ใหม่ที่มี hash ติดท้าย ทั้งที่เนื้อหาจริง ๆ ของไฟล์พวกนั้นไม่ได้เปลี่ยนเลยสักนิด
ผลลัพธ์คือ cache ถูกเคลียร์แบบไม่จำเป็น ทำให้แทบจะตามไม่ทันเลยว่าจริง ๆ แล้วระหว่างแต่ละ build มีอะไรเปลี่ยนไปบ้าง แถมที่แย่สุด ๆ คือมันทำให้ build ของ Cloudflare Pages พังเพราะชนกับลิมิตจำนวนไฟล์
ด้านล่างนี้ผมจะแยกเล่าให้ฟังว่าปัญหาคืออะไร ทำไมวิธีที่มีอยู่ถึงใช้กับผมไม่ได้ และผมเขียนปลั๊กอิน Vite แบบ custom ที่ใช้ Import Maps มาจัดการเรื่องนี้แบบถาวรยังไง
ปัญหา: Cascading Hash Changes
Vite ใช้ content-based hashing ตอน build production หมายความว่าเวลา build แอป ไฟล์ JavaScript แต่ละไฟล์จะได้ hash ติดในชื่อไฟล์ตามเนื้อหาข้างใน เช่นถ้า button.tsx ถูก compile เป็น button-abc12345.js แล้วเนื้อหาไฟล์เปลี่ยน มันก็จะกลายเป็น button-def45678.js แบบนี้ดีมากสำหรับ cache busting เพราะพอไฟล์เปลี่ยน ผู้ใช้ก็จะได้ไฟล์ใหม่ทันที
ปัญหาคือพอไฟล์ A import ไฟล์ B สมมติว่ามีโค้ดแบบนี้:
// main.js
import { Button } from "./button-abc12345.js";
พอ button.tsx เปลี่ยน Vite ก็จะสร้าง button-def45678.js ขึ้นมา แต่ตอนนี้ main.js ก็ต้องเปลี่ยนด้วย เพราะมันยังมีสตริง "./button-abc12345.js" ซึ่งไม่ถูกต้องแล้ว เพราะงั้น main.js เลยต้องได้ hash ใหม่ด้วย ทั้งที่ logic ข้างใน main.js ไม่ได้เปลี่ยนแม้แต่นิดเดียว
แล้วเรื่องนี้มันไม่ได้หยุดแค่นั้น มันลามไปทั้งกราฟ dependency เลย เปลี่ยน utility function ตัวเดียว ไฟล์ js ครึ่งโปรเจกต์ก็ได้ hash ใหม่หมด ในเคสของผม แก้ตัวอักษรแค่ตัวเดียวใน useBackgroundMusic.ts ทำให้ไฟล์โดน re-hash ไปมากกว่า 500 ไฟล์
ในโลกจริงมันเจ็บตัวกว่านั้นเยอะ เราบันเดิล asset ของ build เก่าเก็บไว้ 8 เวอร์ชัน เพื่อให้ผู้ใช้ที่ยังใช้ client เวอร์ชันเก่านิดหน่อยยังรันเวอร์ชันของเขาได้ตอนเราปล่อยเวอร์ชันใหม่ขึ้น Cloudflare Pages แต่ Cloudflare Pages มีลิมิตไฟล์อยู่ที่ 20,000 ไฟล์ ซึ่งเราเริ่มชนลิมิตนี้เพราะ การเปลี่ยน i18n ก่อนหน้านี้ของเรา ที่ทำให้จำนวนไฟล์ที่สร้างพุ่งกระฉูด
พอแก้ปัญหา cascading hashes ได้ เราก็เก็บ build เก่าได้เยอะขึ้นมากโดยไม่ชนลิมิต เพราะตอนนี้ไฟล์ส่วนใหญ่ไม่จำเป็นต้องเปลี่ยนแล้ว แถมยังลดโอกาสที่ผู้ใช้บน build เก่าจะเจอ error ด้วย เพราะโอกาสสูงขึ้นมากที่เขาจะเรียกไฟล์ที่ยังเหมือนเดิมและเรายังเก็บไว้ให้
แล้วทำไมไม่ใช้ [วิธีอื่น ๆ]?
ตอนเริ่มคิดจะแก้ปัญหานี้ ผมลองคิดอยู่หลายวิธี แต่ไม่มีแบบไหนที่ "ใช่" สำหรับผมจริง ๆ
Post-build Scripts
ความคิดแรกคือเขียนสคริปต์รันหลัง build เพื่อ normalize path ของ import ทั้งหมด แล้วค่อย re-hash ไฟล์กับอัปเดต reference ดูเหมือนง่ายเลย แค่ regex หาชื่อไฟล์ที่มี hash แล้วแทนที่ด้วยชื่อที่เสถียร จากนั้นก็คำนวณ hash ใหม่
แต่สุดท้ายผมตัดวิธีนี้ทิ้งเพราะกลัว Heisenbug กับปัญหา cache poisoning ถึงเราจะเก็บ build เก่าไว้บน Cloudflare Pages แต่ความเสี่ยงเรื่อง cache ไม่ตรงกันมันไม่คุ้มเลย สคริปต์ที่ไปแก้ไฟล์หลัง build สามารถแอบใส่บั๊กเบา ๆ ที่โผล่มาเฉพาะใน production ได้ แล้วถ้าต้องมานั่ง debug บั๊กแบบนั้นคงปวดหัวสุด ๆ
Vite manualChunks
อีกทางเลือกคือใช้ config manualChunks ของ Vite เพื่อแยกโค้ดที่เสถียร (เช่น node_modules) ออกจากโค้ดที่เปลี่ยนบ่อย (business logic) ไอเดียคือ vendor code จะเปลี่ยนไม่บ่อย ทำให้ไฟล์ฝั่งนั้นโดน cascade น้อยลง
แต่จริง ๆ วิธีนี้ไม่ได้แก้ปัญหาที่ต้นตอ มันแค่ช่วยลดความรุนแรงเฉย ๆ คุณยังได้ cascading hashes อยู่ดีใน chunk ฝั่ง business logic ผมอยากได้วิธีที่ไปจัดการกับรากของปัญหา ไม่ใช่แค่ทำให้มันดูเบาลงหน่อย
Import Maps: วิธีสมัยใหม่
Import Maps เป็นฟีเจอร์ที่มีในเบราว์เซอร์แล้ว (มี polyfill ให้เบราว์เซอร์เก่าด้วย) ที่ช่วยแยก "ชื่อ module" ออกจาก "path ของไฟล์" แทนที่ไฟล์ A จะ import "./button-abc123.js" มันจะ import "button" แทน แล้วเบราว์เซอร์จะดูจาก import map ว่า "button" ไปแมปกับไฟล์ที่มี hash ตัวไหน
นี่แหละคือสิ่งที่ผมต้องการเป๊ะ ๆ เนื้อหาของไฟล์ A เลยเหมือนเดิมเสมอ (มัน import แค่ "button" ตลอด) ทำให้ hash ของไฟล์ A ไม่ต้องเปลี่ยน มีแค่ import map กับไฟล์ที่เปลี่ยนเท่านั้นที่ต้องได้ hash ใหม่ ผมแอบงงว่าทำไมยังไม่มีใครทำปลั๊กอินดี ๆ แบบนี้ไว้ให้ใช้เลย!
เส้นทางการลงมือทำ
ผมตัดสินใจเขียนปลั๊กอิน Vite ขึ้นมาเองให้มันทำสิ่งเหล่านี้:
- แปลง relative import ทั้งหมดให้ไปใช้ชื่อ module แบบเสถียร
- สร้าง import map ที่แมปชื่อ module พวกนั้นไปยังชื่อไฟล์ที่มี hash จริง ๆ
- ยัด import map เข้าไปใน HTML ให้พร้อมใช้งาน
ปลั๊กอินตัวนี้เปิดบน GitHub แล้ว: @foony/vite-plugin-import-map
แนวคิดแรกเริ่ม
ผมเริ่มจากปลั๊กอิน Vite ที่ใช้ hook generateBundle รอบแรกใช้ regex หาแล้วแทนที่ path ของ import อันนี้เขียนง่าย ใช้ในทีมเล็ก ๆ อย่าง Foony ก็ยังพอไหว แต่จริง ๆ แล้วเปราะบางมาก และน่าจะใช้ในปลั๊กอินแบบทั่วไปไม่ได้แน่ ๆ เพราะอาจมี false-positive โดนแก้ไปด้วย
ปัญหาของ regex เด่นชัดมาก เช่น ถ้ามีสตริงในโค้ดที่หน้าตาคล้ายชื่อไฟล์ขึ้นมาเฉย ๆ ล่ะ? แล้ว dynamic import ล่ะ? แล้ว export แบบต่าง ๆ ล่ะ? ถ้าจะทำปลั๊กอินให้คนอื่นใช้ได้จริง ผมต้องมีวิธีที่แข็งแรงกว่านี้
การ parse AST
ผมต้อง parse โค้ด JavaScript ให้ถูกต้องเพื่อหา import statement ทั้งหมด ความพยายามแรกคือใช้ es-module-lexer ที่ออกแบบมาสำหรับ parse ES modules โดยเฉพาะ แต่สุดท้ายมันทำให้เกิด native panic ระหว่างที่ Vite วิเคราะห์ module แม้แต่ลองใช้ build แบบ asm.js ก็ยังหยุด panic ไม่ได้
ผมเลยมาจบที่ Acorn ซึ่งเป็น parser แบบ pure JavaScript ที่ทั้งเร็วและเบา พอจับคู่กับ acorn-walk สำหรับเดิน AST ก็ได้ทุกอย่างที่ต้องใช้ แถมไม่ต้องยุ่งกับ dependency ฝั่ง native เลย
ปัญหาหลัก ๆ ที่ต้องแก้
รองรับ import ทุกรูปแบบ
`import มีหลายแบบในโค้ด และแต่ละแบบก็จะเป็น node คนละชนิดใน 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" แล้วปลั๊กอินผมเมินมันไปหมดเลย เพราะผมไปดูแค่ node ประเภท ImportDeclaration กับ ImportExpression
ตอนนี้ผมจัดการทุกแบบแบบนี้:
walk(ast, {
ImportDeclaration(node: any) {
// import แบบ static: import x from "spec"
const specifier = node.source.value;
// ... โค้ดแปลง
},
ExportNamedDeclaration(node: any) {
// export แบบระบุชื่อพร้อม source: export { x, y } from "spec"
if (!node.source?.value) return;
// ... โค้ดแปลง
},
ExportAllDeclaration(node: any) {
// export ทั้งหมด: export * from "spec"
if (!node.source?.value) return;
// ... โค้ดแปลง
},
ImportExpression(node: any) {
// import แบบ dynamic: import("spec")
// ... โค้ดแปลง
},
});
การแก้ชื่อซ้ำให้เสถียร (Deterministic Conflict Resolution)
บางครั้งเรามีหลายไฟล์ที่ใช้ชื่อ base เดียวกัน (เช่น index.tsx หลายไฟล์ในหลายโฟลเดอร์) ผมต้องหาวิธีแยกแยะให้ได้ จะให้ใช้ "index" เหมือนกันหมดก็ไม่ได้
วิธีของผมคือ ถ้ามีชื่อชนกัน ผมจะ hash จาก path ต้นฉบับของไฟล์รวมกับชื่อ base เช่น src/client/games/chess/index.tsx:index เอาไป hash ให้ได้ชื่ออย่าง index-abc123 แบบนี้ทำให้ไฟล์เดิมไฟล์เดียวกันจะได้ชื่อ module specifier แบบเดียวกันทุกครั้งไม่ว่าจะ build กี่รอบ แม้จะมีไฟล์อื่นชื่อ index.tsx โผล่เข้ามาหรือถูกลบออกไปก็ตาม
ผมใช้ chunk.facadeModuleId (entry point) เป็นตัวระบุหลัก ถ้าไม่มีค่อย fallback ไปใช้ chunk.moduleIds[0] วิธีนี้ช่วยให้ได้ path ต้นทางที่เสถียรพอจะเอาไป hash ต่อ
การต่อสาย Source Map
พอผมแปลงโค้ด ผมไปตัดสาย source map เดิมทิ้งโดยไม่ตั้งใจ ปกติ source map ที่มีอยู่จะ map จาก TypeScript ดั้งเดิม ผ่าน Babel และการ minify มาจนถึงโค้ดปัจจุบัน การแปลงของผมเพิ่มเลเยอร์ใหม่เข้ามาอีกชั้น เลยต้องรักษาสายโซ่นี้เอาไว้
ผมใช้ MagicString ในการตามรอยว่ามีการเปลี่ยนอะไรตรงไหน แล้วให้มันสร้าง source map ใหม่ จากนั้นก็ merge เข้ากับแผนที่เดิม โดยยังคง sources และ sourcesContent จาก map เดิมไว้ แบบนี้จะยังคงสายโซ่เต็ม ๆ แบบนี้:
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,
});
// รวม map: ใช้ mappings จาก map ใหม่ แต่ยังเก็บ sources เดิมเอาไว้
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Re-hash หลังแปลงเนื้อหาแล้ว
ผมต้องการให้เนื้อหาไฟล์เสถียรจริง ๆ ขั้นตอนคือ แปลง import ทั้งหมด (แทนที่ import แบบมี hash ของ Vite ด้วยชื่อ module แบบเสถียรของผม) แล้วตัด comment ของ source map ออกจากส่วนที่ใช้คำนวณ hash (เพราะตรงนั้นยังอ้างอิงชื่อไฟล์เก่าอยู่)
หลังจากนั้นค่อยคำนวณ hash ใหม่ แล้วอัปเดตทั้งชื่อไฟล์และ entry ที่อยู่ใน import map ให้ตรงกัน
ภาพรวมการทำงานสุดท้าย
ปลั๊กอินตัวนี้ใช้กลยุทธ์แบบ 4 รอบ:
- รอบนับ (Count pass): เช็กว่ามีไฟล์กี่ไฟล์ที่ใช้ชื่อ base เดียวกัน เพื่อจับเคสชื่อชน
- รอบแมป (Map pass): สร้าง mapping ของ chunk (ชื่อไฟล์ที่มี hash -> module specifier) และ import map เบื้องต้น
- รอบแปลง (Transform pass): เขียน path ของ import ใหม่ในโค้ด คำนวณ hash ใหม่ แล้วอัปเดต source map
- รอบเปลี่ยนชื่อ (Rename pass): อัปเดตชื่อไฟล์ใน bundle ให้ตรง และปิดจ๊อบ import map
นี่คือ logic แกนกลางของการแปลง:
import {simple as walk} from 'acorn-walk';
// parse โค้ดให้ได้ AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// เดิน AST เพื่อหา 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 เพื่อข้ามเครื่องหมาย quote ตัวหน้า
end: node.source.end - 1, // -1 เพื่อข้ามเครื่องหมาย quote ตัวท้าย
replacement: moduleSpec,
});
}
},
// ... รองรับ node ประเภทอื่น ๆ
});
// ใช้การแปลงย้อนจากท้ายสุดขึ้นมา เพื่อไม่ให้ตำแหน่งพัง
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
ส่วนการยัด import map เข้าไปใน HTML ผมใช้ API สำหรับ inject tag ของ Vite แทนการใช้ regex จับ HTML เอง:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
แบบนี้เชื่อถือได้กว่าการพยายามใช้ regex ไปจับ tag HTML เยอะ
ตัวเลขคร่าว ๆ
ลองให้ภาพรวมคร่าว ๆ ว่าปลั๊กอินนี้ทำอะไรบ้างในโปรเจกต์ของผม:
- ~1,000+ ไฟล์ JavaScript ถูกประมวลผลต่อหนึ่ง build
- ~2-3 วินาที ที่เพิ่มเข้ามาในเวลา build (ถือว่าแลกแล้วคุ้ม)
- ~99% ลดลง ของ hash ที่เปลี่ยนไปแบบไม่จำเป็น (ส่วนใหญ่ไฟล์จะเปลี่ยนเฉพาะตอนเนื้อหาข้างในเปลี่ยนเท่านั้น)
- ~340 บรรทัด ของโค้ดในปลั๊กอิน (รวมคอมเมนต์และ error handling แล้ว)
ตอนนี้ปลั๊กอินรองรับเคสแปลก ๆ ที่ผมเจอทั้งหมด และขั้นตอน build ก็ predictable ขึ้นเยอะมาก
สิ่งที่ได้เรียนรู้
ทำไม AST parsing ถึงสำคัญ
ใช้ regex จับโค้ดที่บันเดิลแล้วนี่อันตรายมาก ถ้ามีสตริงในโค้ดที่หน้าตาเหมือนชื่อไฟล์ แล้ว regex ไปโดนเข้า มันก็จะถูกเขียนทับทั้งที่ไม่ควร AST parsing ช่วยให้เรามั่นใจได้ว่ากำลังแตะเฉพาะ statement ที่เป็น import/export จริง ๆ เท่านั้น
ทำไมเลือก Acorn แทน es-module-lexer
es-module-lexer เร็วกว่าและถูกออกแบบมาสำหรับงานแบบนี้โดยตรง แต่ปัญหา native panic ทำให้เอามาใช้ใน context ของปลั๊กอิน Vite ไม่ได้จริง Acorn เป็น pure JavaScript เลยไม่ต้องกังวล dependency ฝั่ง native ในอนาคตผมน่าจะลองมอง es-module-lexer อีกทีเพื่อ optimization เรื่องความเร็ว แต่ตอนนี้ Acorn ก็ทำงานได้ดีมากแล้ว
ทำไม Import Maps ดีกว่าวิธีอื่น
Import Maps เป็นมาตรฐานของเว็บ มีรองรับในเบราว์เซอร์จริง ๆ แล้ว มันเลยเป็นวิธีที่ "ใช่" ในการแก้ปัญหานี้ polyfill (es-module-shims) ก็ช่วยรองรับเบราว์เซอร์เก่า (เช่น Safari < 16.4) แบบนุ่มนวล แถมโค้ดที่ได้ก็อ่านง่ายและดูแลต่อได้ไม่ยาก
สรุป
ปลั๊กอิน Import Maps ตัวนี้ช่วยหยุดปัญหา cascading hash changes ใน Vite build ของผมได้สำเร็จ ไฟล์จะได้ hash ใหม่เฉพาะตอนเนื้อหาข้างในเปลี่ยนจริง ๆ ไม่ใช่แค่เพราะ dependency ของมันเปลี่ยน ส่งผลให้ build เดาทางได้ง่ายขึ้น ลดการเคลียร์ cache ที่ไม่จำเป็น และช่วยให้เราอยู่ใต้ลิมิตจำนวนไฟล์ของ Cloudflare Pages ได้แบบสบายใจขึ้น
โซลูชันนี้ทั้งเรียบง่าย ดูแลง่าย และใช้มาตรฐานเว็บสมัยใหม่ เป็นตัวอย่างที่ดีว่า หลายครั้งวิธีที่ "ถูกต้อง" ก็คือวิธีที่เรียบง่ายที่สุด แค่ต้องเข้าใจปัญหาให้ลึกพอจะมองมันออก
ปลั๊กอินนี้เป็น open source และอยู่บน GitHub แล้ว: @foony/vite-plugin-import-map ติดตั้งได้ด้วยคำสั่ง npm install @foony/vite-plugin-import-map แล้วก็เอาไปใช้ในโปรเจกต์ Vite ของคุณได้เลย
ในอนาคตอาจมีการปรับปรุงเพิ่มเติม เช่น ลอง optimize ด้วย es-module-lexer ถ้าปัญหา native panic ถูกแก้แล้ว หรือเพิ่มการรองรับเคส import ที่ซับซ้อนขึ้น แต่ตอนนี้ ปลั๊กอินก็ทำในสิ่งที่ผมต้องการได้ครบถ้วนแล้ว
ใครจะรู้ วันหนึ่ง Vite อาจรองรับอะไรแบบนี้มาให้ในตัวเลยก็ได้