background blurbackground mobile blur

1/1/1970

Paano Ko Naresolba ang Cascading Hash Changes gamit ang Import Maps

Kumusta! Matagal ko nang may ganitong problema, mga higit 5 taon na, pero ngayon ko lang talaga hinarap dahil umabot na sa puntong hindi ko na kayang isnabin. Kapag binago ko ang isang character sa isang file, kalahati ng mga JavaScript file sa build ko ay nagkakaroon ng bagong hashed filenames kahit hindi naman talaga nagbago ang laman nila. Nagdudulot ito ng sobrang daming hindi kailangang cache invalidation, halos imposibleng matukoy kung ano talaga ang nagbago sa pagitan ng mga build, at ang pinakamasama: nababasag ang mga build ko sa Cloudflare Pages dahil sa file limit.

Sa ibaba, babalikan ko ang problema, kung bakit hindi gumana para sa akin ang mga existing na solusyon, at kung paano ako gumawa ng custom na Vite plugin gamit ang Import Maps para tuluyan na itong ayusin.

Ang Problema: Cascading Hash Changes

Gumagamit ang Vite ng content-based hashing para sa production builds. Kapag binuild mo ang app mo, bawat JavaScript file ay may hash sa filename base sa laman nito. Kung ang button.tsx ay nag-compile bilang button-abc12345.js, at nagbago ang content, magiging button-def45678.js. Maganda ito para sa cache busting dahil siguradong makukuha ng users ang bagong file kapag nagbago ito.

Nagkakaproblema lang kapag si File A ay nag-i-import kay File B. Sabihin nating meron kang:

// main.js
import { Button } from "./button-abc12345.js";

Kapag nagbago ang button.tsx, gagawa si Vite ng button-def45678.js. Pero ngayon, nagbago na rin ang main.js dahil kasama sa laman nito ang string na "./button-abc12345.js", na mali na ngayon. Kaya magkakaroon din ng bagong hash si main.js, kahit na wala talagang nabago sa logic sa loob ng main.js.

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

Malaki ang epekto nito sa totoong mundo. Nagtatabi kami ng 8 na bersyon ng assets ng mga nakaraang build para ang mga user na nasa medyo luma pang version ng client ay puwede pa ring patakbuhin ang version nila kapag nag-deploy kami ng bagong version sa Cloudflare Pages. Pero may 20,000 file limit ang Cloudflare Pages na nasimulan na naming tamaan dahil sa i18n change namin dati na sobrang nagparami ng mga file na ginagawa namin.

Sa pagresolba ng cascading hashes, kaya na naming mag-imbak ng mas maraming past builds nang hindi sumasagad sa limit na ito dahil karamihan ng files ay hindi na kailangang magbago. Mababa na rin ang tsansang mag-error ang isang user na nasa luma pang build dahil mas malamang na ang hinihingi niyang file ay hindi na nagbago at meron pa kami nito.

Bakit Hindi [Alternative Solutions]?

Nung una kong tinignan kung paano aayusin ito, nag-isip ako ng ilang paraan. Wala lang talagang tumama nang maayos.

Post-build Scripts

Una kong naisip na gumawa ng post-build script na magma-"normalize" ng lahat ng import paths, iha-hash ulit ang mga file, at ia-update ang references. Mukhang diretso lang ito sa unang tingin: mag-regex lang para palitan ang hashed filenames ng stable names, tapos i-recompute ang hashes.

Tinanggihan ko ang approach na ito dahil sa mga "Heisenbugs" at risk ng cache poisoning. Kahit nag-iimbak kami ng mga past builds sa Cloudflare Pages, hindi sulit ang panganib ng cache inconsistencies. Ang script na nagmo-modify ng files pagkatapos ng build ay puwedeng magpasok ng mga subtle na bug na lumalabas lang sa production, at sobrang sakit ng ulo mag-debug nun.

Vite manualChunks

