1/7/2026

Paano Ko Naayos ang Cascading Hash Changes Gamit ang Import Maps

Howdy! Mahigit 5 taon ko nang problema ito, pero ngayon ko lang tinapatan dahil umabot na sa puntong hindi ko na talaga maaaring balewalain. Kapag binago ko ang isang character sa isang file, kalahati ng JavaScript files sa build ko ay nagkakaroon ng bagong hashed na filename, kahit hindi naman talaga nagbago ang aktwal na nilalaman. Nagdudulot ito ng hindi kinakailangang cache invalidation, halos imposibleng masubaybayan kung ano talaga ang nagbago sa bawat build, at pinakamasama: nasisira ang Cloudflare Pages builds ko dahil sa file limit.

Sa baba, ipapaliwanag ko ang problema, kung bakit hindi gumana sa akin ang mga existing solutions, at kung paano ako gumawa ng custom Vite plugin gamit ang Import Maps para malutas ito minsanan.

Ang Problema: Cascading Hash Changes

Gumagamit ang Vite ng content-based hashing para sa production builds. Kapag nag-build ka ng app mo, bawat JavaScript file ay binibigyan ng hash sa filename batay sa nilalaman nito. Kung ang button.tsx ay nagiging button-abc12345.js, at nagbago ang nilalaman, magiging button-def45678.js ito. Maganda ito para sa cache busting, kasi nakukuha ng mga user ang bagong file kapag nagbago ito.

Dumarating ang problema kapag ang File A ay nag-iimport ng File B. Sabihin natin meron kang:

// main.js

Kapag nagbago ang button.tsx, gumagawa ang Vite ng button-def45678.js. Pero ngayon, nagbago rin ang main.js dahil naglalaman ito ng string na "./button-abc12345.js", na mali na. Kaya nakakakuha rin ng bagong hash ang main.js, kahit hindi naman talaga nagbago ang aktwal na logic doon.

Kumakalat ito sa buong dependency graph mo. Baguhin mo ang isang utility function, at bigla na lang ang kalahati ng js files mo ay may bagong hashes. Sa kaso ko, ang pagbabago ng isang character sa useBackgroundMusic.ts ay nagdulot ng higit sa 500 files na na-rehash.

Malaki ang epekto sa real world. Nagba-bundle kami ng 8 versions ng past build assets namin para ang mga user na nasa medyo lumang version pa ng client namin ay maipatakbo pa rin ang version nila kapag nag-deploy kami ng bagong version sa Cloudflare Pages. Pero may 20,000 file limit ang Cloudflare Pages na nagsimula kaming tamaan dahil sa pagbabago namin sa i18n nung nakaraan na nagpasabog sa bilang ng files na ginagawa namin.

Sa paglutas ng cascading hashes, mas marami kaming maitatabing past builds nang hindi tumatama sa mga limits dahil ngayon, karamihan ng files ay hindi na kailangang magbago. Nababawasan din nito ang posibilidad na ma-error ang user na nasa lumang build, dahil mas malamang na hihingi sila ng file na hindi nagbago at meron pa rin kami.

Bakit Hindi Ginamit ang [Mga Alternatibong Solusyon]?

Nung una kong tiningnan ang paglutas nito, may ilang approach akong inisip. Walang nag-fit nang maayos.

Post-build Scripts

Ang unang naisip ko ay magsulat ng post-build script na magno-normalize ng lahat ng import paths, magre-rehash ng files, at mag-uupdate ng references. Mukhang simple lang, regex replace lang ng hashed filenames gamit ang stable names, tapos i-recompute ang hashes.

Tinanggihan ko ang approach na ito dahil sa "Heisenbugs" at cache poisoning concerns. Kahit nag-iimbak kami ng past builds sa Cloudflare Pages, hindi sulit ang risk ng cache inconsistencies. Ang script na nagmo-modify ng files pagkatapos ng build ay maaaring magdulot ng subtle bugs na lalabas lang sa production, at bangungot ang pag-debug niyon.

Vite manualChunks

Isa pang option ay gamitin ang manualChunks configuration ng Vite para paghiwalayin ang stable code (gaya ng node_modules) sa unstable code (business logic). Ang ideya ay mas madalang magbago ang vendor code, kaya mas kaunti ang files na magka-cascade.

Hindi naman talaga nito nilulutas ang root problem, pinapagaan lang. Nagkakaroon ka pa rin ng cascading hashes sa loob ng business logic chunks mo. Gusto ko ng solusyon na sumasagot sa core issue, hindi lang nagpapaganda nang konti.

Import Maps: Ang Modern Solution

