background blurbackground mobile blur

1/1/1970

چطور مشکل عوض شدن آبشاری هش‌ها را با Import Mapها حل کردم

سلام! بیش از ۵ سال بود که این مشکل را داشتم، ولی تازه الان تصمیم گرفتم سراغش بروم، چون به جایی رسیده بود که دیگر نمی‌شد نادیده‌اش گرفت. کافی بود توی یک فایل فقط یک کاراکتر عوض کنم، نصف فایل‌های JavaScript توی بیلد، اسم‌های هش‌شده‌ی جدید می‌گرفتند، در حالی که محتوای واقعی‌شان اصلاً عوض نشده بود. این هم کش را بی‌خودی باطل می‌کرد، هم تقریباً غیرممکن می‌شد بفهمم دقیقاً بین دو بیلد چه چیزی عوض شده، و از همه بدتر این‌که به خاطر محدودیت تعداد فایل، بیلدهای Cloudflare Pages را هم می‌ترکاند.

پایین‌تر مشکل را ریز می‌کنم، می‌گویم چرا راه‌حل‌های آماده برای من جواب ندادند و چطور یک پلاگین سفارشی Vite با استفاده از Import Mapها نوشتم که این ماجرا را یک‌بار برای همیشه حل کند.

مشکل: عوض شدن آبشاری هش‌ها

Vite برای بیلدهای پروداکشن از هش بر اساس محتوا استفاده می‌کند. وقتی اپ را بیلد می‌کنی، هر فایل JavaScript بر اساس محتوایش یک هش در اسم فایل می‌گیرد. اگر button.tsx تبدیل شود به button-abc12345.js و بعد محتوایش عوض شود، اسمش می‌شود button-def45678.js. این برای پاک شدن کش و گرفتن نسخه‌ی جدید خیلی عالی است، چون هر وقت فایل عوض شود، کاربر همان فایل جدید را می‌گیرد.

مشکل از جایی شروع می‌شود که فایل A فایل B را import می‌کند. فرض کن چنین چیزی داری:

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

وقتی button.tsx عوض می‌شود، Vite فایل button-def45678.js را می‌سازد. اما حالا main.js هم عوض می‌شود، چون توی خودش رشته‌ی "./button-abc12345.js" را دارد که دیگر درست نیست. پس main.js هم یک هش جدید می‌گیرد، در حالی که منطق واقعی داخل main.js اصلاً عوض نشده.

این تغییر همین‌طور توی کل نمودار وابستگی‌ات آبشاروار پخش می‌شود. یک فانکشن کمکی را عوض می‌کنی و یکهو نصف فایل‌های js هش جدید می‌گیرند. توی پروژه‌ی من، عوض کردن فقط یک کاراکتر داخل useBackgroundMusic.ts باعث شد بیش از ۵۰۰ تا فایل دوباره هش بخورند.

تأثیرش در دنیای واقعی هم کم نبود. ما ۸ نسخه از دارایی‌های بیلدهای قبلی‌مان را هم داخل بیلد جدید می‌گذاریم تا کاربرهایی که هنوز روی نسخه‌های کمی قدیمی‌تر کلاینت هستند، وقتی نسخه‌ی جدید را روی Cloudflare Pages دیپلوی می‌کنیم، همچنان بتوانند نسخه‌ی خودشان را اجرا کنند. اما Cloudflare Pages محدودیت ۲۰٬۰۰۰ فایل دارد و ما به خاطر تغییر i18n که قبلاً انجام دادیم که تعداد فایل‌هایی را که می‌سازیم ترکاند، کم‌کم به این سقف خوردیم.

حل کردن مشکل هش‌های آبشاری باعث شد بتوانیم تعداد خیلی بیشتری از بیلدهای قدیمی را بدون خوردن به این محدودیت‌ها نگه داریم، چون حالا بیشتر فایل‌ها دیگر لازم نیست عوض شوند. این شانس خطا دادن کاربرهایی که روی بیلد قدیمی هستند را هم کم می‌کند، چون احتمال این‌که فایلی را بخواهند که در نسخه‌های جدید هم عوض نشده و هنوز داریمش، خیلی بیشتر است.

چرا نه [راه‌حل‌های دیگر]؟

وقتی اول سراغ حل این مشکل رفتم، چند تا رویکرد مختلف را بررسی کردم. هیچ‌کدامشان دقیقاً به درد من نمی‌خورد.

اسکریپت‌های پس از بیلد

