

1/1/1970
كيف حللت مشكلة تغيّر الهاش المتسلسل باستخدام Import Maps
أهلًا! هذه المشكلة تلاحقني منذ أكثر من 5 سنوات، لكنني لم أقرر مواجهتها بجدية إلا الآن، بعد أن وصلت إلى مرحلة لم أعد أستطيع فيها تجاهلها. كلما غيّرت حرفًا واحدًا في ملف واحد، كانت نصف ملفات JavaScript في ناتج البناء تحصل على أسماء ملفات جديدة مع هاش مختلف، رغم أن محتواها الفعلي لم يتغيّر.
هذا كان يسبب إلغاء كاش غير ضروري، ويجعل من شبه المستحيل أن أعرف ما الذي تغيّر فعلًا بين كل build والثاني، والأسوأ من ذلك كله أنه كان يكسر عمليات البناء في Cloudflare Pages بسبب حدّ عدد الملفات.
فيما يلي سأشرح المشكلة، ولماذا الحلول الموجودة لم تنجح معي، وكيف بنيت إضافة مخصّصة لـ Vite تستخدم Import Maps لحلها بشكل نهائي.
المشكلة: تغيّر الهاش المتسلسل (Cascading Hash Changes)
Vite يستخدم "هاش" مبنيًا على المحتوى في build الإنتاج. عندما تبني تطبيقك، كل ملف JavaScript يحصل على هاش في اسم الملف مبني على محتواه. إذا كان button.tsx يُترجَم إلى button-abc12345.js، ثم تغيّر المحتوى، يصبح الاسم button-def45678.js. هذا ممتاز لكسر الكاش، لأن المستخدم يحصل على الملف الجديد عندما يتغيّر.
المشكلة تظهر عندما يستورد الملف A الملف B. لنفترض أن عندك:
// main.js
import { Button } from "./button-abc12345.js";
عندما يتغيّر button.tsx، يقوم Vite بتوليد button-def45678.js. لكن الآن main.js نفسه يتغيّر، لأنه يحتوي على النص "./button-abc12345.js"، الذي أصبح غير صحيح. فيحصل main.js بدوره على هاش جديد، رغم أن منطق الكود داخله ما تغيّر أبدًا.
هذه السلسلة تستمر في كامل شجرة التبعيات الخاصة بك. غيّر دالة مساعدة واحدة، وفجأة نصف ملفات js عندك تحصل على هاش جديد. في حالتي، تغيير حرف واحد في useBackgroundMusic.ts أدّى إلى إعادة توليد الهاش لأكثر من 500 ملف.
الأثر في العالم الحقيقي كان كبيرًا. نحن نحزم 8 نسخ من أصول (assets) الـ build السابق، حتى يتمكن المستخدمون الذين يعملون على نسخة قديمة قليلًا من العميل من تشغيل نسختهم عندما ننشر نسخة جديدة على Cloudflare Pages. لكن Cloudflare Pages لديه حد 20,000 ملف، وكنّا نبدأ بالاصطدام بهذا الحد بسبب تغيير i18n الذي قمنا به سابقًا والذي فجّر عدد الملفات التي نُولّدها.
حل مشكلة الهاش المتسلسل يسمح لنا بتخزين عدد أكبر بكثير من الـ builds السابقة بدون أن نصطدم بهذه الحدود، لأن أغلب الملفات لن تحتاج إلى التغيّر بعد الآن. هذا أيضًا يقلّل احتمال أن مستخدمًا على نسخة قديمة سيواجه خطأ، لأن احتمال طلبه لملف لم يعد يتغيّر صار أعلى بكثير.
لماذا لا أستخدم [حلولًا بديلة]؟
عندما بدأت أفكر في حل المشكلة، جرّبت أن أقيّم عدة مقاربات. ولا واحدة منها كانت مناسبة بالكامل.
سكربتات ما بعد البناء (Post-build Scripts)
أول فكرة خطرت ببالي كانت كتابة سكربت يعمل بعد انتهاء الـ build يقوم بتوحيد (normalize) مسارات الاستيراد، وإعادة حساب الهاش، وتحديث المراجع. الفكرة بدت بسيطة: نستبدل أسماء الملفات المهاشّة بأسماء ثابتة باستخدام regex، ثم نعيد حساب الهاشات.
رفضت هذا النهج بسبب "Heisenbugs" ومخاطر تسمّم الكاش (cache poisoning). حتى لو كنا نخزّن الـ builds السابقة في Cloudflare Pages، المخاطرة بوجود عدم اتساق في الكاش لم تكن تستحق المجازفة. سكربت يعبث بالملفات بعد انتهاء الـ build ممكن يخلق أخطاء طفيفة لا تظهر إلا في الإنتاج، وتصحيحها سيكون كابوسًا.
استخدام manualChunks في Vite
خيار آخر كان استخدام إعداد manualChunks في Vite لفصل الكود المستقر (مثل node_modules) عن الكود المتغيّر بشكل مستمر (منطق العمل الخاص بنا). الفكرة أن كود الـ vendor يتغيّر بشكل أقل، فيصير عدد الملفات التي تتأثر بالسلسلة أقل.
لكن هذا لا يحل جذور المشكلة، بل فقط يخففها قليلًا. ما زلت تحصل على تغيّر متسلسل في الهاش داخل حِزم منطق العمل نفسها. أنا أردت حلًا يعالج المشكلة من الأساس، لا مجرد تقليل ضررها.
Import Maps: الحل العصري
Import Maps هي ميزة أصلية في المتصفحات (مع polyfill للمتصفحات الأقدم) تفصل بين "اسم الوحدة" (module specifier) وبين مسار الملف الفعلي. بدلًا من أن يستورد الملف A "./button-abc123.js"، يستورد "button". المتصفح يستخدم الـ import map ليحل "button" إلى اسم الملف المهاشّ الفعلي.
وهذا بالضبط ما كنت أحتاجه. محتوى الملف A يبقى نفسه تمامًا (دائمًا يستورد "button")، لذلك الهاش الخاص به يبقى ثابتًا. فقط ملف الـ import map والملف الذي تغيّر فعليًا يحصلان على هاش جديد. بصراحة استغربت أني لم أجد إضافة جاهزة جيدة تفعل هذا من قبل!
رحلة التنفيذ
قررت أن أبني إضافة (plugin) لـ Vite تقوم بالتالي:
- تحويل كل الاستيرادات النسبية لاستخدام أسماء وحدات ثابتة
- توليد import map تربط هذه الأسماء بأسماء الملفات المهاشّة الفعلية
- حقن ملف الـ import map داخل الـ HTML
الإضافة متوفرة الآن على GitHub: @foony/vite-plugin-import-map
المحاولة الأولى
بدأت ببناء إضافة Vite تستخدم الـ hook generateBundle. أول محاولة كانت باستخدام regex للبحث عن مسارات الاستيراد واستبدالها. كان من السهل كتابة هذا الكود، واشتغل بشكل جيد لفريقنا الصغير في Foony، لكنه كان هشًا، وبالتأكيد لن يصلح كإضافة عامة يتوقع أن تعمل مع مشاريع متنوعة بدون تطابقات خاطئة يتم التلاعب بها.
مشكلة regex واضحة: ماذا لو كان هناك string في الكود يشبه اسم ملف؟ ماذا عن الاستيراد الديناميكي؟ ماذا عن تعبيرات التصدير (export)؟ كنت أحتاج إلى حل أقوى بكثير إذا كنت أريد أن أجعل الإضافة قابلة للاستخدام من الآخرين.
تحليل الـ AST
كنت بحاجة لتحليل كود JavaScript بشكل صحيح لأجد كل أوامر الاستيراد. أول أداة جرّبتها كانت es-module-lexer، وهي مبنية خصيصًا لتحليل وحدات ES. للأسف تسببت في "native panics" أثناء مرحلة تحليل الوحدات في Vite. حتى عندما جرّبت نسخة asm.js، استمرت الـ panics.
في النهاية استقريت على Acorn، وهو parser سريع وخفيف ومكتوب بالكامل في JavaScript. ومع acorn-walk للتجوال في الـ AST حصلت على كل ما أحتاجه، وبدون مشاكل الاعتماد على إضافات native.
التحديات الأساسية التي تم حلّها
التعامل مع كل أنواع الاستيراد
الاستيراد يأتي بأشكال متعددة، وكل نوع يُمثَّل بشكل مختلف في الـ 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
},
});
حل تعارض الأسماء بشكل حتمي (Deterministic)
عندما يكون هناك أكثر من ملف يملك نفس الاسم الأساسي (مثل عدة ملفات index.tsx داخل مجلدات مختلفة)، أحتاج لطريقة تميّز بينها. لا يمكنني أن أستخدم "index" للجميع.
الحل الذي استخدمته: إذا حصل تعارض، أقوم بعمل hash لمسار المصدر الأصلي مع الاسم الأساسي. مثلًا src/client/games/chess/index.tsx:index يتم عمل hash له لينتج index-abc123. هذا يضمن أن نفس الملف يحصل دائمًا على نفس اسم الوحدة (module specifier) بين مختلف الـ builds، حتى لو أضفت أو حذفت ملفات أخرى بنفس الاسم.
أستخدم chunk.facadeModuleId (نقطة الدخول) كمعرّف أساسي، وأعود إلى chunk.moduleIds[0] إذا لم يكن الأول متوفرًا. هذا يعطيني مسارًا ثابتًا للمصدر يمكن الاعتماد عليه في حساب الهاش بشكل حتمي.
ربط سلسلة خرائط المصدر (Source Map Chaining)
عندما أعدّل الكود، فأنا عمليًا أكسر سلسلة خرائط المصدر (source maps). خريطة المصدر الموجودة أصلًا تربط بين كود TypeScript الأصلي وبين ما بعد Babel والتصغير، إلى أن نصل إلى الكود الحالي. التعديلات التي أقوم بها تضيف طبقة جديدة، لذلك أحتاج للحفاظ على السلسلة كاملة.
أستخدم MagicString لتتبع التعديلات وتوليد خريطة مصدر جديدة. بعد ذلك أدمجها مع الخريطة الموجودة عن طريق الحفاظ على مصفوفات 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,
};
إعادة حساب الهاش بعد التعديلات
كنت بحاجة إلى محتوى ملف ثابت فعليًا. لتحقيق ذلك، أعدّل الاستيرادات (أستبدل استيرادات Vite المهاشّة باستيرادات ثابتة خاصة بي)، ثم أزيل تعليقات الـ source map من عملية حساب الهاش، لأنها تشير إلى أسماء ملفات قديمة.
بعد ذلك أحسب هاشًا جديدًا، وأحدّث اسم الملف وسجلّه داخل الـ import map.
التنفيذ النهائي
الإضافة تستخدم استراتيجية من أربع مراحل:
- مرحلة العد: اكتشاف تعارضات الأسماء عن طريق عدّ عدد الملفات التي تشترك في كل اسم أساسي
- مرحلة الخرائط: إنشاء خريطة القطع (hashed filename → module specifier) والـ import map الأولي
- مرحلة التحويل: إعادة كتابة مسارات الاستيراد في الكود، وإعادة حساب الهاش، وتحديث خرائط المصدر
- مرحلة إعادة التسمية: تحديث أسماء الملفات داخل الـ 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 أستخدم واجهة Vite الخاصة بحقن الوسوم بدلًا من التلاعب بـ HTML بالـ regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
هذا أكثر موثوقية بكثير من محاولة استخدام regex لمطابقة وسوم HTML.
الأرقام
حتى تأخذ فكرة أوضح عن ما تقوم به الإضافة:
- حوالي 1,000+ ملف JavaScript تتم معالجتها في كل build
- حوالي 2-3 ثوانٍ زيادة على زمن الـ build (صفقة مقبولة)
- حوالي 99% تقليل في تغيّر الهاش غير الضروري (معظم الملفات الآن تتغيّر فقط عندما يتغيّر محتواها فعلًا)
- حوالي 340 سطرًا من كود الإضافة (مع التعليقات والتعامل مع الأخطاء)
الإضافة تتعامل مع كل الحالات الطرفية التي واجهتها حتى الآن، وعملية الـ build أصبحت أكثر قابلية للتنبؤ.
ما الذي تعلمته
لماذا تحليل الـ AST ضروري
استخدام regex على الكود المجمّع مخاطرة كبيرة. إذا كان هناك string في الكود يشبه اسم ملف، الـ regex سيعدّله بدون تمييز. تحليل الـ AST يضمن أنك تغيّر فقط أوامر الاستيراد/التصدير الحقيقية.
لماذا اخترت Acorn بدل es-module-lexer
es-module-lexer أسرع ومصمّم خصيصًا لهذا الغرض، لكن مشاكل الـ native panic جعلته غير قابل للاستخدام داخل إضافة Vite في وضعي الحالي. Acorn مكتوب بالكامل في JavaScript، وهذا يعني عدم وجود تبعيات native تقلق بشأنها. في المستقبل قد أعود إلى es-module-lexer لتحسين الأداء، لكن حاليًا Acorn يقوم بالواجب بشكل ممتاز.
لماذا Import Maps أفضل من البدائل
Import Maps معيار ويب رسمي ومدعوم مباشرة من المتصفحات. هو "الحل الصحيح" للمشكلة. الـ polyfill (es-module-shims) يتعامل مع المتصفحات الأقدم (مثل Safari أقل من 16.4) بشكل سلس، والحل الناتج نظيف وسهل الصيانة.
الخلاصة
إضافة Import Maps نجحت في إيقاف تغيّر الهاش المتسلسل في Builds الخاصة بـ Vite عندي. الملفات الآن تحصل على هاش جديد فقط عندما يتغيّر محتواها الفعلي، وليس عندما تتغيّر التبعيات الخاصة بها. هذا يجعل الـ builds أكثر قابلية للتنبؤ، ويقلّل من إلغاء الكاش غير الضروري، ويساعدنا على البقاء تحت حدود عدد الملفات في Cloudflare Pages.
الحل بسيط، سهل الصيانة، ويعتمد على معايير ويب حديثة. وهو مثال جميل على أن "الحل الصحيح" يكون أحيانًا هو الأبسط، بشرط أن تفهم المشكلة بعمق كافٍ لتراها بوضوح.
الإضافة مفتوحة المصدر ومتاحة على GitHub: @foony/vite-plugin-import-map. يمكنك تثبيتها عبر npm install @foony/vite-plugin-import-map والبدء في استخدامها في مشاريع Vite الخاصة بك.
التحسينات المستقبلية قد تشمل تحسين الأداء باستخدام es-module-lexer عندما تُحل مشاكل الـ native panic، أو إضافة دعم لسيناريوهات استيراد أكثر تعقيدًا. لكن في الوقت الحالي، الإضافة تفعل بالضبط ما أحتاجه منها.
ومن يدري؟ ربما في يوم من الأيام يدعم Vite شيئًا مشابهًا لهذا بشكل افتراضي.