Ang Import Maps ay browser-native feature (na may polyfill support para sa mga lumang browser) na naghihiwalay sa module specifiers mula sa file paths. Sa halip na ang File A ay mag-import ng "./button-abc123.js", mag-iimport ito ng "button". Ginagamit ng browser ang import map para i-resolve ang "button" papunta sa aktwal na hashed filename.

Eto mismo ang kailangan ko. Pareho lang ang nilalaman ng File A (palaging nag-iimport ito ng "button"), kaya pareho rin ang hash nito. Ang import map at ang nagbagong file lang ang nakakakuha ng bagong hashes. Medyo nagulat ako na walang gumawa ng magandang plugin para dito!

Paggawa ng Vite Plugin

Nagdesisyon akong gumawa ng Vite plugin na:

  1. Magbabago ng lahat ng relative imports gamit ang stable module specifiers
  2. Gagawa ng import map na magma-map ng mga specifier na iyon papunta sa aktwal na hashed filenames
  3. Mag-i-inject ng import map sa HTML

Available na ang plugin sa GitHub: @foony/vite-plugin-import-map

Unang Approach

Nagsimula ako sa Vite plugin gamit ang generateBundle hook. Ang unang pagsubok ko ay gumamit ng regex para hanapin at palitan ang import paths. Madaling i-code ito at gumana sa maliit naming team na Foony, pero marupok at siguradong hindi gagana sa plugin kung saan maaaring may false-positives na magmu-mutate.

May halatang problema ang regex approach: paano kung ang isang string sa code ay parang filename? Paano ang dynamic imports? Paano ang export statements? Kailangan ko ng mas matibay na solusyon kung gagawa ako ng plugin para sa iba.

AST Parsing

Kailangan kong i-parse nang maayos ang JavaScript code para mahanap ang lahat ng import statements. Ang unang pagsubok ko ay es-module-lexer, na espesipikong dinisenyo para sa pag-parse ng ES modules. Sa kasamaang palad, nagdulot ito ng native panics sa module analysis phase ng Vite. Kahit ang asm.js build ay hindi nakatulong sa pagpigil ng panics.

Pumili ako ng Acorn, isang mabilis, magaan, at puro JavaScript parser. Pinagsama sa acorn-walk para sa AST traversal, binigyan ako nito ng lahat ng kailangan ko nang walang native dependency issues.

Mga Pangunahing Hamon na Nalutas

Pag-handle ng Lahat ng Uri ng Import

Maraming anyo ang imports, at iba ang trato sa kanila sa AST. Kailangan kong i-handle ang:

  • Static imports: import x from "./file.js"
  • Dynamic imports: import("./file.js")
  • Named re-exports: export { x } from "./file.js" (na-miss ko ito sa una!)
  • Re-export all: export * from "./file.js"

Lalong nakatricky ang re-export case dahil hindi ko ito napansin hanggang sa makakita ako ng file na hindi nagba-transform. Ang code ay may export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" at ganap na binabalewala ito ng plugin ko dahil naghahanap lang ako ng ImportDeclaration at ImportExpression nodes.

Ganito ko ngayon hina-handle silang lahat:

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

Kapag maraming files ang may parehong base name (gaya ng maraming index.tsx files sa magkakaibang directory), kailangan kong i-disambiguate sila. Hindi pwedeng "index" lang ang gamitin para sa lahat.

Ang solusyon ko: kung may conflict, hina-hash ko ang original source path kasama ang base name. Halimbawa, ang src/client/games/chess/index.tsx:index ay hina-hash para gumawa ng index-abc123. Tinitiyak nito na ang parehong file ay laging nakakakuha ng parehong module specifier sa lahat ng builds, kahit may iba pang files na may parehong pangalan na idinagdag o tinanggal.

Ginagamit ko ang chunk.facadeModuleId (ang entry point) bilang primary identifier, at babagsak sa chunk.moduleIds[0] kung wala iyon. Binibigyan ako nito ng stable source path para sa deterministic hashing.

Source Map Chaining

Kapag tina-transform ko ang code, sinisira ko ang source map chain. Ang existing source map ay nagma-map mula sa original TypeScript source dumaan sa Babel at minification papunta sa kasalukuyang code. Ang mga transformations ko ay nagdadagdag ng isa pang layer, kaya kailangan kong panatilihin ang chain na iyon.

Ginagamit ko ang MagicString para subaybayan ang mga transformations ko at gumawa ng bagong source map. Tapos ini-merge ko ito sa existing map sa pamamagitan ng pagpapanatili ng original sources at sourcesContent arrays. Pinapanatili nito ang buong 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,
};