Isa pang opsyon ang paggamit ng manualChunks configuration ng Vite para paghiwalayin ang stable code (tulad ng node_modules) sa unstable code (business logic). Ang ideya, mas madalang magbago ang vendor code kaya mas kaunti ang mga file na magkaka-cascade.

Pero hindi nito sinosolusyunan ang ugat ng problema, pinapahina lang nito nang kaunti. Magkakaroon ka pa rin ng cascading hashes sa loob ng mga business logic chunks mo. Gusto ko ng solusyong tumatama mismo sa core issue, hindi lang yung pampaluwag ng kaunti.

Import Maps: Ang Modernong Solusyon

Ang Import Maps ay browser-native feature (na may polyfill support para sa mas matatandang browser) na naghihiwalay sa module specifiers at file paths. Sa halip na mag-import si File A ng "./button-abc123.js", mag-i-import siya ng "button". Gagamitin ng browser ang import map para i-resolve ang "button" papunta sa totoong hashed filename.

Ito mismo ang kailangan ko. Pareho ang laman ni File A (lagi lang siyang nag-i-import ng "button"), kaya nananatili ang hash niya. Ang nagkakaroon lang ng bagong hashes ay ang import map at ang mismong nagbago na file. Medyo nagulat ako na wala pang gumawa ng matinong plugin para dito!

Ang Paglalakbay ng Implementation

Nagdesisyon akong gumawa ng Vite plugin na:

  1. Magta-transform ng lahat ng relative imports para gumamit ng stable na module specifiers
  2. Bubuo ng import map na nagma-map sa mga specifier papunta sa totoong 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. Sa unang subok, gumamit ako ng regex para hanapin at palitan ang import paths. Madaling ikod ito at gumana para sa maliit naming team sa Foony, pero brittle siya at hindi talaga bagay sa plugin kung saan puwedeng may mga false positives na mamumutate.

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

AST Parsing

Kailangan kong i-parse nang maayos ang JavaScript code para mahanap lahat ng import statements. Una kong sinubukan ang es-module-lexer, na gawa talaga para sa pag-parse ng ES modules. Kaso, nagdudulot ito ng native panics habang tumatakbo ang module analysis phase ng Vite. Kahit yung asm.js build sinubukan ko, hindi pa rin nawala ang panics.

Kaya napunta ako sa Acorn, isang mabilis, magaan, pure JavaScript parser. Pinares ko ito sa acorn-walk para sa AST traversal, at nakuha ko lahat ng kailangan ko nang walang problema sa native dependencies.

Mga Pangunahing Hamon na Naresolba

Paghawak sa Lahat ng Uri ng Import

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

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

Medyo tricky lalo na yung re-export case kasi hindi ko agad napansin hanggang sa may isang file na hindi natatransform. Meron itong export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" at tuluyang binabalewala ng plugin ko dahil nakatingin lang ako sa ImportDeclaration at ImportExpression nodes.

Ganito ko na sila hinahandle ngayon:

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 na Pag-resolve ng Conflict

Kapag maraming files ang may parehong base name (halimbawa, maraming index.tsx sa iba-ibang folder), kailangan ko silang pag-ibahin. Hindi puwedeng lahat ay "index" lang.

Ang solusyon ko: kapag may conflict, hinahash ko ang original source path kasama ang base name. Halimbawa, ang src/client/games/chess/index.tsx:index ay iha-hash para maging index-abc123. Nakakatiyak ito na ang parehong file ay palaging magkakaroon ng parehong module specifier sa lahat ng build, kahit may maidagdag o mabawas na ibang file na may kaparehong pangalan.

Ginagamit ko ang chunk.facadeModuleId (ang entry point) bilang primary identifier, at nagfa-fallback sa chunk.moduleIds[0] kung wala ito. Nagbibigay ito ng stable na source path para sa deterministic hashing.

Source Map Chaining

