background blurbackground mobile blur

1/1/1970

چطور تغییرات هش آبشاری را با Import Maps حل کردم

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

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

مشکل: تغییرات هش آبشاری

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

مشکل وقتی پیش می‌آید که فایل A فایل B را ایمپورت می‌کند. فرض کنید این را داریم:

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

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

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

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

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

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

وقتی برای اولین بار به حل این موضوع نگاه کردم، چند رویکرد را در نظر گرفتم. هیچ‌کدام کاملاً جور درنمی‌آمدند.

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

اولین فکرم نوشتن یک اسکریپت پس از بیلد بود که همه مسیرهای ایمپورت را نرمال‌سازی می‌کرد، فایل‌ها را مجدد هش می‌کرد و ارجاعات را به‌روزرسانی می‌کرد. این ساده به نظر می‌رسید: فقط با regex نام‌های هش‌شده را با نام‌های پایدار جایگزین کنید، سپس هش‌ها را دوباره محاسبه کنید.

این رویکرد را به دلیل نگرانی‌های "Heisenbugs" و آلودگی کش رد کردم. حتی با اینکه بیلدهای قبلی را در Cloudflare Pages ذخیره می‌کنیم، ریسک ناسازگاری‌های کش ارزشش را نداشت. اسکریپتی که فایل‌ها را پس از بیلد تغییر می‌دهد می‌تواند باگ‌های ظریفی ایجاد کند که فقط در پروداکشن ظاهر می‌شوند، و دیباگ کردن آن‌ها یک کابوس خواهد بود.

Vite manualChunks

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

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

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

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

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

ساختن پلاگین Vite

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

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

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

رویکرد اولیه

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

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

پارس کردن AST

نیاز داشتم کد جاوااسکریپت را به‌درستی پارس کنم تا تمام دستورات ایمپورت را پیدا کنم. اولین تلاشم es-module-lexer بود که به‌طور خاص برای پارس کردن ماژول‌های ES طراحی شده است. متأسفانه، در فاز تحلیل ماژول ویت باعث پنیک‌های بومی می‌شد. حتی امتحان کردن نسخه asm.js هم به جلوگیری از پنیک‌ها کمکی نکرد.

روی Acorn که یک پارسر سریع، سبک و کاملاً جاوااسکریپت است، تصمیم گرفتم. در ترکیب با acorn-walk برای پیمایش AST، همه چیزی که نیاز داشتم را بدون مشکلات وابستگی بومی به من داد.

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

رسیدگی به همه انواع ایمپورت‌ها

ایمپورت‌ها به اشکال مختلفی می‌آیند و در AST به‌صورت متفاوتی بررسی می‌شوند. نیاز داشتم به این‌ها رسیدگی کنم:

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

مورد ری‌اکسپورت به‌خصوص مشکل بود چون آن را از قلم انداختم تا اینکه فایلی را دیدم که تبدیل نشده بود. کد 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 و کوچک‌سازی به کد فعلی نگاشت می‌کند. تبدیلات من یک لایه دیگر اضافه می‌کنند، پس باید آن زنجیره را حفظ کنم.

از MagicString برای پیگیری تبدیلاتم و تولید یک source map جدید استفاده می‌کنم. سپس آن را با نقشه موجود ادغام می‌کنم با حفظ آرایه‌های اصلی sources و sourcesContent. این زنجیره کامل را حفظ می‌کند: منبع اصلی ← (نقشه موجود) ← کد تبدیل‌یافته.

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

هش‌گذاری مجدد محتوای تبدیل‌یافته

به محتوای فایل پایدار نیاز دارم. برای این کار، ایمپورت‌ها را تبدیل می‌کنم (ایمپورت‌های هش‌شده ویت را با ایمپورت‌های پایدار خودم جایگزین می‌کنم) و سپس کامنت‌های source map را از محاسبه هش حذف می‌کنم (آن‌ها به نام فایل‌های قدیمی ارجاع می‌دهند).

پس از آن، یک هش جدید محاسبه می‌کنم و هم نام فایل و هم ورودی import map را به‌روزرسانی می‌کنم.

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

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

  1. مرحله شمارش: تشخیص تعارضات نام با شمارش تعداد فایل‌هایی که نام پایه مشترک دارند
  2. مرحله نگاشت: ایجاد نگاشت چانک (نام فایل هش‌شده ← مشخص‌کننده ماژول) و import map اولیه
  3. مرحله تبدیل: بازنویسی مسیرهای ایمپورت در کد، محاسبه مجدد هش‌ها، به‌روزرسانی source map‌ها
  4. مرحله تغییر نام: به‌روزرسانی نام‌های فایل باندل و نهایی کردن 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 تزریق تگ ویت استفاده می‌کنم:

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

این بسیار قابل اعتمادتر از تلاش برای تطبیق regex با تگ‌های HTML است.

با اعداد

برای اینکه حسی از کاری که این پلاگین انجام می‌دهد به شما بدهم:

  • ~۱،۰۰۰+ فایل جاوااسکریپت پردازش‌شده در هر بیلد
  • ~۲ تا ۳ ثانیه اضافه‌شده به زمان بیلد (مبادله قابل قبولی)
  • ~۹۹٪ کاهش در تغییرات هش غیرضروری (اکثر فایل‌ها اکنون فقط زمانی تغییر می‌کنند که محتوای واقعی‌شان تغییر کند)
  • ~۳۴۰ خط کد پلاگین (شامل کامنت‌ها و رسیدگی به خطا)

پلاگین تمام موارد لبه‌ای که تاکنون با آن‌ها مواجه شده‌ام را مدیریت می‌کند، و فرآیند بیلد اکنون بسیار قابل پیش‌بینی‌تر است.

درس‌های آموخته‌شده

چرا پارس AST ضروری است

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

چرا Acorn به جای es-module-lexer

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

چرا Import Maps به جای جایگزین‌ها

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

نتیجه‌گیری

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

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

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

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

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

(به‌روزرسانی: پس از امتحان کردن پلاگین روی بیلد Foony، برخی از کاربران مشکلات غیرمنتظره‌ای داشتند، پس فعلاً آن را غیرفعال کردم. بعداً به آن بازخواهم گشت. شاید. هنوز فکر می‌کنم این یک راه‌حل جالب است.)

8 Ball Pool online multiplayer billiards icon