background blurbackground mobile blur

1/1/1970

Paano Ko Nalutas ang Cascading Hash Changes Gamit ang Import Maps

Howdy! Limang taon na akong may ganitong problema, pero ngayon ko lang tinapatang harapin dahil umabot na sa puntong hindi ko na maaaring balewalain. Pag binago ko ang isang character sa isang file, kalahati ng JavaScript files sa build ko ay magkakaroon ng bagong hashed filenames, kahit hindi naman talaga nagbago ang nilalaman nila. Dahil dito, hindi kailangang nagkakaroon ng cache invalidation, halos imposibleng masubaybayan kung ano talaga ang nagbago sa pagitan ng mga build, at ang pinakamasama: nasisira ang Cloudflare Pages builds ko dahil sa file limit.

Sa baba, isa-isa kong tatalakayin ang problema, kung bakit hindi gumana sa akin ang mga umiiral na solusyon, at kung paano ako gumawa ng custom Vite plugin gamit ang Import Maps para malutas ito nang tuluyan.

Ang Problema: Cascading Hash Changes

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

Ang problema ay kapag ang File A ay nag-import ng File B. Halimbawa, mayroon kang:

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

Pag nagbago ang button.tsx, gagawa ang Vite ng button-def45678.js. Pero ngayon, magbabago rin ang main.js dahil naglalaman ito ng string na "./button-abc12345.js", na mali na. Kaya makakakuha rin ng bagong hash ang main.js, kahit walang nagbago sa aktwal na logic ng main.js.

Kumakalat ito sa buong dependency graph mo. Baguhin mo ang isang utility function, at bigla, kalahati ng js files mo ay makakakuha ng bagong hashes. Sa kaso ko, ang pagbabago ng isang character sa useBackgroundMusic.ts ay nagdulot ng paggawa ng bagong hash sa mahigit 500 files.

Malaki ang epekto nito sa totoong buhay. Nag-bubundle kami ng 8 versions ng assets ng nakaraang build para ang mga users na nasa medyo lumang version ng client namin ay maaari 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, at sinimulan naming abutin ito dahil sa pagbabago sa i18n namin kamakailan na nagpasabog ng dami ng files na ginagawa namin.

Sa paglutas ng cascading hashes, mas marami kaming maitatabing nakaraang builds nang hindi naaabot ang mga limitasyong ito dahil karamihan sa files ay hindi na kailangang magbago. Binabawasan din nito ang posibilidad na mag-error ang isang user sa lumang build, dahil mas malaki ang tsansa na hihilingin nila ang isang file na hindi nagbago at mayroon nga kami.

Bakit Hindi [Mga Alternatibong Solusyon]?

Noong una kong tiningnan kung paano ito lulutasin, ilang approach ang naisip ko. Walang isa man na kasya.

Post-build Scripts

Ang una kong naisip ay magsulat ng post-build script na magno-normalize ng lahat ng import paths, mag-re-hash ng files, at mag-update ng references. Mukhang madali, mag-regex replace lang ng hashed filenames gamit ang stable names, tapos mag-recompute ng hashes.

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

Vite manualChunks

Isa pang opsyon ay ang paggamit ng manualChunks configuration ng Vite para ihiwalay ang stable code (gaya ng node_modules) mula sa unstable code (business logic). Ang ideya ay hindi gaanong nagbabago ang vendor code, kaya kakaunti lang ang files na magka-cascade.

Hindi nito talaga nilulutas ang root problem, pinapagaan lang. Magkakaroon ka pa rin ng cascading hashes sa loob ng business logic chunks mo. Gusto ko ng solusyong tumutugon sa core issue, hindi yung medyo pinapagaan lang.

Import Maps: Ang Modernong Solusyon

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

Ito mismo ang kailangan ko. Ang nilalaman ng File A ay mananatiling pareho (palagi itong nag-i-import ng "button"), kaya ang hash nito ay mananatiling pareho. Tanging ang import map at ang nagbagong file lang ang makakakuha 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. Magta-transform ng lahat ng relative imports para gumamit ng stable module specifiers
  2. Gagawa ng import map na magma-map ng mga specifier na iyon sa aktwal na hashed filenames
  3. Mag-i-inject ng import map sa HTML

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

Unang Approach

Nagsimula ako sa Vite plugin gamit ang generateBundle hook. Sa unang pagtatangka ko, gumamit ako ng regex para hanapin at palitan ang import paths. Madali itong i-code at gumana para sa maliit naming team na Foony, pero marupok at siguradong hindi gagana sa plugin kung saan maaaring magkaroon ng false-positives na ma-mu-mutate.

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

AST Parsing

Kailangan kong maayos na i-parse ang JavaScript code para mahanap ang lahat ng import statements. Ang una kong sinubukan ay ang es-module-lexer, na partikular na ginawa para sa pag-parse ng ES modules. Sa kasamaang palad, nagdulot ito ng native panics sa module analysis phase ng Vite. Kahit subukan ang asm.js build, hindi nito napigilan ang panics.

