أنماط تصميم تطبيقات الويب › أنماط JavaScript 1 / 3 السابق التالي

نمط الوزن الخفيف (flyweight)

الوزن الخفيف (flyweight) نمط هيكلي للمواقف التي يحتاج فيها تطبيق إلى العرض (rendering) أو تتبّع عدد هائل من الكائنات التي تبدو متشابهة في الغالبية العظمى. وبدلًا من أن يملك كل كائن مجموعة البيانات الكاملة التي يحتاجها للعرض، تستخرج الأجزاء التي لا تتغير أبدًا وتخزنها مرة واحدة، متتركة بين جميع المستهلكين.

لم يصبح النمط «نمط تصميم» (design pattern) إلا لأنه له شكل يمكن التعرف عليه: مجموعة صغيرة وثابتة من الكائنات المشتركة (الأوزان الخفيفة) إضافة إلى سياق لكل نسخة (الحالة الخارجية) يُقدم عند الاستدعاء. يظهر هذا الشكل اليوم بأسماء كثيرة — العرض بنسخ مثلثة، وAtomic CSS، ومجمعات الكائنات، والأصناف المخفية، وذاكرة مؤقتة للقواعد — ولا يظل ذا معنى في عام 2025، لكن نادرًا ما يكون للسبب الذي روّج له GoF أصلًا.

الحالة الداخلية مقابل الحالة الخارجية

تتمثل حيلة التعرف على الوزن الخفيف في تقسيم الكائن إلى مجموعتين:

  • الداخلية — قيم تكون نفسها لكل مستهلك. آمنة للمشاركة.
  • الخارجية — قيم تختلف حسب المستهلك. تُقدم من الخارج في كل عملية.

إذا استطعت إجراء هذا التقسيم بوضوح، فيمكن عادةً تقليص المجموعة الداخلية من «آلاف الكائنات» إلى «حفنة»، وهذا هو جوهر الفكرة.

عارض لرسائل المحادثة

تخيل واجهة محادثة تعرض 50,000 رسالة بينما يتصفح المستخدم السجل. لكل رسالة مؤلف وصورة رمزية ولون دور واسم عرض — إضافة إلى جسم ووقت ختم وموضع على الشاشة.

يخزن التطبيق الساذج كل ذلك في كل رسالة:

// Naive: each message owns a full copy of the author info

const messages = history.map((m) => ({

body: m.body,

timestamp: m.timestamp,

authorName: m.user.name,

authorAvatar: m.user.avatarUrl,

authorColor: m.user.color,

authorRole: m.user.role,

}));

إذا كتب 30 مستخدمًا تلك الرسائل الخمسين ألفًا، تتكرر سلاسل الاسم/الصورة الرمزية/اللون نفسها عشرات الآلاف من المرات. ويضطر جامع القمامة إلى المرور على كل ذلك. كما يجب أن يقارنه التحليل في قائمة افتراضية.

تقسيم الوزن الخفيف: الملف التعريفي للمؤلف حالة داخلية (فهو نفسه لكل رسالة أرسلها ذلك المستخدم)، بينما الجسم ووقت الختم والموضع حالة خارجية.

// A WeakMap-backed pool keyed by user id.

// We use a plain Map here because user ids are primitives.

const authorPool = new Map();

function getAuthor(user) {

let flyweight = authorPool.get(user.id);

if (flyweight) return flyweight;

flyweight = Object.freeze({

id: user.id,

name: user.name,

avatarUrl: user.avatarUrl,

color: user.color,

role: user.role,

});

authorPool.set(user.id, flyweight);

return flyweight;

}

// Each message is now mostly extrinsic state plus a pointer to a shared author.

function makeMessage(raw) {

return {

author: getAuthor(raw.user),

body: raw.body,

timestamp: raw.timestamp,

};

}

الاستخدام التفصيلي لـ Object.freeze مهم؛ فهو يوضح النية بأن لا يعدّل شيء وزنًا خفيفًا مشتركًا، وستتخطى معظم المحركات بعض الفحوص للكائنات المجمدة.

