

1/1/1970
Mình đã xử lý chuyện hash đổi dây chuyền với Import Maps như thế nào
Chào mọi người! Mình đã gặp vấn đề này hơn 5 năm rồi nhưng đến bây giờ mới quyết tâm giải quyết, vì nó đã đến mức mình không thể lờ đi nữa. Chỉ cần mình đổi một ký tự trong một file, là một nửa số file JavaScript trong build sẽ có tên mới kèm hash, dù nội dung thực sự của chúng không hề thay đổi. Điều này làm mất hiệu lực cache một cách vô ích, gần như không thể theo dõi được chính xác cái gì thay đổi giữa các lần build, và tệ nhất là làm hỏng build trên Cloudflare Pages vì đụng giới hạn số lượng file.
Dưới đây mình sẽ chia nhỏ vấn đề, giải thích vì sao các giải pháp sẵn có không hợp với mình, và cách mình viết một plugin Vite riêng dùng Import Maps để xử lý dứt điểm chuyện này.
Vấn đề: Hash đổi dây chuyền
Vite dùng hashing dựa trên nội dung cho các build production. Khi bạn build app, mỗi file JavaScript sẽ có một đoạn hash trong tên file dựa trên nội dung của nó. Nếu button.tsx được compile thành button-abc12345.js, rồi nội dung thay đổi, nó sẽ thành button-def45678.js. Điều này rất tốt cho việc phá cache cũ (cache busting): người dùng sẽ tải file mới khi nó thay đổi.
Vấn đề xuất hiện khi File A import File B. Ví dụ bạn có:
// main.js
import { Button } from "./button-abc12345.js";
Khi button.tsx thay đổi, Vite tạo ra button-def45678.js. Nhưng lúc này main.js cũng bị thay đổi vì bên trong nó đang chứa chuỗi "./button-abc12345.js", vốn đã sai. Thế là main.js cũng bị gán hash mới, dù logic thực sự trong main.js chẳng hề đổi.
Chuỗi hiệu ứng này lan ra toàn bộ đồ thị phụ thuộc của bạn. Đổi một hàm tiện ích là tự nhiên một nửa số file js có hash mới. Trường hợp của mình, chỉ đổi một ký tự trong useBackgroundMusic.ts mà hơn 500 file bị re-hash lại.
Ảnh hưởng thực tế thì khá nặng. Bọn mình bundle 8 phiên bản asset của các build cũ để người dùng đang chạy client hơi cũ vẫn có thể tiếp tục chạy đúng phiên bản đó khi bọn mình deploy phiên bản mới lên Cloudflare Pages. Tuy nhiên, Cloudflare Pages có giới hạn 20.000 file, và bọn mình bắt đầu đụng trần sau đợt thay đổi i18n trước đó khiến số lượng file tạo ra tăng bùng nổ.
Giải quyết được chuyện hash đổi dây chuyền giúp bọn mình lưu được nhiều build cũ hơn mà không chạm giới hạn, vì giờ đa số file không còn cần phải thay đổi nữa. Nó cũng giảm khả năng người dùng trên một build cũ bị lỗi, vì khả năng cao họ sẽ yêu cầu một file giờ đây không đổi và bọn mình vẫn còn giữ.
Tại sao không dùng [Giải pháp khác]?
Lúc mới bắt tay vào nghĩ cách xử lý, mình đã cân nhắc vài hướng tiếp cận khác nhau. Nhưng chẳng cái nào thực sự hợp.
Script chạy sau khi build
Suy nghĩ đầu tiên của mình là viết một script chạy sau khi build để chuẩn hóa tất cả đường dẫn import, re-hash lại file và cập nhật các chỗ tham chiếu. Nghe thì có vẻ khá đơn giản, kiểu như dùng regex thay các tên file có hash thành tên ổn định, rồi tính lại hash.
Mình bỏ qua hướng này vì sợ sinh ra "Heisenbugs" và các vấn đề cache bị nhiễm bẩn. Dù bọn mình có lưu các build cũ trên Cloudflare Pages, rủi ro cache không nhất quán vẫn quá lớn. Một script sửa file sau khi build có thể tạo ra những bug rất khó thấy, chỉ xuất hiện trên production, và debug mấy thứ đó thì đúng là ác mộng.
Vite manualChunks
Một lựa chọn khác là dùng config manualChunks của Vite để tách phần code ổn định (như node_modules) khỏi phần hay thay đổi (business logic). Ý tưởng là code vendor sẽ ít thay đổi hơn, nên số file bị ảnh hưởng dây chuyền sẽ ít lại.
Nhưng cách này thực ra không giải quyết tận gốc, mà chỉ giảm bớt phần nào. Bạn vẫn bị hash đổi dây chuyền trong các chunk business logic. Mình muốn một giải pháp xử lý đúng vào lõi vấn đề, chứ không chỉ làm nó đỡ tệ hơn một chút.
Import Maps: Giải pháp hiện đại
Import Maps là một tính năng có sẵn trên trình duyệt (có polyfill cho các trình duyệt cũ) giúp tách tên module khỏi đường dẫn file. Thay vì File A import "./button-abc123.js", nó chỉ import "button". Trình duyệt sẽ dùng import map để ánh xạ "button" sang đúng tên file đã có hash.
Đây chính xác là thứ mình cần. Nội dung File A giữ nguyên (nó luôn import "button"), nên hash của nó cũng giữ nguyên. Chỉ có import map và file thực sự thay đổi mới nhận hash mới. Mình cũng khá sốc là chưa ai viết sẵn một plugin ngon lành cho chuyện này!
Hành trình hiện thực hóa
Thế là mình quyết định viết một plugin Vite với các mục tiêu:
- Biến tất cả import tương đối thành các module specifier ổn định
- Tạo một import map ánh xạ các specifier đó tới đúng tên file có hash
- Inject import map đó vào HTML
Plugin hiện đã có trên GitHub: @foony/vite-plugin-import-map
Cách tiếp cận ban đầu
Mình bắt đầu với một plugin Vite dùng hook generateBundle. Lần thử đầu tiên dùng regex để tìm và thay đường dẫn import. Viết kiểu này thì rất dễ và chạy ổn với team nhỏ của bọn mình ở Foony, nhưng nó khá mong manh và chắc chắn không ổn nếu đóng gói thành plugin cho người khác, nơi có thể có rất nhiều trường hợp bị nhận diện sai rồi bị sửa nhầm.
Cách dùng regex có một loạt vấn đề rõ ràng: nếu một string trong code vô tình trông giống tên file thì sao? Còn dynamic import thì xử lý thế nào? Còn các câu lệnh export thì sao? Nếu muốn làm plugin cho người khác dùng, mình cần một giải pháp cứng cáp hơn nhiều.
Parse AST
Mình cần parse code JavaScript một cách đàng hoàng để tìm ra tất cả các câu lệnh import. Lần thử đầu là dùng es-module-lexer, một thư viện được thiết kế riêng cho việc parse ES module. Tiếc là nó lại gây ra native panic trong giai đoạn Vite phân tích module. Thử cả bản build asm.js rồi mà vẫn không hết panic.
Cuối cùng mình chọn Acorn, một parser thuần JavaScript, nhanh và nhẹ. Kết hợp với acorn-walk để duyệt AST, bộ đôi này cho mình mọi thứ cần thiết mà không dính đến dependency native nào.
Những thách thức chính đã giải quyết
Xử lý mọi kiểu import
Import xuất hiện dưới nhiều dạng khác nhau, và trên AST thì mỗi dạng lại được biểu diễn khác nhau. Mình cần xử lý được:
- Import tĩnh:
import x from "./file.js" - Import động:
import("./file.js") - Re-export có tên:
export { x } from "./file.js"(ban đầu mình còn bỏ sót kiểu này!) - Re-export tất cả:
export * from "./file.js"
Trường hợp re-export đặc biệt rắc rối vì mình đã bỏ qua nó cho đến khi thấy một file không hề bị transform. Đoạn code kiểu export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" hoàn toàn bị plugin ngó lơ, vì lúc đó mình chỉ dò các node ImportDeclaration và ImportExpression.
Giờ thì mình xử lý tất cả như thế này:
walk(ast, {
ImportDeclaration(node: any) {
// Import tĩnh: import x from "spec"
const specifier = node.source.value;
// ... logic chuyển đổi
},
ExportNamedDeclaration(node: any) {
// Export có tên kèm source: export { x, y } from "spec"
if (!node.source?.value) return;
// ... logic chuyển đổi
},
ExportAllDeclaration(node: any) {
// Export tất cả: export * from "spec"
if (!node.source?.value) return;
// ... logic chuyển đổi
},
ImportExpression(node: any) {
// Import động: import("spec")
// ... logic chuyển đổi
},
});
Giải quyết xung đột một cách xác định
Khi nhiều file có cùng tên gốc (ví dụ nhiều file index.tsx ở các thư mục khác nhau), mình cần phân biệt chúng. Không thể chỉ dùng "index" cho tất cả được.
Giải pháp của mình: nếu bị trùng, mình hash đường dẫn gốc cộng với tên file. Ví dụ, src/client/games/chess/index.tsx:index sẽ được hash thành index-abc123. Cách này đảm bảo cùng một file thì luôn nhận cùng một module specifier qua các lần build, ngay cả khi có thêm hoặc bớt những file trùng tên khác.
Mình dùng chunk.facadeModuleId (entry point) làm định danh chính, và fallback sang chunk.moduleIds[0] nếu không có. Nhờ đó mình có được một đường dẫn nguồn ổn định để tính hash một cách xác định.
Nối chuỗi source map
Khi mình transform code, mình vô tình làm gãy chuỗi source map. Source map hiện tại đang map từ source TypeScript gốc, qua Babel và minify, đến đoạn code hiện tại. Việc transform thêm một lớp nữa nghĩa là mình cần giữ cho chuỗi đó vẫn liền mạch.
Mình dùng MagicString để theo dõi các thay đổi và sinh ra một source map mới. Sau đó mình merge nó với map cũ bằng cách giữ lại các mảng sources và sourcesContent gốc. Nhờ vậy, toàn bộ chuỗi được giữ nguyên: Source gốc → (map hiện có) → Code đã transform.
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: dùng mapping của map mới nhưng giữ nguyên sources gốc
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Re-hash nội dung đã transform
Mình cần nội dung file ổn định. Để làm được vậy, mình transform các import (thay các import có hash do Vite tạo bằng import ổn định của mình), rồi loại bỏ phần comment source map khỏi bước tính hash (vì chúng còn tham chiếu đến tên file cũ).
Sau đó mình tính hash mới, rồi cập nhật cả tên file lẫn entry tương ứng trong import map.
Bản triển khai cuối cùng
Plugin này hoạt động theo chiến lược bốn bước:
- Bước đếm: Phát hiện trùng tên bằng cách đếm xem có bao nhiêu file dùng cùng một tên gốc
- Bước map: Tạo mapping giữa chunk (tên file có hash → module specifier) và import map ban đầu
- Bước transform: Ghi lại đường dẫn import trong code, tính lại hash, cập nhật source map
- Bước đổi tên: Cập nhật tên file trong bundle và chốt import map cuối cùng
Dưới đây là phần logic transform cốt lõi:
import {simple as walk} from 'acorn-walk';
// Parse code để lấy AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Duyệt AST để tìm tất cả 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 để bỏ qua dấu nháy mở
end: node.source.end - 1, // -1 để bỏ qua dấu nháy đóng
replacement: moduleSpec,
});
}
},
// ... xử lý các kiểu node khác
});
// Áp dụng thay đổi theo thứ tự ngược để giữ nguyên vị trí
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Để inject import map vào HTML, mình dùng API chèn tag của Vite thay vì nghịch HTML bằng regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Cách này tin cậy hơn rất nhiều so với việc cố dùng regex để bắt các thẻ HTML.
Một vài con số
Để bạn hình dung rõ hơn plugin này làm được gì:
- ~1.000+ file JavaScript được xử lý mỗi lần build
- ~2-3 giây thêm vào thời gian build (một mức đánh đổi chấp nhận được)
- ~99% giảm số lần đổi hash không cần thiết (đa số file giờ chỉ đổi khi nội dung thực sự thay đổi)
- ~340 dòng code plugin (bao gồm cả comment và xử lý lỗi)
Plugin xử lý được tất cả các edge case mà đến giờ mình gặp phải, và quy trình build giờ dễ đoán hơn rất nhiều.
Những bài học mình rút ra
Vì sao parse AST là cực kỳ quan trọng
Dùng regex trên code đã bundle rất nguy hiểm. Nếu một string trong code tình cờ trông giống tên file, regex sẽ sửa luôn nó. Parse AST giúp bạn chắc chắn chỉ đụng vào những câu lệnh import/export thật sự.
Vì sao chọn Acorn thay vì es-module-lexer
es-module-lexer nhanh hơn và được thiết kế đúng mục đích hơn, nhưng các vấn đề native panic khiến nó không dùng được trong bối cảnh plugin Vite của mình. Acorn thì thuần JavaScript, nên không phải lo chuyện dependency native. Sau này mình vẫn muốn thử dùng es-module-lexer để tối ưu tốc độ, nhưng hiện tại thì Acorn chạy quá ổn rồi.
Vì sao chọn Import Maps thay vì các cách khác
Import Maps là một tiêu chuẩn web có hỗ trợ trực tiếp từ trình duyệt. Đây là cách "đúng chuẩn" để giải quyết vấn đề này. Polyfill (es-module-shims) xử lý các trình duyệt cũ (ví dụ Safari < 16.4) khá êm, và toàn bộ giải pháp thì sạch sẽ, dễ bảo trì.
Kết luận
Plugin Import Maps này đã chặn thành công chuyện hash đổi dây chuyền trong các build Vite của mình. Giờ thì file chỉ nhận hash mới khi chính nội dung của nó thay đổi, chứ không phải khi dependency của nó đổi. Điều đó giúp quá trình build dễ đoán hơn, giảm việc làm mất hiệu lực cache một cách không cần thiết và giúp bọn mình không vượt quá giới hạn số file của Cloudflare Pages.
Giải pháp này đơn giản, dễ bảo trì và dựa trên các tiêu chuẩn web hiện đại. Nó là một ví dụ hay cho việc đôi khi giải pháp "đúng" cũng chính là giải pháp đơn giản nhất, miễn là bạn hiểu vấn đề đủ sâu để nhìn ra nó.
Plugin là mã nguồn mở và đang có trên GitHub: @foony/vite-plugin-import-map. Bạn có thể cài bằng npm install @foony/vite-plugin-import-map và bắt đầu dùng trong các dự án Vite của riêng bạn.
Trong tương lai, mình có thể tối ưu thêm bằng es-module-lexer khi các vấn đề native panic được giải quyết, hoặc hỗ trợ nhiều trường hợp import phức tạp hơn. Còn hiện tại, plugin đã làm đúng chính xác những gì mình cần.
Ai mà biết được, biết đâu một ngày nào đó Vite sẽ hỗ trợ sẵn một thứ tương tự như thế này.