1/7/2026

Cách Mình Giải Quyết Vấn Đề Hash Thay Đổi Dây Chuyền Bằng Import Maps

Chào cả nhà! Mình đã gặp vấn đề này hơn 5 năm rồi, nhưng giờ mới quyết định xử lý vì nó đã đến mức không thể bỏ qua nữa. Khi mình thay đổi một ký tự duy nhất trong một file, một nửa số file JavaScript trong bản build sẽ có tên hash mới, dù nội dung thực tế chẳng thay đổi gì. Điều này gây ra việc cache bị vô hiệu hóa không cần thiết, khiến việc theo dõi xem thực sự có gì thay đổi giữa các bản build trở nên gần như bất khả thi, và tệ nhất là: làm hỏng bản build Cloudflare Pages của mình vì giới hạn số lượng file.

Dưới đây mình sẽ phân tích vấn đề, lý do các giải pháp hiện có không hợp với mình, và cách mình đã xây dựng một plugin Vite tùy chỉnh sử dụng Import Maps để giải quyết vấn đề này một lần và mãi mãi.

Vấn Đề: Hash Thay Đổi Dây Chuyền

Vite sử dụng hashing dựa trên nội dung cho các bản build production. Khi bạn build app, mỗi file JavaScript sẽ có một hash trong tên file dựa trên nội dung của nó. Nếu button.tsx được biên dịch thành button-abc12345.js, và nội dung thay đổi, nó sẽ trở thành button-def45678.js. Cách này rất hữu ích cho việc cache busting, người dùng sẽ nhận được file mới khi nó thay đổi.

Vấn đề xuất hiện khi File A import File B. Giả sử bạn có:

// main.js

Khi button.tsx thay đổi, Vite tạo ra button-def45678.js. Nhưng giờ main.js cũng thay đổi vì nó chứa chuỗi "./button-abc12345.js", vốn giờ đã sai. Nên main.js cũng có hash mới, dù logic thực tế trong main.js chẳng thay đổi tí nào.

Điều này lan truyền qua toàn bộ đồ thị phụ thuộc của bạn. Thay đổi một hàm tiện ích, và đột nhiên một nửa số file js có hash mới. Trong trường hợp của mình, thay đổi một ký tự duy nhất trong useBackgroundMusic.ts đã khiến hơn 500 file phải re-hash.

Tác động thực tế là rất đáng kể. Chúng mình bundle 8 phiên bản tài sản từ các bản build trước để người dùng đang dùng phiên bản client hơi cũ vẫn có thể chạy phiên bản của họ khi chúng 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 mà chúng mình bắt đầu chạm tới do thay đổi i18n trước đó, khiến số lượng file tạo ra tăng vọt.

Giải quyết được vấn đề hash dây chuyền cho phép chúng mình lưu trữ nhiều bản build cũ hơn nữa mà không bị chạm giới hạn vì giờ hầu hết các file không cần phải thay đổi nữa. Điều này cũng giảm khả năng người dùng đang dùng bản build cũ gặp lỗi, vì khả năng cao là họ sẽ yêu cầu một file giờ không còn thay đổi và chúng mình tình cờ vẫn có.

Tại Sao Không Dùng [Giải Pháp Khác]?

Khi mới tìm cách giải quyết, mình đã cân nhắc vài hướng. Không cái nào thực sự phù hợp.

Script Sau Build

Ý nghĩ ban đầu của mình là viết một script chạy sau build để chuẩn hóa tất cả đường dẫn import, re-hash các file, và cập nhật các tham chiếu. Việc này có vẻ đơn giản, chỉ cần dùng regex thay thế tên file đã hash bằng tên ổn định, rồi tính lại hash.

Mình bác bỏ cách tiếp cận này vì lo ngại "Heisenbugs" và cache poisoning. Dù chúng mình lưu các bản build cũ trên Cloudflare Pages, rủi ro về sự không nhất quán của cache không đáng để đánh đổi. Một script sửa file sau khi build có thể tạo ra những bug tinh vi chỉ xuất hiện trong production, và debug chúng sẽ là cơn ác mộng.

manualChunks của Vite

Một lựa chọn khác là dùng cấu hình manualChunks của Vite để tách code ổn định (như node_modules) khỏi code không ổn định (logic nghiệp vụ). Ý tưởng là code vendor sẽ ít thay đổi hơn, nên ít file bị ảnh hưởng dây chuyền hơn.

