background blurbackground mobile blur

1/1/1970

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

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

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

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

Vite dùng hash dựa trên nội dung cho các bản build production. Khi 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 đổi, nó sẽ thành button-def45678.js. Cách này tuyệt vời cho việc cache busting: người dùng 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
import { Button } from "./button-abc12345.js";

Khi button.tsx đổi, Vite sinh ra button-def45678.js. Nhưng giờ main.js cũng đổi vì nó chứa chuỗi "./button-abc12345.js", giờ đã sai. Thế là main.js cũng có hash mới, dù logic thật trong main.js không hề thay đổi.

Hiệu ứng này lan tỏa khắp đồ thị phụ thuộc của bạn. Đổi một hàm tiện ích, đột nhiên một nửa số file js có hash mới. Trong trường hợp của mình, đổi một ký tự duy nhất trong useBackgroundMusic.ts khiến hơn 500 file phải hash lại.

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

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

Sao Không Dùng [Giải Pháp Thay Thế]?

Khi mới nhìn vào vấn đề này, mình đã cân nhắc vài cách. Không cách nào thực sự phù hợp.

Script Sau Khi 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, hash lại các file, và cập nhật tham chiếu. Nghe có vẻ đơn giản: chỉ cần regex thay tên file có hash bằng tên ổn định, rồi tính lại hash.

Mình từ chối cách này vì lo ngại về "Heisenbugs" và cache poisoning. Dù chúng mình có lưu các bản build cũ trên Cloudflare Pages, rủi ro về cache không nhất quán 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.

Vite manualChunks

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 đổi hơn, nên ít file bị lan tỏa hơn.

Cách này không thực sự giải quyết gốc rễ vấn đề, mà chỉ làm giảm nhẹ. Bạn vẫn bị hash đổi dây chuyền trong các chunk logic nghiệp vụ. Mình muốn một giải pháp giải quyết tận gốc, không phải chỉ làm nó bớt tệ 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 (có hỗ trợ polyfill cho trình duyệt cũ) tách 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 để phân giải "button" thành tên file có hash thực sự.

Đây đúng là cái 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 đã đổi mới có hash mới. Mình hơi bất ngờ là 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 mọi import tương đối sang dùng module specifier ổn định
  2. Tạo một import map ánh xạ các specifier đó sang tên file có 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. Code khá dễ và hoạt động cho team nhỏ của chúng mình ở Foony, nhưng dễ vỡ và chắc chắn không hoạt động trong một plugin nơi có thể có false-positive bị biến đổi.

Cách dùng regex có vấn đề rõ ràng: nếu một chuỗi trong code tình cờ trông giống tên file thì sao? Còn dynamic import thì sao? Còn câu lệnh export thì sao? Mình cần giải pháp chắc chắn 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 parse code JavaScript đúng cách để tìm tất cả câu lệnh import. Lần thử đầu là es-module-lexer, được thiết kế riêng cho việc parse ES module. Tiếc là nó gây panic native trong giai đoạn phân tích module của Vite. Thử cả bản asm.js cũng không ngăn được panic.

Mình chọn Acorn, một parser JavaScript thuần, 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.

Các Thử Thách Chính Đã Giải Quyết

Xử Lý Mọi 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ý:

  • Static import: import x from "./file.js"
  • Dynamic import: import("./file.js")
  • Named re-export: 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ó nhằn vì mình chỉ phát hiện khi thấy một file không được transform. Code có dòng export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" và plugin của mình hoàn toàn bỏ qua vì chỉ tìm node ImportDeclarationImportExpression.

Đây là cách mình xử lý tất cả bây 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 Định Tính

Khi nhiều file có cùng tên cơ bản (như 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ể dùng "index" cho tất 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ơ bản. Ví dụ, src/client/games/chess/index.tsx:index được hash để tạo ra index-abc123. Cách này đảm bảo cùng một file luôn nhận cùng một module specifier qua các bản build, kể cả khi các file khác cùng tên được thêm hoặc xóa.

Mình dùng chunk.facadeModuleId (entry point) làm định danh chính, dự phòng bằng chunk.moduleIds[0] nếu cái kia không có. Cách này cho mình một đường dẫn nguồn ổn định để hash định tính.

Nối Chuỗi Source Map

Khi mình transform code, mình đang làm đứt chuỗi source map. Source map hiện tại ánh xạ từ source TypeScript gốc qua Babel và minify đến code hiện tại. Các transform của mình thêm một lớp nữa, nên cần giữ chuỗi đó nguyên vẹn.

Mình dùng MagicString để theo dõi các transform và sinh ra source map mới. Sau đó merge với map có sẵn bằng cách giữ lại mảng sourcessourcesContent gốc. Cách này duy trì chuỗi đầy đủ: Source gốc → (map có sẵn) → 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: use new map's mappings but preserve original sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Hash Lại Nội Dung Đã Transform

Mình cần nội dung file ổn định. Để làm vậy, mình transform các import (thay import có hash của Vite bằng 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 tên file cũ).

Sau đó, mình tính 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 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ơ bản
  2. Lượt map: Tạo ánh xạ chunk (tên file có hash → module specifier) và import map ban đầu
  3. Lượt transform: 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 bundle và hoàn tất import map

Đây là logic transform cốt lõi:

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

Để chèn import map vào HTML, mình dùng API chèn tag của Vite thay vì thao tác 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 cố regex match các thẻ HTML.

Bằng Những Con Số

Để bạn hình dung plugin này làm gì:

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

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

Bài Học Rút Ra

Vì sao phân tích AST là thiết yếu

Dùng regex trên code đã bundle rất 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ỉ transform các câu lệnh import/export thực sự.

Vì sao chọn Acorn thay vì es-module-lexer

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

Vì 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 hỗ trợ trình duyệt native. Đây là cách "đúng" để giải quyết vấn đề này. Polyfill (es-module-shims) xử lý trình duyệt cũ (ví dụ Safari < 16.4) một cách mượt mà, và giải pháp gọn gàng, dễ bảo trì.

Kết Luận

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

Giải pháp đơn giản, dễ bảo trì, và dùng các chuẩn web hiện đại. Đây là một ví dụ điển hình về việc đôi khi giải pháp "đúng" 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 ra 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 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 mình.

Cải tiến tương lai có thể bao gồm tối ưu bằng 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 bây giờ, plugin làm đúng những gì mình cần.

Và biết đâu đấy? Một ngày nào đó Vite có thể hỗ trợ thứ như vậy ngay từ trong nhâ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 vấn đề bất ngờ, nên mình đã tắt nó tạm thời. Mình sẽ xem lại sau. Chắc vậy. Mình vẫn nghĩ đây là một giải pháp hay.)

8 Ball Pool online multiplayer billiards icon