تحسين أداء React مع تدفّق الأحداث المستمر
الموضوعات: React · الأداء · الجداول
يساعد عرض الصفوف الظاهرة فقط، أو virtualization، على تقليل عدد عناصر DOM في الجداول الكبيرة. لكن عندما تصل بيانات جديدة باستمرار، نحتاج أيضًا إلى مراجعة طريقة استقبالها ومعالجتها قبل عرضها في الجدول.
قد تصل الأحداث عبر WebSocket أو مصدر آخر لتدفّق البيانات. نحلّل كل رسالة ونضيفها إلى مخزن البيانات، ثم نحدّث React لإعادة رسم الجزء الظاهر. إذا كرّرنا هذه الخطوات لكل حدث على حدة، فقد تبقى الواجهة بطيئة حتى مع تقليل الصفوف المعروضة.
ابدأ من مسار البيانات
قبل تغيير مكوّن الجدول، أرسم الطريق الذي تسلكه الرسالة:
stream → parse → normalize → store → selector → visible rowsثم أقيس كل خطوة. هل يستغرق تحليل الرسائل وقتًا طويلًا؟ هل يتغير مرجع القائمة كاملة مع كل رسالة؟ هل تتكرر عملية تصفية مكلفة؟ أم أن المتصفح يقضي وقته في layout؟ من السهل أن نضع اللوم على React لأن التقطّع يظهر في الواجهة، بينما يكون السبب الحقيقي قبلها بخطوتين.
هناك خطأ أراه كثيرًا: استدعاء setState لكل رسالة تصل. عندها يصبح معدل وصول البيانات هو نفسه معدل تحديث React، مع أن الاثنين لا يجب أن يرتبطا.
اجمع الرسائل ثم حدّث الواجهة
أضع الرسائل أولًا في buffer عادي خارج React، ثم أنقل ما تجمع مرة واحدة في إطار الرسم:
const buffer: SecurityEvent[] = [];
socket.onmessage = (event) => {
buffer.push(parse(event.data));
};
function drain() {
if (buffer.length > 0) {
store.append(buffer.splice(0, buffer.length));
}
requestAnimationFrame(drain);
}الفكرة ليست في requestAnimationFrame نفسه. المهم أن المصدر يستطيع إرسال مئة رسالة، بينما ترى الواجهة تحديثًا واحدًا. ويمكن ربط المخزن بـ React عبر useSyncExternalStore أو selector صغير حتى لا تعرف كل المكونات بكل تغيير.
بهذا يصبح لدينا معدّلان مختلفان: معدل استقبال الأحداث ومعدل تحديث الشاشة. وهذا الفصل هو أول شيء أريد ضمانه قبل تجربة أي تحسين آخر.
ما الذي تحسّنه virtualization؟
بعد ذلك يأتي دور virtualization. هي تقلل عدد عناصر DOM، وهذا مهم، لكنها لا تمنعك من تنفيذ عمل مكلف على آلاف السجلات قبل معرفة الصفوف التي ستظهر.
أؤجّل تنسيق الوقت وجلب أسماء القواعد وتجهيز نصوص البحث إلى أن نحتاج إلى عرض الصف. أخزن سجلًا صغيرًا ببنية ثابتة، ثم أشتق قيم العرض للصفوف المرئية فقط. إذا كانت عملية التحويل مكلفة ونقية، أحفظ نتيجتها بمفتاح واضح.
حتى أبسط نافذة عرض تكفي لشرح الفكرة:
const start = Math.floor(scrollTop / rowHeight);
const end = start + Math.ceil(viewportHeight / rowHeight) + overscan;
const visibleRows = rows.slice(start, end);لكن هذا الكود لا يجيب عن سؤالين مهمين: ماذا نفعل بالبيانات الأقدم؟ وكيف نحافظ على موضع المستخدم عندما تدخل صفوف جديدة؟
ضع حدًا للبيانات المحفوظة في الذاكرة
إذا كان الإدخال أسرع من العرض، فإن buffer بلا حد يؤجل المشكلة فقط. سيبقى الجدول سلسًا قليلًا، ثم يرتفع استهلاك الذاكرة ويبدأ التأخر بين آخر حدث وصل وآخر حدث يراه المستخدم.
هنا القرار ليس تقنيًا فقط. في بعض الشاشات يجب الاحتفاظ بكل حدث، لكن ليس من الضروري أن يبقى كل شيء داخل الذاكرة التي تغذي الجدول. يمكن إبقاء نافذة عمل محدودة وجلب الأقدم عند الطلب. ويمكن دمج بعض تحديثات الحالة المتكررة، لكن دمج أحداث الكشف قد يغيّر المعنى. تحدّد متطلبات المنتج البيانات التي يمكن استبعادها أو دمجها.
لذلك لا أقيس FPS وحده. أريد أيضًا معرفة طول الطابور، وعمر أقدم رسالة تنتظر، والفارق بين أحدث حدث في المصدر وأحدث حدث ظاهر. واجهة تتحرك بسلاسة وهي متأخرة دقيقة كاملة ليست واجهة سريعة.
ثبّت ما يقرأه المستخدم
عندما يصعد المحلل إلى صف قديم، يجب ألا تقفز الشاشة كلما دخلت أحداث جديدة في الأعلى. أحفظ معرّف أول صف ظاهر قبل الإضافة، ثم أصحح scrollTop بمقدار الارتفاع الذي أضيف فوقه.
الصفوف ثابتة الارتفاع تجعل هذا أسهل بكثير. لهذا أفضل غالبًا صفًا مختصرًا بارتفاع معروف ولوحة جانبية للتفاصيل. إذا كان توسيع الصف ضروريًا، فلا بد من قياس الارتفاع وربطه بمعرّف الصف. التخمين من طول النص ينتج أخطاء صغيرة تتراكم حتى تصبح القفزة واضحة.
ووضع المتابعة الحية يحتاج إلى حالة صريحة. إما أن يكون المستخدم عند آخر حدث، أو أنه ترك النهاية ليقرأ شيئًا أقدم. عندما يستأنف المتابعة، نعرض أحدث البيانات دفعة واحدة، لا بعدة قفزات متتالية.
متى أستخدم Web Worker؟
ليس أول خطوة. أنقل تحليل الرسائل إلى Web Worker فقط عندما يثبت القياس أن المعالج مشغول فعلًا بهذه العملية. وينطبق الشيء نفسه على React.memo: وضعها على كل خلية لا يعالج layout سيئًا أو selector يعيد قائمة جديدة كل مرة.
ترتيبي في التشخيص بسيط: أتأكد من عدد تحديثات React، ثم أنظر إلى scripting وlayout وpaint، وبعدها أنقل العمل الثقيل إلى worker إذا كان هو السبب. أختار التحسين المناسب بناءً على نتائج القياس.
القياس الذي يهم
أراقب زمن الإطار أثناء تدفق البيانات، لا بعد توقفه:
let last = performance.now();
function tick(now: number) {
const frame = now - last;
if (frame > 32) report("slow-frame", frame);
last = now;
requestAnimationFrame(tick);
}ثم أضع هذا الرقم بجانب تأخر الطابور وعمر أحدث حدث ظاهر. هذه الأرقام تشرح إن كانت الواجهة سريعة فعلًا، أم أنها تبدو سريعة لأنها بدأت تتجاهل ما يصلها. أريد أن يتمكّن المحلّل من متابعة التحقيق دون قفز في الصفوف أو تأخر في البيانات أو تجمّد للشاشة عند وصول دفعة جديدة. هذا ما أقيس به نجاح التحسين.