اولین فکری که کردم این بود که یک اسکریپت بعد از بیلد بنویسم که همه‌ی مسیرهای import را نرمال کند، دوباره روی فایل‌ها هش بزند و رفرنس‌ها را آپدیت کند. روی کاغذ خیلی سرراست به نظر می‌رسید؛ فقط کافی بود اسم فایل‌های هش‌شده را با اسم‌های ثابت با regex عوض کنم و بعد هش‌ها را دوباره حساب کنم.

این رویکرد را به خاطر نگرانی از «Heisenbug»ها و خراب شدن کش کنار گذاشتم. حتی با این‌که بیلدهای قبلی را روی Cloudflare Pages نگه می‌داریم، ریسک ناهماهنگی کش اصلاً نمی‌ارزید. یک اسکریپت که بعد از بیلد برود فایل‌ها را دستکاری کند، می‌تواند باگ‌های ریزی بسازد که فقط توی پروداکشن خودشان را نشان می‌دهند و دیباگ کردنشان کابوس است.

Vite manualChunks

گزینه‌ی بعدی این بود که از تنظیمات manualChunks توی Vite استفاده کنم تا کد پایدار (مثلاً node_modules) را از کد ناپایدار (منطق بیزینسی) جدا کنم. ایده این بود که کد vendor کمتر عوض می‌شود، پس تعداد فایل‌هایی که آبشاروار هش عوض می‌کنند هم کمتر می‌شود.

اما این در اصل مشکل ریشه‌ای را حل نمی‌کند، فقط کمی از شدت آن کم می‌کند. هنوز هم داخل چانک‌های منطق بیزینسی، همان داستان هش‌های آبشاری را داری. من دنبال راه‌حلی بودم که خودِ مسئله را حل کند، نه این‌که فقط کمی قابل‌تحمل‌ترش کند.

Import Mapها: راه‌حل مدرن

Import Mapها یک قابلیت بومی مرورگر هستند (با polyfill برای مرورگرهای قدیمی‌تر) که نام ماژول‌ها را از مسیر فایل‌ها جدا می‌کنند. به‌جای این‌که فایل A مستقیماً "./button-abc123.js" را import کند، "button" را import می‌کند. مرورگر با استفاده از import map این "button" را به اسم واقعی فایل هش‌شده نگاشت می‌کند.

دقیقاً چیزی بود که لازم داشتم. محتوای فایل A یکسان می‌ماند (همیشه "button" را import می‌کند)، پس هشش هم ثابت می‌ماند. فقط خودِ import map و فایلی که عوض شده، هش جدید می‌گیرند. واقعاً تعجب کرده بودم که کسی تا حالا یک پلاگین درست‌وحسابی برای این کار نساخته بود!

مسیر پیاده‌سازی

تصمیم گرفتم یک پلاگین Vite بسازم که این کارها را بکند:

  1. همه‌ی importهای نسبی را طوری تبدیل کند که از نام‌های پایدار برای ماژول‌ها استفاده کنند
  2. یک import map بسازد که این نام‌ها را به اسم واقعی فایل‌های هش‌شده نگاشت کند
  3. آن import map را داخل HTML تزریق کند

این پلاگین الان روی GitHub در دسترس است: @foony/vite-plugin-import-map

رویکرد اولیه

اول با یک پلاگین Vite شروع کردم که از هوک generateBundle استفاده می‌کرد. تلاش اولم این بود که با regex مسیرهای import را پیدا و جایگزین کنم. پیاده‌سازی‌اش ساده بود و برای تیم کوچک‌مان در Foony هم جواب می‌داد، ولی خیلی شکننده بود و مطمئناً برای یک پلاگین عمومی که ممکن است کلی false-positive عجیب غریب داشته باشد، مناسب نبود.

روش regex مشکلات واضحی داشت: اگر یک رشته‌ی معمولی توی کد، ظاهراً شبیه اسم فایل بود چه؟ importهای داینامیک چه می‌شدند؟ exportها چه؟ اگر قرار بود پلاگینی بسازم که بقیه هم از آن استفاده کنند، یک راه‌حل خیلی محکم‌تر لازم داشتم.

پارس کردن AST

باید کد JavaScript را درست‌وحسابی پارس می‌کردم تا همه‌ی دستورهای import را پیدا کنم. انتخاب اولم es-module-lexer بود که مشخصاً برای پارس کردن ماژول‌های ES ساخته شده. متأسفانه وسط فاز تحلیل ماژول‌ها در Vite باعث panicهای native می‌شد. حتی نسخه‌ی asm.js آن هم کمکی نکرد که این panicها متوقف شوند.

