

1/1/1970
كيف حللت مشكلة تتالي تغييرات التجزئة باستخدام Import Maps
أهلاً! عانيت من هذه المشكلة لأكثر من 5 سنوات، لكنني قررت معالجتها الآن فقط لأنها وصلت إلى مرحلة لم أعد قادراً على تجاهلها. عندما كنت أغيّر حرفاً واحداً في ملف واحد، كان نصف ملفات JavaScript في عملية البناء (build) يحصل على أسماء ملفات مُجزّأة جديدة، رغم أن محتواها الفعلي لم يتغير. كان هذا يسبب إبطالاً غير ضروري للذاكرة المؤقتة (cache)، ويجعل من المستحيل تقريباً تتبّع ما تغيّر فعلاً بين عمليات البناء، والأسوأ من ذلك: أنه كان يكسر عمليات البناء على Cloudflare Pages بسبب حدّ عدد الملفات.
في الأسفل سأشرح المشكلة، ولماذا لم تنجح الحلول الحالية معي، وكيف بنيت إضافة (plugin) مخصصة لـ Vite باستخدام Import Maps لحلّها نهائياً.
المشكلة: تتالي تغييرات التجزئة
يستخدم Vite تجزئة قائمة على المحتوى لعمليات بناء الإنتاج. عند بناء تطبيقك، يحصل كل ملف 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 على تجزئة جديدة أيضاً، رغم أن المنطق الفعلي في main.js لم يتغير على الإطلاق.
يتتالى هذا عبر مخطط الاعتماديات بأكمله. غيّر دالة مساعدة واحدة، وفجأة يحصل نصف ملفات js لديك على تجزئات جديدة. في حالتي، تسبّب تغيير حرف واحد في useBackgroundMusic.ts في إعادة تجزئة أكثر من 500 ملف.
كان التأثير في الواقع كبيراً. نحن نحزم 8 إصدارات من أصول البناء السابق حتى يتمكن المستخدمون الذين لديهم إصدارات قديمة قليلاً من العميل من تشغيل إصدارهم عندما ننشر الإصدار الجديد على Cloudflare Pages. ومع ذلك، لدى Cloudflare Pages حدّ يبلغ 20,000 ملف، وقد بدأنا في الوصول إليه بسبب تغيير الترجمة (i18n) السابق لدينا الذي ضاعف عدد الملفات التي ننشئها.
حلّ مشكلة تتالي التجزئة يسمح لنا بتخزين عدد أكبر بكثير من عمليات البناء السابقة دون الوصول إلى هذه الحدود، لأن معظم الملفات لم تعد بحاجة إلى التغيير. كما يقلّل هذا من احتمالية حدوث خطأ لمستخدم على إصدار قديم، لأنه من المرجّح أكثر بكثير أن يطلبوا ملفاً لم يتغير ونحن نملكه.
لماذا لم أختر [الحلول البديلة]؟
عندما نظرت أولاً إلى حلّ هذه المشكلة، فكّرت في عدة أساليب. لم يكن أيٌّ منها مناسباً تماماً.
نصوص ما بعد البناء (Post-build Scripts)
كانت فكرتي الأولى هي كتابة سكريبت يعمل بعد البناء يقوم بتوحيد جميع مسارات الاستيراد، وإعادة تجزئة الملفات، وتحديث المراجع. بدا هذا واضحاً ومباشراً: مجرد استبدال أسماء الملفات المُجزّأة بأسماء ثابتة باستخدام regex، ثم إعادة حساب التجزئات.
رفضت هذا النهج بسبب مخاوف "Heisenbugs" وتلوّث الذاكرة المؤقتة. حتى مع تخزين عمليات البناء السابقة في Cloudflare Pages، فإن خطر عدم اتساق الذاكرة المؤقتة لم يكن يستحق المخاطرة. أي سكريبت يعدّل الملفات بعد البناء قد يُدخل أخطاء دقيقة لا تظهر إلا في الإنتاج، وتصحيحها سيكون كابوساً.
استخدام manualChunks في Vite
خيار آخر كان استخدام إعدادات manualChunks في Vite لفصل الكود المستقر (مثل node_modules) عن الكود غير المستقر (منطق العمل). الفكرة أن كود الموردين سيتغير بشكل أقل تواتراً، لذا ستتتالى ملفات أقل.
هذا لا يحلّ المشكلة الجذرية فعلاً، بل يخفّفها فقط. لا تزال تحصل على تتالي التجزئات داخل أجزاء منطق العمل لديك. كنت أريد حلاً يعالج المشكلة الأساسية، وليس فقط جعلها أقل سوءاً قليلاً.
Import Maps: الحل الحديث
Import Maps هي ميزة مدمجة في المتصفحات (مع دعم polyfill للمتصفحات الأقدم) تفصل بين مُحدّدات الوحدات (module specifiers) ومسارات الملفات. بدلاً من أن يستورد الملف A "./button-abc123.js"، فإنه يستورد "button". يستخدم المتصفح خريطة الاستيراد لحلّ "button" إلى اسم الملف المُجزّأ الفعلي.
هذا بالضبط ما كنت أحتاجه. يبقى محتوى الملف A مطابقاً (يستورد دائماً "button")، لذلك تبقى تجزئته كما هي. فقط خريطة الاستيراد والملف المتغير يحصلان على تجزئات جديدة. كنت مصدوماً نوعاً ما لأن لا أحد قد صنع إضافة جيدة لهذا من قبل!
بناء إضافة Vite
قررت بناء إضافة لـ Vite تقوم بما يلي:
- تحويل جميع الاستيرادات النسبية لاستخدام مُحدّدات وحدات ثابتة
- إنشاء خريطة استيراد تربط تلك المُحدّدات بأسماء الملفات المُجزّأة الفعلية
- حقن خريطة الاستيراد في HTML
الإضافة متاحة الآن على GitHub: @foony/vite-plugin-import-map
النهج الأولي
بدأت بإضافة Vite تستخدم خطّاف generateBundle. اعتمدت محاولتي الأولى على regex للعثور على مسارات الاستيراد واستبدالها. كان هذا سهل البرمجة وعمل لفريقنا الصغير في Foony، لكنه كان هشّاً وبالتأكيد لن يعمل في إضافة قد تتعرض لمطابقات إيجابية خاطئة (false-positives) يتم تعديلها.
كان لنهج regex مشاكل واضحة: ماذا لو كانت سلسلة نصية في الكود تبدو كاسم ملف؟ ماذا عن الاستيرادات الديناميكية؟ ماذا عن جمل التصدير؟ كنت بحاجة إلى حلّ أكثر متانة إذا أردت بناء إضافة للآخرين.
تحليل AST
كنت بحاجة إلى تحليل كود JavaScript بشكل صحيح للعثور على جميع جمل الاستيراد. كانت محاولتي الأولى مع es-module-lexer، المصمَّم خصيصاً لتحليل وحدات ES. لسوء الحظ، تسبّب في حالات panic أصلية (native panics) خلال مرحلة تحليل الوحدات في Vite. حتى تجربة بناء asm.js لم تساعد في إيقاف حالات panic.
استقرّيت على Acorn، وهو محلّل سريع وخفيف الوزن ومكتوب بـ JavaScript خالص. مع 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 Chaining)
عندما أحوّل الكود، أكسر سلسلة خرائط المصدر. خريطة المصدر الموجودة تربط من مصدر 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 المُجزّأة باستيراداتي الثابتة)، ثم أزيل تعليقات خرائط المصدر من حساب التجزئة (لأنها تشير إلى أسماء ملفات قديمة).
بعد ذلك، أحسب تجزئة جديدة، وأحدّث كلاً من اسم الملف وإدخال خريطة الاستيراد.
التنفيذ النهائي
تستخدم الإضافة استراتيجية من أربع تمريرات:
- تمريرة العدّ: اكتشاف تعارضات الأسماء بحساب عدد الملفات التي تشترك في كل اسم أساسي
- تمريرة التخطيط: إنشاء خريطة الـ chunk (اسم الملف المُجزّأ ← مُحدّد الوحدة) وخريطة الاستيراد الأولية
- تمريرة التحويل: إعادة كتابة مسارات الاستيراد في الكود، إعادة حساب التجزئات، تحديث خرائط المصدر
- تمريرة إعادة التسمية: تحديث أسماء ملفات الحزمة وإنهاء خريطة الاستيراد
إليك منطق التحويل الأساسي:
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);
}
لحقن خريطة الاستيراد في HTML، أستخدم واجهة برمجة حقن الوسوم في Vite بدلاً من التلاعب بـ regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
هذا أكثر موثوقية بكثير من محاولة مطابقة وسوم HTML باستخدام regex.
بالأرقام
لأعطيك فكرة عما تفعله هذه الإضافة:
- ~1,000+ ملف JavaScript تتم معالجته في كل عملية بناء
- ~2-3 ثوانٍ يضيفها إلى وقت البناء (مقايضة مقبولة)
- ~99% انخفاض في تغييرات التجزئة غير الضرورية (معظم الملفات الآن تتغير فقط عندما يتغير محتواها الفعلي)
- ~340 سطراً من كود الإضافة (بما في ذلك التعليقات ومعالجة الأخطاء)
تعالج الإضافة جميع الحالات الحدّية التي واجهتها حتى الآن، وأصبحت عملية البناء الآن أكثر قابلية للتنبؤ بكثير.
الدروس المستفادة
لماذا تحليل AST ضروري
استخدام regex على كود مُحزَّم خطير. إذا كانت سلسلة نصية في كودك تبدو كاسم ملف، فإن regex سيعيد كتابتها. تحليل AST يضمن أنك تحوّل فقط جمل الاستيراد/التصدير الفعلية.
لماذا Acorn بدلاً من es-module-lexer
es-module-lexer أسرع وأكثر تخصصاً، لكن مشاكل panic الأصلية جعلته غير قابل للاستخدام في سياق إضافة Vite. Acorn مكتوب بـ JavaScript خالص، مما يعني عدم وجود اعتماديات أصلية للقلق بشأنها. سأرغب في النظر إلى es-module-lexer في المستقبل كتحسين للسرعة، ولكن في الوقت الحالي يعمل Acorn بشكل ممتاز.
لماذا Import Maps بدلاً من البدائل
Import Maps هي معيار ويب مع دعم أصلي للمتصفحات. إنها الطريقة "الصحيحة" لحل هذه المشكلة. يتعامل الـ polyfill (es-module-shims) مع المتصفحات الأقدم (مثل Safari < 16.4) بسلاسة، والحل نظيف وقابل للصيانة.
الخاتمة
نجحت إضافة Import Maps في منع تتالي تغييرات التجزئة في عمليات بناء Vite الخاصة بي. أصبحت الملفات الآن تحصل على تجزئات جديدة فقط عندما يتغير محتواها الفعلي، وليس عندما تتغير اعتمادياتها. هذا يجعل عمليات البناء أكثر قابلية للتنبؤ، ويقلل من إبطال الذاكرة المؤقتة غير الضروري، ويساعدنا على البقاء تحت حدود ملفات Cloudflare Pages.
الحل بسيط، قابل للصيانة، ويستخدم معايير الويب الحديثة. إنه مثال جيد على أن الحل "الصحيح" أحياناً هو أيضاً الأبسط، بمجرد أن تفهم المشكلة بعمق كافٍ لرؤيته.
الإضافة مفتوحة المصدر ومتاحة على GitHub: @foony/vite-plugin-import-map. يمكنك تثبيتها بـ npm install @foony/vite-plugin-import-map والبدء باستخدامها في مشاريع Vite الخاصة بك.
قد تتضمن التحسينات المستقبلية تحسين الأداء باستخدام es-module-lexer بمجرد حلّ مشاكل panic الأصلية، أو إضافة دعم لسيناريوهات استيراد أكثر تعقيداً. لكن في الوقت الحالي، تقوم الإضافة بالضبط بما أحتاج منها أن تفعله.
ومن يدري؟ ربما يوماً ما سيدعم Vite شيئاً كهذا بشكل أصلي.
(تحديث: بعد تجربة الإضافة على بناء Foony، واجه بعض المستخدمين مشاكل غير متوقعة، لذلك عطّلتها في الوقت الحالي. سأعود إليها لاحقاً. ربما. لا أزال أعتقد أن هذا حل أنيق.)