Pinili ko ang Acorn, isang mabilis, magaan, at puro JavaScript parser. Kasama ang acorn-walk para sa AST traversal, naibigay nito ang 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-iba ang pakikitungo sa kanila sa AST. Kailangan kong mahawakan 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"

Ang re-export case ay partikular na mahirap dahil hindi ko ito napansin hangga't hindi ko nakita ang isang file na hindi nata-transform. May export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" ang code at lubos itong pinapabayaan ng plugin ko dahil naghahanap lang ako ng ImportDeclaration at ImportExpression nodes.

Heto kung paano ko hina-handle silang lahat 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 Conflict Resolution

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

Ang solusyon ko: kung may conflict, hina-hash ko ang orihinal na 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 makakakuha 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, na bumabalik sa chunk.moduleIds[0] kung hindi ito available. Nagbibigay ito sa akin ng stable source path para sa deterministic hashing.

Source Map Chaining

Kapag tina-transform ko ang code, sinisira ko ang source map chain. Ang umiiral na source map ay nagma-map mula sa orihinal na TypeScript source sa pamamagitan ng Babel at minification papunta sa kasalukuyang code. Nagdadagdag ng panibagong layer ang mga transformation ko, kaya kailangan kong mapanatili ang chain na iyon.

Gumagamit ako ng MagicString para subaybayan ang aking mga transformations at gumawa ng bagong source map. Pagkatapos, pinagsasama ko ito sa umiiral na map sa pamamagitan ng pagpapanatili ng orihinal na 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,
};

Re-hashing ng Transformed Content

Kailangan ko ng stable file content. Para magawa ito, tina-transform ko ang imports (pinapalitan ang hashed imports ng Vite ng aking stable imports), at saka inaalis ang source map comments mula sa hash calculation (tumutukoy sila sa lumang filenames).

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

Ang Pinal na Implementasyon

Ang plugin ay gumagamit ng four-pass strategy:

  1. Count pass: Hanapin ang name collisions sa pamamagitan ng pagbibilang kung ilang files ang nagbabahagi ng bawat base name
  2. Map pass: Gumawa ng chunk mapping (hashed filename → module specifier) at unang import map
  3. Transform pass: Muling isulat ang import paths sa code, mag-recompute ng hashes, mag-update ng source maps
  4. Rename pass: I-update ang bundle filenames at i-finalize ang import map

Heto 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 sa halip na regex manipulation:

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

Mas maaasahan ito kaysa sa pagsubok na mag-regex-match ng HTML tags.

Sa mga Numero

Para mabigyan kayo ng ideya kung ano ang ginagawa ng plugin na ito:

  • ~1,000+ JavaScript files na pinoproseso bawat build
  • ~2-3 segundo ang naidagdag sa build time (katanggap-tanggap na trade-off)
  • ~99% pagbabawas sa hindi kinakailangang hash changes (karamihan sa files ay nagbabago lang ngayon kapag nagbago ang aktwal na nilalaman nila)
  • ~340 lines ng plugin code (kasama na ang comments at error handling)

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

Mga Aral na Natutunan

Bakit mahalaga ang AST parsing

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

Bakit Acorn sa halip na es-module-lexer

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

Bakit Import Maps sa halip na mga alternatibo

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

Konklusyon

Matagumpay na napipigilan ng Import Maps plugin ang cascading hash changes sa Vite builds ko. Nakakakuha lang ng bagong hashes ang mga files ngayon kapag nagbago ang aktwal na nilalaman nila, hindi kapag nagbago ang dependencies nila. Mas predictable ang builds dahil dito, nababawasan ang hindi kinakailangang cache invalidation, at tinutulungan kaming manatili sa ilalim ng file limits ng Cloudflare Pages.

Simple, maipagpapatuloy, at gumagamit ng modernong 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. Maaari mo itong i-install gamit ang npm install @foony/vite-plugin-import-map at simulang gamitin sa sarili mong Vite projects.

Maaaring kabilang sa mga improvement sa hinaharap ang pag-optimize gamit ang es-module-lexer kapag nalutas na ang native panic issues, o ang pagdaragdag ng support para sa mas masalimuot na import scenarios. Pero sa ngayon, ginagawa nga ng plugin ang eksaktong kailangan ko.

At sino nakakaalam? Baka balang araw, suportahan na ng Vite ang ganito nang natively.

(Update: Pagkatapos subukan ang plugin sa build ng Foony, may ilang users na nakaranas ng hindi inaasahang issues, kaya kasalukuyan kong dini-disable. Babalikan ko ito mamaya. Marahil. Sa tingin ko pa rin, magandang solusyon ito.)

8 Ball Pool online multiplayer billiards icon