Pag-rehash ng Transformed Content

Kailangan ko ng stable file content. Para gawin ito, tina-transform ko ang imports (pinapalitan ang hashed imports ng Vite ng aking stable imports), tapos tinatanggal ko ang source map comments mula sa hash calculation (nag-re-reference sila sa lumang filenames).

Pagkatapos niyon, kino-compute ko ang bagong hash, at ina-update ang filename at ang import map entry.

Ang Pinal na Implementation

Gumagamit ang plugin ng four-pass strategy:

  1. Count pass: I-detect ang name collisions sa pamamagitan ng pagbibilang kung ilang files ang may parehong base name
  2. Map pass: Gumawa ng chunk mapping (hashed filename → module specifier) at initial import map
  3. Transform pass: Sulatin muli ang import paths sa code, i-recompute ang hashes, i-update ang source maps
  4. Rename pass: I-update ang bundle filenames at i-finalize ang import map

Eto ang core transformation logic:


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

Para sa pag-inject ng import map sa HTML, ginagamit ko ang tag injection API ng Vite sa halip na regex manipulation:

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

Mas reliable ito kaysa sa pagsusubok mag-regex-match ng HTML tags.

Sa Mga Numero

Para magkaroon ka ng ideya kung ano ang ginagawa ng plugin na ito:

  • ~1,000+ JavaScript files ang pino-process bawat build
  • ~2-3 seconds ang nadagdag sa build time (acceptable trade-off)
  • ~99% reduction sa hindi kinakailangang hash changes (karamihan ng files ay nagbabago na lang kapag nagbabago talaga ang aktwal nilang nilalaman)
  • ~340 lines ng plugin code (kasama ang comments at error handling)

Hina-handle ng plugin ang lahat ng edge cases na nakatagpo ko hanggang ngayon, at mas predictable na ang build process.

Mga Natutunan

Bakit mahalaga ang AST parsing

Mapanganib ang regex sa bundled code. Kung ang isang string sa code mo ay nagkataong parang filename, susulatan ito ng regex. Tinitiyak ng AST parsing na tina-transform mo lang ang aktwal na import/export statements.

Bakit Acorn kaysa es-module-lexer

Mas mabilis at mas purpose-built ang es-module-lexer, pero hindi ito magamit sa Vite plugin context ko dahil sa native panic issues. Puro JavaScript ang Acorn, na ibig sabihin walang native dependencies na alalahanin. Gusto kong tingnan ang es-module-lexer sa hinaharap bilang speed optimization, pero sa ngayon, perpektong gumagana ang Acorn.

Bakit Import Maps kaysa sa mga alternatibo

Ang Import Maps ay web standard na may native browser support. Ito ang "tamang" paraan para lutasin ang problemang ito. Maayos na hina-handle ng polyfill (es-module-shims) ang mga lumang browsers (hal. Safari < 16.4), at malinis at maintainable ang solusyon.

Konklusyon

Matagumpay na napipigilan ng Import Maps plugin ang cascading hash changes sa Vite builds ko. Ang mga files ay nakakakuha lang ng bagong hashes kapag nagbabago talaga ang aktwal nilang nilalaman, hindi kapag nagbago ang dependencies nila. Mas predictable ngayon ang builds, nababawasan ang hindi kinakailangang cache invalidation, at nakakatulong itong manatili kami sa ilalim ng file limits ng Cloudflare Pages.

Simple, maintainable, at gumagamit ng modern web standards ang solusyon. Magandang halimbawa ito kung paano minsan ang "tamang" solusyon ay siya ring pinakasimple, kapag naintindihan mo na nang sapat ang problema para makita ito.

Open source ang plugin at available sa GitHub: @foony/vite-plugin-import-map. Pwede mo itong i-install gamit ang npm install @foony/vite-plugin-import-map at simulan itong gamitin sa sarili mong Vite projects.

Maaaring kabilang sa mga improvements sa hinaharap ang pag-optimize gamit ang es-module-lexer kapag naayos na ang native panic issues, o pagdagdag ng support para sa mas kumplikadong import scenarios. Pero sa ngayon, ginagawa ng plugin nang eksakto kung ano ang kailangan ko.

At sino ang nakakaalam? Baka balang araw, mag-support na ang Vite ng ganito nang native.

(Update: Pagkatapos subukan ang plugin sa build ng Foony, may ilang user na nagkakaroon ng hindi inaasahang issues, kaya pansamantala kong dini-disable ito. Babalikan ko ulit. Siguro. Inaakala ko pa ring magandang solusyon ito.)

8 Ball Pool online multiplayer billiards icon