Kapag tinatransform ko ang code, napuputol ko ang source map chain. Ang existing na source map ay nagma-map mula sa original na TypeScript source, dadaan sa Babel at minification, papunta sa kasalukuyang code. Dahil nagdadagdag ako ng panibagong layer ng transformations, kailangan kong panatilihin ang chain na iyon.

Gumagamit ako ng MagicString para sundan ang mga transformation at gumawa ng bagong source map. Pagkatapos, imi-merge ko ito sa existing map sa pamamagitan ng pag-preserve ng original na sources at sourcesContent arrays. Sa ganitong paraan, buo pa rin ang 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: gamitin ang mappings ng bagong map pero i-preserve ang original sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Pagre-rehash ng Na-transform na Content

Kailangan ko ng stable na laman ng file. Para magawa ito, tina-transform ko muna ang imports (pinapalitan ang hashed imports ng Vite ng stable imports ko), tapos tinatanggal ko ang source map comments mula sa hash calculation (dahil nagre-refer pa sila sa lumang filenames).

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

Ang Final na Implementation

Gumagamit ang plugin ng four-pass strategy:

  1. Count pass: Hinahanap ang name collisions sa pamamagitan ng pagbilang kung ilang files ang may parehong base name
  2. Map pass: Gumagawa ng chunk mapping (hashed filename → module specifier) at initial import map
  3. Transform pass: Ina-rewrite ang import paths sa code, nire-recompute ang hashes, ina-update ang source maps
  4. Rename pass: Ina-update ang bundle filenames at fina-finalize ang import map

Ito ang core transformation logic:

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

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

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

Mas maaasahan ito kaysa mag-regex sa HTML tags.

Sa Mga Numero

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

  • ~1,000+ JavaScript files ang napo-process kada build
  • ~2-3 segundo ang nadadagdag sa build time (okay na trade-off)
  • ~99% na bawas sa hindi kailangang hash changes (kadalasan, nagbabago na lang ang file kapag talagang nagbago ang laman nito)
  • ~340 linya ng plugin code (kasama ang comments at error handling)

Na-ha-handle ng plugin ang lahat ng edge cases na na-encounter ko sa ngayon, at mas predictable na ang buong build process.

Mga Natutunan

Bakit mahalaga ang AST parsing

Delikado ang regex sa bundled code. Kung may string sa code mo na mukhang filename, babaguhin ito ng regex. Sa AST parsing, siguradong actual import/export statements lang ang iti-transform mo.

Bakit Acorn imbes na es-module-lexer

Mas mabilis at mas purpose-built ang es-module-lexer, pero dahil sa native panic issues, hindi ko ito magamit sa Vite plugin context ko. Pure JavaScript ang Acorn, kaya wala kang aalalahaning native dependencies. Sa future, gusto kong balikan ang es-module-lexer bilang speed optimization, pero sa ngayon, maayos na maayos gumagana ang Acorn.

Bakit Import Maps kumpara sa iba

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

Konklusyon

Epektibong pinipigilan ng Import Maps plugin ang cascading hash changes sa Vite builds ko. Nagkakaroon na lang ng bagong hashes ang mga file kapag talagang nagbago ang laman nila, hindi kapag nagbago lang ang dependencies. Mas predictable ang builds, mas kaunti ang hindi kailangang cache invalidation, at mas madali kaming manatili sa loob ng file limits ng Cloudflare Pages.

Simple, maintainable, at gumagamit ng modern web standards ang solusyon. Magandang halimbawa ito na minsan, ang "tamang" solusyon ay siya ring pinakamadali, kapag sapat na kalalim ang pagkakaintindi mo sa problema para makita ito.

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

Sa future, puwedeng idagdag ang optimizations gamit ang es-module-lexer kapag naresolba na ang native panic issues, o magdagdag ng support para sa mas kumplikadong import scenarios. Pero sa ngayon, eksaktong ginagawa ng plugin ang kailangan ko.

At sino ang nakakaalam? Baka balang araw, suportado na mismo ni Vite ang ganitong feature.

8 Ball Pool online multiplayer billiards icon