تشارك الآن 50,000 رسالة نحو 30 كائنًا للمؤلف. تقرأ كل عملية عرض message.author.color بدلًا من البحث عن سلسلة جديدة. يقصر ذلك المسار الساخن ويهدئ الكومة.

لماذا المصنع (factory)؟

الجزء الخاص بـ«المصنع» في الوزن الخفيف يؤدي مهمة واحدة: فرض الثابت مفتاح داخلي واحد → كائن واحد. إذا بنى موقعا استدعاء كائنَي مؤلف خاصين بهما للمستخدم نفسه، فسيكون لديك وزنان خفيفان، وتكون ذاكرة مؤقتة خاطئة، وستفشل مقارنات الهوية (a.author === b.author) بصمت.

تعمل Map عندما تكون المفاتيح قيمًا أولية مستقرة، مثل معرّفات المستخدمين والرموز الدولية ونقاط ترميز المحارف. وتعمل WeakMap عندما يكون المفتاح هو الكائن الذي تجري إزالة تكراره، وتريد أن تختفي قيم المجمع عند جمع المفتاح. ويعمل سجل مفاتيحه بـ Symbol عندما تريد مقبضًا خاصًا لا يحتمل تصادمًا للوزن الخفيف من مواضع أخرى في قاعدة الشيفرة.

أماكن أهمية الوزن الخفيف اليوم

في الأجهزة الحديثة، نادرًا ما تكون وفرات الذاكرة التي شدد عليها GoF هي القدر المهيمن. ولا يزال النمط يستحق الاستخدام في الحالات التالية:

  • حلقة عرض محكمة تخصّص ذاكرة في كل إطار (canvas، WebGL، محركات الألعاب).
  • قائمة أو شبكة افتراضية تعرض عشرات الآلاف من الخلايا.
  • تكلفة إنشاء للكائن المشترك لا تريد دفعها مرارًا (ترجمة التعبيرات النمطية، ومنسقات Intl، والمخططات المحللة).

في الحالات الثلاث، التكلفة التي جرى توزيعها هي الإنشاء وضغط جمع القمامة، لا عدد البايتات الخام.

الصيغ الحديثة التي استخدمتها على الأرجح

التخزين المؤقت لـIntl.NumberFormat وIntl.Collator

إنشاء new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) مكلف؛ فهو يحمّل بيانات اللغة المحلية من ICU. وتنفيذه داخل عملية عرض أو دالة مقارنة ترتيب يدمّر الأداء (performance). الحل النموذجي هو مجمع وزن خفيف صغير مفتاحه الخيارات:

const formatterPool = new Map();

function currency(locale, currency) {

const key = `${locale}:${currency}`;

let f = formatterPool.get(key);

if (!f) {

f = new Intl.NumberFormat(locale, { style: "currency", currency });

formatterPool.set(key, f);

}

return f;

}

currency("en-US", "USD").format(42); // "$42.00"

ينبغي لأي عرض قائمة منسق أرقام أن يفعل شيئًا مثل هذا. معظمها لا يفعل، ويمثل ذلك سبب بطئها.

مجمعات الكائنات في WebGL ومحرّكات الألعاب

كل من InstancedMesh في Three.js، وParticleContainer في PixiJS، والنسخ الخفيفة في Babylon.js هو في الواقع وزن خفيف متنكر: جزء واحد من الهندسة ومادة واحدة، يُرسمان N مرة مع N من مصفوفات التحويل المختلفة. الهندسة حالة داخلية، والتحويل حالة خارجية، وتقوم وحدة معالجة الرسوميات بالضرب. ويكون تخصيص Mesh جديد لكل جسيم مدمرة؛ أما العرض بنسخ مثلثة فيتيح لمكالمة رسم واحدة معالجة آلاف العناصر.

تأخذ أنظمة الجسيمات الفكرة نفسها أبعد من ذلك باستخدام مجمعات كائنات صريحة — فعند موت الجسيمات تعود إلى قائمة حرة بدلًا من جمعها في القمامة، يتيح للإطار التالي إعادة استخدام الكائن نفسه بدلًا من طلب كائن جديد من الموزع.

Atomic CSS كوزن خفيف للتنسيق