Cách này thực ra không giải quyết được vấn đề gốc rễ, nó chỉ giảm nhẹ thôi. Bạn vẫn bị hash dây chuyền trong các chunk logic nghiệp vụ. Mình muốn một giải pháp xử lý vấn đề cốt lõi, không chỉ làm nó đỡ tệ đi 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 trong trình duyệt (kèm hỗ trợ polyfill cho trình duyệt cũ) tách rời module specifier khỏi đường dẫn file. Thay vì File A import "./button-abc123.js", nó import "button". Trình duyệt dùng import map để giải quyết "button" thành tên file đã hash thực tế.

Đây chính xác là thứ mình cần. Nội dung File A vẫn giữ nguyên (nó luôn import "button"), nên hash của nó vẫn giữ nguyên. Chỉ có import map và file đã thay đổi mới có hash mới. Mình hơi sốc khi thấy chưa ai làm một plugin tốt cho việc này!

Xây Dựng Plugin Vite

Mình quyết định xây một plugin Vite có thể:

  1. Chuyển đổi tất cả import tương đối sang dùng module specifier ổn định
  2. Tạo một import map ánh xạ các specifier đó tới tên file đã hash thực tế
  3. Chèn 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 thế đường dẫn import. Cách này dễ code và hoạt động ổn cho đội nhỏ của chúng mình tại Foony, nhưng mong manh và chắc chắn không hoạt động được trong một plugin nơi có thể có những trường hợp dương tính giả bị biến đổi.

Cách tiếp cận regex có những vấn đề rõ ràng: lỡ một chuỗi trong code tình cờ trông giống tên file thì sao? Còn dynamic import? Còn câu lệnh export? Mình cần một giải pháp vững chắc hơn nếu muốn xây plugin cho người khác dùng.

Phân Tích AST

Mình cần phân tích code JavaScript một cách chính xác để tìm tất cả câu lệnh import. Lần thử đầu tiên là es-module-lexer, được thiết kế đặc biệt để phân tích ES module. Tiếc thay, nó gây ra panic native trong giai đoạn phân tích module của Vite. Ngay cả thử bản asm.js cũng không giúp ngăn được các panic này.

Mình quyết định dùng Acorn, một parser JavaScript thuần túy, nhanh và nhẹ. Kết hợp với acorn-walk để duyệt AST, nó cho mình mọi thứ cần thiết mà không gặp vấn đề về phụ thuộc native.

Những Thử Thách Chính Đã Giải Quyết

Xử Lý Tất Cả Loại Import

Import có nhiều dạng, và chúng được xử lý khác nhau trong AST. Mình cần xử lý:

  • Import tĩnh: import x from "./file.js"
  • Import động: import("./file.js")
  • Re-export có tên: export { x } from "./file.js" (mình đã bỏ sót cái này lúc đầu!)
  • Re-export tất cả: export * from "./file.js"

Trường hợp re-export đặc biệt khó vì mình bỏ sót cho đến khi thấy một file không được biến đổi. Code có export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" và plugin của mình hoàn toàn bỏ qua nó vì mình chỉ tìm các node ImportDeclarationImportExpression.

Đây là cách mình xử lý tất cả chúng giờ:

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
  },
});

Giải Quyết Xung Đột Một Cách Tất Định

Khi nhiều file có cùng tên cơ sở (ví dụ nhiều file index.tsx trong 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 có xung đột, mình hash đường dẫn nguồn gốc cộng với tên cơ sở. Ví dụ, src/client/games/chess/index.tsx:index được hash thành index-abc123. Cách này đảm bảo cùng một file luôn có cùng module specifier qua các bản build, ngay cả khi các file khác cùng tên được thêm vào hoặc bị xóa.

Mình dùng chunk.facadeModuleId (entry point) làm định danh chính, và quay lại dùng chunk.moduleIds[0] nếu cái đó không có. Điều này cho mình một đường dẫn nguồn ổn định để hash một cách tất định.

Liên Kết Source Map

Khi mình biến đổi code, mình đang phá vỡ chuỗi source map. Source map hiện có ánh xạ từ source TypeScript gốc qua Babel và minification đến code hiện tại. Các biến đổi của mình thêm một lớp nữa, nên cần phải bảo toàn chuỗi đó.

Mình dùng MagicString để theo dõi các biến đổi và tạo ra một source map mới. Sau đó merge nó với map hiện có bằng cách bảo toàn các mảng sourcessourcesContent ban đầu. Điều này duy trì toàn bộ chuỗi: Source Gốc → (map hiện có) → Code Đã Biến Đổi.

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-hash Nội Dung Đã Biến Đổi

Mình cần nội dung file ổn định. Để làm điều này, mình biến đổi các import (thay thế các import đã hash của Vite bằng các import ổn định của mình), rồi loại bỏ các comment source map khỏi việc tính hash (chúng tham chiếu đến tên file cũ).

Sau đó, mình tính một hash mới, và cập nhật cả tên file lẫn entry trong import map.

Triển Khai Cuối Cùng

Plugin sử dụng chiến lược bốn lượt:

  1. Lượt đếm: Phát hiện xung đột tên bằng cách đếm số file dùng chung mỗi tên cơ sở
  2. Lượt ánh xạ: Tạo ánh xạ chunk (tên file đã hash → module specifier) và import map ban đầu
  3. Lượt biến đổi: Viết lại đường dẫn import trong code, tính lại hash, cập nhật source map
  4. Lượt đổi tên: Cập nhật tên file trong bundle và hoàn thiện import map

Đây là logic biến đổi cốt lõi:


// 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);
}