در نهایت رفتم سراغ Acorn, یک پارسر سریع، سبک و کاملاً JavaScript. وقتی با acorn-walk برای پیمایش AST ترکیبش کردم، بدون این‌که درگیر وابستگی‌های native شوم، دقیقاً همه‌چیزهایی را که لازم داشتم به من داد.

چالش‌های مهمی که حل شدند

هندل کردن همه‌ی انواع import

importها شکل‌های مختلفی دارند و توی AST هم هرکدام یک جور هندل می‌شوند. باید این موارد را پوشش می‌دادم:

  • importهای استاتیک: import x from "./file.js"
  • importهای داینامیک: import("./file.js")
  • re-exportهای نام‌دار: export { x } from "./file.js" (اولش این یکی را جا انداخته بودم!)
  • re-export همه‌چیز: export * from "./file.js"

ماجرای re-exportها مخصوصاً tricky بود، چون تا وقتی به فایلی نخوردم که تبدیل نمی‌شد، اصلاً حواسم به آن نبود. توی کد این خط را داشتیم: export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" و پلاگین من کاملاً نادیده‌اش می‌گرفت، چون فقط دنبال نودهای ImportDeclaration و ImportExpression بودم.

الان همه‌شان را این‌طوری هندل می‌کنم:

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

حل تداخل‌ها به‌صورت قطعی و قابل پیش‌بینی

وقتی چند تا فایل یک اسم پایه‌ی یکسان دارند (مثلاً چندین index.tsx در دایرکتوری‌های مختلف)، باید بتوانم آن‌ها را از هم متمایز کنم. نمی‌توانم برای همه‌شان فقط از "index" استفاده کنم.

راه‌حل من این است که اگر تداخل پیش آمد، مسیر کامل سورس به‌علاوه‌ی اسم پایه را هش می‌کنم. مثلاً src/client/games/chess/index.tsx:index هش می‌شود و از دلش index-abc123 درمی‌آید. این کار تضمین می‌کند که یک فایل مشخص همیشه در همه‌ی بیلدها همان نام ماژولِ ثابت خودش را داشته باشد، حتی اگر فایل‌های دیگری با همان اسم اضافه یا کم شوند.

من از chunk.facadeModuleId (ورودی اصلی) به‌عنوان شناسه‌ی اصلی استفاده می‌کنم و اگر در دسترس نبود، می‌روم سراغ chunk.moduleIds[0]. این طوری یک مسیر سورس ثابت برای هش کردن قطعی در اختیار دارم.

زنجیره کردن Source Mapها

وقتی کد را ترنسفورم می‌کنم، در واقع زنجیره‌ی source map را می‌شکنم. source map فعلی از سورس TypeScript اصلی شروع می‌کند و از Babel و minify رد می‌شود تا به کد فعلی برسد. تغییراتی که من می‌دهم یک لایه‌ی دیگر اضافه می‌کند، پس باید این زنجیره را حفظ کنم.

برای ردیابی تغییرات و ساختن یک source map جدید از MagicString استفاده می‌کنم. بعد آن را با map قبلی ترکیب می‌کنم و آرایه‌های sources و sourcesContent اصلی را نگه می‌دارم. این طوری کل زنجیره حفظ می‌شود: 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,
};

دوباره هش کردن محتوای ترنسفورم‌شده

به محتوای پایدارِ فایل نیاز دارم. برای این کار، importها را ترنسفورم می‌کنم (importهای هش‌شده‌ی Vite را با importهای پایدار خودم عوض می‌کنم) و بعد کامنت‌های source map را از محاسبه‌ی هش حذف می‌کنم، چون هنوز به اسم فایل‌های قدیمی اشاره می‌کنند.

بعد از آن یک هش جدید حساب می‌کنم و هم اسم فایل را عوض می‌کنم و هم entry مربوطه را توی import map به‌روزرسانی می‌کنم.

پیاده‌سازی نهایی

این پلاگین از یک استراتژی چهار عبوری استفاده می‌کند:

  1. Count pass: با شمردن تعداد فایل‌هایی که یک اسم پایه‌ی مشترک دارند، تداخل اسم‌ها را پیدا می‌کند
  2. Map pass: نگاشت چانک‌ها را می‌سازد (hashed filename → module specifier) و import map اولیه را تولید می‌کند
  3. Transform pass: مسیرهای import را داخل کد بازنویسی می‌کند، هش‌ها را دوباره حساب می‌کند و source mapها را به‌روزرسانی می‌کند
  4. Rename pass: اسم فایل‌های bundle را عوض می‌کند و import map را نهایی می‌کند