Tailwind وUnoCSS وAtomizer هي في الجوهر مجمع وزن خفيف لقواعد CSS. توجد p-4 في ورقة الأنماط مرة واحدة بالضبط؛ وتحمل العناصر التي تستخدمها الملايين اسم الصنف فقط، لا إعلان padding: 1rem; مكررًا. الداخلي = القاعدة، والخارجي = العنصر الذي تنطبق عليه.

المشاركة البنيوية في Immer وImmutable.js

عند تحديث عقدة واحدة عميقة في شجرة غير قابلة للتغيير، لا تستنسخ الشجرة كلها؛ بل تستنسخ المسار إلى تلك العقدة وتشارك كل فرع آخر مع الإصدار السابق. هذه المشاركة هي الوزن الخفيف مطبقًا على أشجار الحالة: الأشجار الفرعية غير المتغيرة أوزان خفيفة داخلية تشير إليها إصدارات متعددة من الجذر.

الأصناف المخفية داخل V8

هذه تفصيل تتعلق بوقت التشغيل لا بشيء تكتبه، لكن من المفيد معرفتها. يرفق محرك V8 ( والمحركات الحديثة الأخرى) شكلًا لكل كائن — مجموعة الخصائص وإزاحاتها. تشارك الكائنات ذات الأشكال المتطابقة الصنف المخفي نفسه، ما يجعل الوصول إلى الخصائص سريعًا. وحين تنشئ كائنات بالحقول نفسها بالترتيب نفسه، فأنت فعليًا تطلب المحرك استخدام شكل بوصفه وزنًا خفيفًا. وأنشئها بترتيبات مختلفة فتجزّئ الأشكال، وتؤدي إلى إلغاء تحسين ذاكرات التخزين المؤقت السطرية، وتخسر الأداء لأسباب لا تشبه إطلاقًا خطأ في الذاكرة.

النصيحة العملية: في الدوال المنشئة الساخنة، هيّئ الخصائص بترتيب ثابت، وتجنب إضافة خصائص جديدة لاحقًا. هذا انضباط الوزن الخفيف الذي يفرضه المحرك.

عرض المحارف: المثال الكلاسيكي في علوم الحاسوب

السبب الوحيد الذي جعل الوزن الخفيف يدخل كتاب «الجماعة الأربعة» هو نظام TeX الخاص بـ Knuth، حيث يُعرض كل محرف في الصفحة من أطلس محارف مشترك. قد يحتوي كتاب على مليون e، لكن يوجد كائن محرف واحد بالضبط يصف كيفية رسم e بخط Computer Modern بحجم 12 نقطة — ومليون موضع (x, y) تخبر العارض أين يضعه. ولا يزال العرض النصي الحديث، بما فيه عرض خطوط canvas وتشكيل الخطوط عبر HarfBuzz، مبنيًا على هذا التقسيم نفسه.

محاذير

  • لا تجمع في المجمع أشياء تتغير. الكائن المشترك القابل للتغيير خطأ في انتظار الإبلاغ. جمّد الوزن الخفيف، أو لا تجمع إلا قيمًا غير قابلة للتغيير بعمق.
  • لا تجمع في المجمع أشياء رخيصة. عادة ما يكلف مجمع { x, y } أكثر مما يوفره التخصيص المباشر؛ فبحث Map أغلى من التخصيص الذي يوفره.
  • راقب التسريبات. Map طويلة العمر تحمل مرجعًا قويًا طويل العمر. إذا أمكن جمع مفاتيح المجمع، ففضّل الإخراج عبر WeakMap أو WeakRef.

المقايضات

الفائدة التكلفة
تخصيصات أقل وضغط أقل على جمع القمامة في المسارات الساخنة توجيه إضافي عبر مصنع
يسهل التفكير في الهوية (=== تعمل على الكائنات المشتركة) يجب التمييز بعناية بين الحالة الداخلية والخارجية
تلائم القوائم والشبكات الافتراضية والعرض بنسخ مثلثة يحتاج المجمع نفسه إلى استراتيجية إخراج إذا كانت المفاتيح غير محدودة
توافق طبيعي مع تحسينات المحرك (الأصناف المخفية) تعديل وزن خفيف يكسر جميع المستهلكين في الوقت نفسه

المراجع