Để chèn import map vào HTML, mình dùng API chèn tag của Vite thay vì thao tác bằng regex:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

Cách này đáng tin cậy hơn nhiều so với việc cố regex-match các tag HTML.

Bằng Những Con Số

Để bạn cảm nhận được plugin này làm gì:

  • ~1.000+ file JavaScript được xử lý mỗi bản build
  • ~2-3 giây thêm vào thời gian build (đánh đổi chấp nhận được)
  • ~99% giảm số lần thay đổi hash không cần thiết (hầu hết file giờ chỉ thay đổi khi nội dung thực tế thay đổi)
  • ~340 dòng code plugin (bao gồm comment và xử lý lỗi)

Plugin xử lý mọi trường hợp biên mình đã gặp đến giờ, và quá trình build giờ dễ đoán hơn nhiều.

Bài Học Rút Ra

Tại sao phân tích AST là thiết yếu

Dùng regex trên code đã bundle là nguy hiểm. Nếu một chuỗi trong code tình cờ trông giống tên file, regex sẽ viết lại nó. Phân tích AST đảm bảo bạn chỉ biến đổi đúng các câu lệnh import/export thực sự.

Tại sao chọn Acorn thay vì es-module-lexer

es-module-lexer nhanh hơn và được xây dựng chuyên dụng hơn, nhưng vấn đề panic native khiến nó không thể dùng được trong ngữ cảnh plugin Vite của mình. Acorn là JavaScript thuần túy, nghĩa là không phải lo về phụ thuộc native. Mình sẽ muốn xem xét es-module-lexer trong tương lai như một tối ưu hóa tốc độ, nhưng giờ Acorn hoạt động hoàn hảo.

Tại sao chọn Import Maps thay vì các giải pháp khác

Import Maps là một chuẩn web với sự hỗ trợ tự nhiên từ trình duyệt. Chúng là cách "đúng đắ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) một cách mượt mà, và giải pháp sạch sẽ, dễ bảo trì.

Kết Luận

Plugin Import Maps đã ngăn chặn thành công vấn đề hash thay đổi dây chuyền trong các bản build Vite của mình. Các file giờ chỉ có hash mới khi nội dung thực tế thay đổi, không phải khi các phụ thuộc thay đổi. Điều này khiến các bản build dễ đoán hơn, giảm việc cache bị vô hiệu hóa không cần thiết, và giúp chúng mình ở dưới giới hạn số file của Cloudflare Pages.

Giải pháp đơn giản, dễ bảo trì, và sử dụng các chuẩn web hiện đại. Đây là một ví dụ hay về việc đôi khi giải pháp "đúng đắn" cũng là giải pháp đơn giản nhất, một khi bạn hiểu vấn đề đủ sâu để nhìn thấy nó.

Plugin là mã nguồn mở và có sẵn trên GitHub: @foony/vite-plugin-import-map. Bạn có thể cài đặt nó bằng npm install @foony/vite-plugin-import-map và bắt đầu sử dụng trong các dự án Vite của mình.

Cải tiến trong tương lai có thể bao gồm tối ưu hóa với es-module-lexer khi vấn đề panic native được giải quyết, hoặc thêm hỗ trợ cho các kịch bản import phức tạp hơn. Nhưng hiện tại, plugin làm chính xác những gì mình cần.

Và biết đâu? Có thể một ngày nào đó Vite sẽ hỗ trợ thứ gì đó như thế này một cách tự nhiên.

(Cập nhật: Sau khi thử plugin trên bản build của Foony, một số người dùng gặp các vấn đề bất ngờ, nên mình đã tắt nó tạm thời. Mình sẽ xem lại sau. Có thể. Mình vẫn nghĩ đây là một giải pháp hay ho.)

8 Ball Pool online multiplayer billiards icon