منطق اصلی ترنسفورم این‌طوری است:

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

برای تزریق import map داخل HTML، به‌جای دستکاری با regex از API تزریق تگ خودِ Vite استفاده می‌کنم:

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

این کار خیلی قابل‌اعتمادتر از این است که با regex سعی کنی تگ‌های HTML را match کنی.

یک نگاه عددی

برای این‌که یک حسی بگیری این پلاگین دقیقاً چه کار می‌کند:

  • حدود ~1,000+ فایل JavaScript در هر بیلد پردازش می‌شوند
  • حدود ۲–۳ ثانیه به زمان بیلد اضافه می‌شود (برای این مزیت، معامله‌ی قابل‌قبولی است)
  • حدود ~۹۹٪ کاهش در تغییرهای بی‌موردِ هش (حالا بیشتر فایل‌ها فقط وقتی عوض می‌شوند که محتوای واقعی‌شان عوض شده باشد)
  • حدود ~340 خط کد پلاگین (با احتساب کامنت‌ها و هندل کردن خطاها)

پلاگین تا حالا تمام edge caseهایی را که بهشان خورده‌ام هندل کرده و روند بیلد الان خیلی قابل‌پیش‌بینی‌تر است.

چیزهایی که یاد گرفتم

چرا پارس کردن AST حیاتی است

regex زدن روی کد bundle شده کار خطرناکی است. اگر یک رشته توی کدت تصادفاً شبیه اسم فایل باشد، regex می‌رود و همان را هم عوض می‌کند. پارس کردن AST تضمین می‌کند فقط دستورهای import/export واقعی را ترنسفورم کنی.

چرا Acorn را به es-module-lexer ترجیح دادم

es-module-lexer سریع‌تر و تخصصی‌تر است، ولی مشکل panicهای native باعث شد در فضای پلاگین Vite برای من غیرقابل‌استفاده باشد. Acorn کاملاً JavaScript است، یعنی خبری از وابستگی‌های native دردسرساز نیست. احتمالاً در آینده دوباره سراغ es-module-lexer می‌روم تا برای سرعت بهینه‌ترش کنم، اما فعلاً Acorn کاملاً کارم را راه می‌اندازد.

چرا Import Mapها را به بقیه گزینه‌ها ترجیح دادم

Import Mapها یک استاندارد وب با پشتیبانی بومی مرورگرها هستند. عملاً «راه درست» برای حل این مشکل همین است. polyfillای مثل (es-module-shims) هم مرورگرهای قدیمی‌تر (مثلاً Safari پایین‌تر از 16.4) را به‌خوبی پوشش می‌دهد و در نهایت یک راه‌حل تمیز و قابل نگه‌داری به دست می‌آید.

جمع‌بندی

این پلاگین Import Mapها جلوی عوض شدن آبشاری هش‌ها را در بیلدهای Vite من گرفته است. حالا فایل‌ها فقط وقتی هش جدید می‌گیرند که محتوای واقعی‌شان عوض شده باشد، نه وقتی که صرفاً وابستگی‌هایشان تغییر کرده‌اند. این کار بیلدها را قابل‌پیش‌بینی‌تر می‌کند، باطل شدن بی‌مورد کش را کم می‌کند و کمک می‌کند زیر سقف محدودیت تعداد فایل‌های Cloudflare Pages بمانیم.

این راه‌حل هم ساده است، هم نگه‌داری‌اش راحت است و هم روی استانداردهای مدرن وب سوار شده. یک نمونه‌ی خوب از این است که بعضی وقت‌ها «راه‌حل درست» همان ساده‌ترین راه‌حل است، به شرطی که به‌قدر کافی در دلِ مشکل عمیق شده باشی و آن را ببینی.

این پلاگین متن‌باز است و روی GitHub قرار دارد: @foony/vite-plugin-import-map. می‌توانی با دستور npm install @foony/vite-plugin-import-map نصبش کنی و در پروژه‌های Vite خودت از آن استفاده کنی.

بهبودهای بعدی ممکن است شامل این باشد که وقتی مشکل panicهای native حل شد، با es-module-lexer بهینه‌ترش کنم یا از سناریوهای پیچیده‌ترِ import هم پشتیبانی اضافه کنم. اما فعلاً همین پلاگین دقیقاً همان کاری را می‌کند که من لازم دارم.

و چه می‌دانی؟ شاید یک روز خودِ Vite به‌صورت بومی از چیزی شبیه به این پشتیبانی کرد.

8 Ball Pool online multiplayer billiards icon