عنق زجاجة الـ DOM الذي لا يتحدث عنه أحد
تخيل لوحة تحكم للدعم الفني تسحب عشرة آلاف إدخال من سجلات الأحداث (logs). أو نظام إدارة علاقات العملاء (CRM) يحاول عرض كل جهة اتصال في جدول واحد قابل للتمرير. في React، يبدو الكود المكتوب لبناء هذا الأمر غير ضار تماماً؛ حيث تقوم بعمل map عبر مصفوفة، وتُرجع بعض الـ JSX، وتترك الإطار (framework) يقوم بعمله. كل شيء يعمل بشكل جيد في بيئة التطوير مع مائة صف، ولكن بمجرد وصول بيانات الإنتاج (production data)، تصبح الصفحة ثقيلة للغاية.
المتصفح ليس كسولاً؛ إنه يفعل بالضبط ما طلبته منه، وهذه هي المشكلة. كل صف يصبح عقدة DOM. كل عقدة يتم تنسيقها (styled)، وتحديد تخطيطها (laid out)، ورسمها (painted)، وتتبعها في الذاكرة. عندما تقوم بالتمرير، يعيد المتصفح حساب المواقع للشجرة بأكملها، وليس فقط الجزء الذي تراه. تتراكم مستمعات الأحداث (Event listeners)، ويرتفع استهلاك الذاكرة بشكل حاد. وفي النهاية، يختنق الخيط الرئيسي (main thread) لفترة كافية تجعل الواجهة تتوقف عن الاستجابة للنقرات، أو ضغطات المفاتيح، أو حتى التمرير نفسه. لم يتوقف التطبيق عن العمل (crash) بالمعنى التقني، ولكن بالنسبة للمستخدم الجالس أمامه، فإن التجربة معطلة تماماً.
يحدث هذا لأن المتصفح يحاول الاحتفاظ بكل عنصر في الذاكرة النشطة في وقت واحد. قد يكون React فعالاً في إنشاء أوصاف افتراضية لواجهة المستخدم (UI) الخاصة بك، ولكن بمجرد أن تصبح هذه الأوصاف عقدًا حقيقية في المستند، فإن تكلفتها تكون مثل الـ HTML المكتوب يدوياً. لا يوجد مخرج طوارئ في الإطار نفسه؛ أنت بحاجة إلى تغيير هيكلي في كيفية تغذية القائمة إلى الـ DOM.
ما الذي تعنيه الـ Virtualization حقاً
الـ Virtualization هو ذلك التغيير الهيكلي. فبدلاً من مطالبة React برسم (render) المصفوفة بأكملها، تقوم برسم العناصر التي يمكن أن تتسع داخل منطقة العرض (viewport) فقط، بالإضافة إلى مساحة تخزين مؤقت (buffer) صغيرة فوقها وتحتها. بينما يقوم المستخدم بالتمرير، يتخلص التطبيق من العقد التي تخرج عن نطاق الرؤية ويقوم بإنشاء عقد جديدة تدخل من الحافة المقابلة. بالنسبة للمستخدم، لا تزال تبدو وكأنها قائمة واحدة مستمرة لأن إجمالي الارتفاع القابل للتمرير يتم الحفاظ عليه، عادةً من خلال عنصر حاوية (container) واحد طويل أو عنصر فاصل (spacer) محسوب بدقة. العناصر المرئية هي ببساطة نافذة تنزلق عبر مجموعة البيانات.
فكر في الأمر كشريط فيلم يمر عبر فتحة جهاز العرض (projector gate). يرى الجمهور حركة سلسة، لكن الآلية تضيء فقط الإطار الموجود حالياً في موضعه. أما بقية البكرة فتوجد في بكرات التغذية واللف، وليس في مسار الضوء. تعمل القوائم الافتراضية (Virtualized lists) بنفس الطريقة؛ مجموعة البيانات هي البكرة، ومنطقة العرض (viewport) هي الفتحة.
هذا ليس "التحميل الكسول" (lazy loading) بالمعنى التقليدي. فالتحميل الكسول يؤجل جلب البيانات حتى يقترب المستخدم منها. أما الـ Virtualization فيفترض أن لديك البيانات بالفعل، لكنك تختار بعناية أي الأجزاء يتم ترقيتها إلى عناصر DOM حقيقية. يمكن للتقنيتين العمل معاً، لكنهما تعالجان مشكلتين مختلفتين.
لماذا يبدو الفرق فورياً
تظهر الفوائد في أربعة مواضع، وكلها مرتبطة بنفس المبدأ الأساسي: أنت تتوقف عن دفع تكلفة ما لا يستطيع المستخدم رؤيته.
أوقات تحميل أولية أسرع. عندما يفتح المتصفح الصفحة، فإنه يرسم ربما خمسة عشر صفاً بدلاً من خمسة عشر ألفاً. يصل أول رسم ذي معنى (first meaningful paint) في وقت أقرب. وينخفض الوقت اللازم للتفاعل (time-to-interactive) لأن محرك JavaScript يقضي وقتاً أقل في إنشاء العقد وإرفاقها بالمستند.
استهلاك أقل للذاكرة. عقدة الـ DOM هي كائن مكلف. كل عقدة تحمل مراجع لقواعد التنسيق، ومقاييس التخطيط، وارتباطات الأحداث. إذا قمت بتقليص عدد العقد النشطة إلى بضع عشرات فقط، فسينخفض حجم استهلاك الذاكرة (memory footprint) بشكل هائل. وفي الأجهزة الضعيفة أو الجلسات الطويلة، يمكن لهذا وحده أن يمنع نظام التشغيل من إغلاق علامة التبويب (tab).
أداء تمرير سلس. مع وجود عقد أقل في الشجرة، يقضي المتصفح وقتاً أقل في مراحل التخطيط والرسم أثناء أحداث التمرير. يمكن لخيط المجمع (compositor thread) التعامل مع الحركة دون إعادة حساب هندسة المحتوى المخفي باستمرار. والنتيجة هي تمرير يظل أقرب إلى معدل تحديث الشاشة (refresh rate).
معدلات إطارات مستقرة. نظرًا لأن الخيط الرئيسي لم يعد غارقاً في أعمال التخطيط، فهناك مساحة لأنشطة أخرى. تظل الرسوم المتحركة سلسة، ويمكن معالجة استجابات الشبكة. ولا تتجمد واجهة المستخدم عند وصول بيانات جديدة لأن مسار الرسم لم يعد يمثل عنق زجاجة.
تنفيذ الأمر بشكل صحيح
في نظام React البيئي، توفر مكتبات مثل react-window ومكتبة react-virtualized الأكثر ثقلاً الآلية لهذا النمط. الفكرة الأساسية ثابتة: تقوم بتعريف مُصيّر للعناصر (item renderer)، وتمرر العدد الإجمالي للعناصر، وتقوم المكتبة بإدارة عمليات الحساب الخاصة بنظام النوافذ (windowing math). لكن التفاصيل هي ما يربك الناس.
أولاً، تحتاج الحاوية إلى ارتفاع محدد. إذا كانت القائمة تقع داخل عنصر أب يتمدد ليتناسب مع عناصره التابعة، فلن تتمكن تقنية الـ virtualization من حساب العناصر المرئية لعدم وجود حدود لنافذة العرض (viewport). يجب عليك تثبيت القائمة بارتفاع ثابت أو داخل حاوية flex ذات قيود معروفة.
ثانياً، حجم العناصر أمر بالغ الأهمية. الصفوف ذات الارتفاع الثابت هي الحالة الأبسط؛ حيث تضرب المكتبة ارتفاع الصف في الفهرس (index) وتعرف بالضبط مكان وضع كل عنصر. أما المحتوى متغير الارتفاع، مثل رسائل الدردشة التي تحتوي على صور مدمجة أو سلاسل التعليقات، فيجبر المكتبة على القياس بعد عملية الـ mount والضبط أثناء التشغيل. قد تسبب خطوة القياس هذه اهتزازاً في التمرير (scroll jitter) إذا حدثت في وقت متأخر جداً. إذا كانت بياناتك تسمح بذلك، فافرض ارتفاعات موحدة أو ارتفاعات دنيا. وإلا، فاستخدم variable-height virtualizer وتقبل التعقيد الإضافي.
ثالثاً، الـ overscanning هو صديقك. إن عرض ما يناسب الشاشة تماماً فقط سيؤدي إلى ظهور شرائط بيضاء فارغة عندما يقوم المستخدم بالتمرير بسرعة. تسمح لك معظم المكتبات بعرض بضعة عناصر إضافية فوق وتحت المنطقة المرئية. عادة ما يكون صفان أو ثلاثة من الـ overscan كافية لإخفاء الفواصل دون إثقال كاهل الـ DOM مرة أخرى.
رابعاً، لا تتجاهل خاصية key. في القائمة الافتراضية (virtualized list)، تُعاد استخدام عقد DOM أثناء التمرير. تمنع المفاتيح (keys) المستقرة React من التخمين الخاطئ أثناء عملية الـ reconciliation وتدمير الحالة (state) داخل مكونات الصفوف. إذا كانت صفوف قائمتك تحتوي على حقول إدخال (inputs) أو أزرار تبديل (toggles) أو أقسام قابلة للتوسيع، فإن المفاتيح السيئة ستفسد حالة واجهة المستخدم بطرق تبدو وكأنها أخطاء في طبقة البيانات، لكنها في الواقع أخطاء في عملية العرض (rendering).
أحد الفخاخ الخفية هو ميزة "البحث في الصفحة" بالمتصفح. نظرًا لأن العناصر المخفية لا توجد في الـ DOM، فلن يراها مربع بحث المتصفح. إذا كان مستخدموك يعتمدون على Ctrl+F لتحديد نص داخل قائمة كبيرة، فستحتاج إلى بناء بحث مخصص يعمل على مجموعة البيانات (dataset) وليس على المستند (document). يمكن أيضاً لقارئات الشاشة أن تفقد السياق إذا لم يتم التعامل مع دلالات القائمة (list semantics) بعناية، لذا اختبر باستخدام التقنيات المساعدة وفكر في إضافة إعلانات المناطق الحية (live region announcements) للتحميل الديناميكي.
متى يجب عليك تجنبها
تقنية الـ virtualization ليست مجانية؛ فهي تضيف ثقلاً للتبعيات، وحسابات للإحداثيات، وعبئاً إضافياً للقيود. إذا كانت قائمتك لا تتجاوز خمسين أو مائة عنصر، فيمكن للمتصفح التعامل مع ذلك دون مساعدة. قم بعرض القائمة بالكامل وانتقل لما بعد ذلك. ينطبق الأمر نفسه إذا كانت عناصر القائمة معقدة للغاية بشكل فردي. توفر لك الـ virtualization آلاف العقد، لكنها لا تستطيع حمايتك من عقدة واحدة تحتوي على رسم بياني ضخم أو عنصر فيديو. قم بإصلاح تضخم العناصر أولاً.
تجنب أيضاً الـ virtualization عندما لا تكون القائمة قابلة للتمرير. إذا كنت تستخدم نظام الصفحات (pagination) مع أزرار "التالي" و"السابق" وتعرض عشرين عنصراً فقط لكل صفحة، فلا يوجد شيء يحتاج إلى "نافذة" (windowing). لا تحقق هذه التقنية جدواها إلا عندما يتوقع المستخدم التمرير عبر تسلسل كبير ومتصل.
الخلاصة الحقيقية
الـ virtualization ليست مجرد اختيار لمكتبة برمجية، بل هي عقلية. فهي تجبرك على الإقرار بأن الـ DOM مورد محدود وليس لوحة غير محدودة. قبل إضافتها، افتح Chrome DevTools، وسجل ملف تعريف الأداء (performance profile)، وتأكد من أن وقت التخطيط (layout) أو الرسم (paint) هو المسبب الفعلي للمشكلة. بمجرد أن تعرف أن الـ DOM هو عنق الزجاجة، التزم بالقيود. ثبت الارتفاعات، وراقب المفاتيح، واستخدم الـ overscan باعتدال، واختبر إمكانية الوصول. إذا تم تنفيذها بشكل صحيح، فستحول القائمة الافتراضية جدار البيانات غير القابل للاستخدام إلى شيء يشعر المستخدم بأنه خفيف مثل عرض التمرير الأصلي (native scroll view). سيتوقف المتصفح عن المقاومة، وسيتوقف مستخدموك عن الانتظار، وسيعمل التطبيق أخيراً بالسرعة التي كنت تنوي بناءها.
