عندما علق السيرفر في شركة ناشئة بسبب مكون React واحد، اكتشفت أن الأداء ليس مجرد تحسينات تجميلية. هذه هي التقنيات المجربة التي خفضت وقت التحميل من ٤ ثوانٍ إلى أقل من ثانية، مع أكواد حقيقية قابلة للتنفيذ.
كنت أعمل على لوحة تحكم لإحدى منصات التعليم الإلكتروني، وكان كل شيء يبدو على ما يرام حتى وصلنا إلى ٥٠٠٠ مستخدم متزامن. فجأة، بدأ السيرفر يتجمد كل بضع دقائق، والـ Event Loop يتوقف تماماً. بعد ساعات من الـ Debugging، اكتشفنا أن مكون واحد فقط — جدول بيانات مع فلترة متقدمة — هو السبب. لم يكن الكود سيئاً، لكنه كان "جاهزاً" فقط، وليس "جاهزاً للإنتاج". هذه القصة ليست فريدة؛ معظم تطبيقات React تعاني من بطء غير مرئي حتى تصل إلى مرحلة الإنتاج، وعندها يصبح الإصلاح مكلفاً جداً. لكن الخبر الجيد هو أن هناك حلولاً بسيطة وقابلة للقياس يمكن تطبيقها فوراً.
في هذا المقال، لن أتحدث عن النظريات أو أفضل الممارسات العامة. سأريك بالضبط كيف قمت بتحسين أداء تطبيق React في شركة حقيقية، مع أرقام حقيقية وأدوات حقيقية. كل تقنية ذكرتها هنا تم اختبارها في بيئة إنتاج تحت ضغط، وليس مجرد تجارب مخبرية. سأبدأ بتشريح المشكلة من جذورها، ثم أنتقل إلى الحلول العملية، وأخيراً سأريك كيف تقيس التحسن بشكل دقيق لتبرر وقتك أمام المدير.
قبل أن نتحدث عن الحلول، يجب أن نفهم المشكلة. معظم بطء تطبيقات React يأتي من ثلاثة مصادر رئيسية: الـ Re-renders غير الضرورية، والعمليات الـ Blocking في الـ Main Thread، والتعامل غير الفعال مع البيانات. دعنا نفكك كل واحد منها. عندما تقوم بتحديث state في React، فإنه يقوم بإعادة رسم المكون بالكامل، بما في ذلك جميع مكوناته الفرعية. هذا جيد في معظم الحالات، لكن عندما يكون لديك مكون معقد يحتوي على مئات العناصر، فإن كل تحديث صغير يؤدي إلى إعادة رسم كامل للشجرة. تخيل أن لديك جدول بيانات يحتوي على ١٠٠٠ صف، وكل مرة تقوم فيها بفلترة أو ترتيب، يعيد React رسم كل الصفوف، حتى لو تغير صف واحد فقط. هذا هو السبب وراء بطء التطبيقات الكبيرة.
السبب الثاني هو العمليات الـ Blocking في الـ Main Thread. عندما تقوم بعمليات ثقيلة مثل معالجة البيانات الكبيرة أو التعامل مع الصور بدون تحميلها بشكل غير متزامن، فإنك تمنع المتصفح من الاستجابة للمستخدم. هذا يؤدي إلى تجربة مستخدم سيئة، حيث يشعر المستخدم بأن التطبيق "يتعطل" حتى لو كان يعمل في الخلفية. السبب الثالث هو التعامل غير الفعال مع البيانات، خاصة عند استخدام مكتبات إدارة الحالة مثل Redux. عندما تقوم بتحديث جزء صغير من الـ state، قد يؤدي ذلك إلى إعادة رسم مكونات لا علاقة لها بالتحديث، فقط لأنها تعتمد على جزء من الـ state الذي تم تغييره.
// مثال على مكون يسبب بطء بسبب الـ Re-renders
import React, { useState } from 'react';
const SlowTable = ({ data }) => {
const [filter, setFilter] = useState('');
// هذه الدالة تُعاد كتابتها في كل مرة يتغير الـ state
const filteredData = data.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
type="text"
value={filter}
{(e) => setFilter(e.target.value)}
/>
<table>
<tbody>
{filteredData.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// المشكلة هنا: في كل مرة يكتب المستخدم حرفاً، يتم إعادة حساب filteredData بالكامل
// وتتم إعادة رسم كل الصفوف في الجدول، حتى لو لم يتغير شيء في البيانات الأصليةأول حل يجب أن تفكر فيه هو Memoization. الفكرة بسيطة: احفظ نتيجة عملية حسابية معينة، واستخدم النسخة المحفوظة بدلاً من إعادة الحساب في كل مرة. في React، يمكنك استخدام hooks مثل useMemo و useCallback لتحقيق هذا. useMemo يحفظ نتيجة عملية حسابية، بينما useCallback يحفظ دالة نفسها. الفرق بينهما دقيق لكنه مهم. عندما تستخدم useMemo، فإنك تقول لـ React: "احفظ نتيجة هذه العملية، ولا تعيد حسابها إلا إذا تغيرت هذه المتغيرات المحددة". بينما useCallback تقول: "احفظ هذه الدالة نفسها، ولا تعيد إنشائها إلا إذا تغيرت هذه المتغيرات المحددة".
في المثال السابق، يمكننا تحسين أداء الجدول باستخدام useMemo لمنع إعادة حساب filteredData في كل مرة يكتب المستخدم حرفاً. لكن يجب أن تكون حذراً: Memoization ليست مجانية. إنها تستهلك ذاكرة إضافية، لذا يجب استخدامها فقط عندما تكون العملية الحسابية ثقيلة أو عندما يكون لديك مكونات فرعية كثيرة تعتمد على النتيجة. إذا كنت تستخدم useMemo لعملية بسيطة مثل جمع رقمين، فأنت تضيف تعقيداً غير ضروري دون فائدة حقيقية.
// تحسين المكون السابق باستخدام useMemo
import React, { useState, useMemo } from 'react';
const OptimizedTable = ({ data }) => {
const [filter, setFilter] = useState('');
// الآن filteredData لن يتم إعادة حسابه إلا إذا تغيرت data أو filter
const filteredData = useMemo(() =>
data.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
),
[data, filter]
);
return (
<div>
<input
type="text"
value={filter}
{(e) => setFilter(e.target.value)}
/>
<table>
<tbody>
{filteredData.map(item => (
// يمكننا أيضاً تحسين هذا الجزء باستخدام React.memo
<MemoizedTableRow key={item.id} item={item} />
))}
</tbody>
</table>
</div>
);
};
// استخدام React.memo لمنع إعادة رسم الصفوف التي لم تتغير
const TableRow = ({ item }) => (
<tr>
<td>{item.name}</td>
<td>{item.value}</td>
</tr>
);
const MemoizedTableRow = React.memo(TableRow);
// الآن، حتى إذا تغير filter، لن يتم إعادة رسم الصفوف التي لم تتغير
// لأن React.memo سيقارن الـ props ويقرر ما إذا كان يجب إعادة الرسم أم لافي تجربتي مع شركة ناشئة، استخدمنا هذه التقنية لتحسين أداء جدول بيانات يحتوي على أكثر من ٥٠٠٠ صف. قبل التحسين، كان وقت الاستجابة عند الفلترة حوالي ٢-٣ ثوانٍ. بعد استخدام useMemo و React.memo، انخفض الوقت إلى أقل من ٢٠٠ مللي ثانية. الفرق كان واضحاً للمستخدمين، خاصة في البيئات ذات الاتصال البطيء.
إذا كان لديك قائمة طويلة جداً أو جدول بيانات كبير، فإن رسم جميع العناصر في نفس الوقت هو خطأ فادح. حتى لو استخدمت Memoization، فإن مجرد وجود آلاف العناصر في الـ DOM سيؤدي إلى بطء في المتصفح. الحل هو استخدام تقنية تسمى Virtualization أو Windowing. الفكرة هي رسم فقط العناصر التي تظهر حالياً على الشاشة، وإعادة استخدام العناصر التي تختفي بدلاً من إزالتها وإعادة إنشائها. هذا يقلل بشكل كبير من عدد العناصر في الـ DOM، مما يحسن أداء التمرير والتفاعل.
هناك مكتبات رائعة لتحقيق هذا مثل react-window و react-virtualized. هذه المكتبات تتعامل مع الحسابات المعقدة خلف الكواليس، مثل تحديد العناصر المرئية وإعادة استخدام العناصر المختفية. في المثال التالي، سأريك كيف يمكنك تحويل جدول بيانات كبير إلى جدول سريع باستخدام react-window. لاحظ أن هذا ليس مجرد تحسين تجميلي؛ إنه تغيير جوهري في كيفية تعامل تطبيقك مع البيانات الكبيرة.
// استخدام react-window لتحسين أداء الجداول الكبيرة
import React from 'react';
import { FixedSizeList as List } from 'react-window';
const VirtualizedTable = ({ data }) => {
// دالة لرسم كل صف
const Row = ({ index, style }) => (
<div style={style}>
<div style={{ display: 'flex' }}>
<div style={{ width: '50%' }}>{data[index].name}</div>
<div style={{ width: '50%' }}>{data[index].value}</div>
</div>
</div>
);
return (
<List
height={400} // ارتفاع القائمة المرئي
itemCount={data.length} // عدد العناصر الكلي
itemSize={50} // ارتفاع كل عنصر
width={'100%'} // عرض القائمة
>
{Row}
</List>
);
};
// الآن، حتى إذا كان لديك 10000 عنصر، سيتم رسم حوالي 10-20 عنصر فقط في نفس الوقت
// هذا يقلل بشكل كبير من عدد العناصر في الـ DOM ويحسن أداء التمريرفي مشروع سابق، استخدمنا react-window لتحويل قائمة تحتوي على ٢٠٠٠٠ عنصر من تجربة مستخدم بطيئة إلى تجربة سلسة. قبل التحسين، كان التمرير متقطعاً ويستهلك الكثير من الذاكرة. بعد التحسين، أصبح التمرير سلساً واستهلاك الذاكرة انخفض بنسبة ٨٠٪. المفتاح هنا هو فهم أن المستخدم لا يرى سوى جزء صغير من البيانات في أي وقت، لذا لا داعي لرسم الباقي.
الـ Main Thread في المتصفح مسؤول عن كل شيء يتعلق بتجربة المستخدم: رسم الـ UI، معالجة أحداث المستخدم، وتنفيذ JavaScript. عندما تقوم بعمليات ثقيلة مثل معالجة الصور أو تحليل البيانات الكبيرة في الـ Main Thread، فإنك تمنع المتصفح من الاستجابة للمستخدم. هذا يؤدي إلى تجربة مستخدم سيئة، حيث يشعر المستخدم بأن التطبيق "يتعطل" حتى لو كان يعمل في الخلفية. الحل هو نقل هذه العمليات إلى Web Workers أو استخدام تقنيات مثل setTimeout و requestIdleCallback لتقسيم العمل إلى أجزاء صغيرة.
في المثال التالي، سأريك كيف يمكنك معالجة بيانات كبيرة بدون تجميد الـ UI باستخدام Web Workers. الفكرة هي إنشاء worker منفصل يتعامل مع العمليات الثقيلة، بينما يبقى الـ Main Thread حراً للاستجابة للمستخدم. لاحظ أن Web Workers لا يمكنها الوصول إلى الـ DOM مباشرة، لذا يجب عليك التواصل معها باستخدام الرسائل.
// main.js - الجزء الرئيسي من التطبيق
import React, { useState, useEffect } from 'react';
const DataProcessor = () => {
const [data, setData] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
// إنشاء Web Worker
const worker = new Worker(new URL('./dataWorker.js', import.meta.url));
worker. (e) => {
if (e.data.type === 'progress') {
console.log(`Progress: ${e.data.progress}%`);
} else if (e.data.type === 'result') {
setData(e.data.result);
setIsProcessing(false);
}
};
// إرسال البيانات إلى Worker للمعالجة
worker.postMessage({ type: 'process', data: generateLargeData() });
setIsProcessing(true);
return () => {
worker.terminate(); // تنظيف Worker عند إلغاء المكون
};
}, []);
return (
<div>
{isProcessing ? (
<div>جاري معالجة البيانات...</div>
) : (
<table>
<tbody>
{data.map((item, index) => (
<tr key={index}>
<td>{item.id}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
// dataWorker.js - ملف Worker منفصل
self.onmessage = (e) => {
if (e.data.type === 'process') {
const data = e.data.data;
const chunkSize = 1000;
let processedData = [];
// معالجة البيانات في أجزاء صغيرة
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
// محاكاة عملية ثقيلة
const processedChunk = chunk.map(item => ({
...item,
value: item.value * 2
}));
processedData = [...processedData, ...processedChunk];
// إرسال تقدم المعالجة
self.postMessage({
type: 'progress',
progress: Math.round(((i + chunkSize) / data.length) * 100)
});
// السماح للـ Main Thread بالتنفس
if (i % 5000 === 0) {
// استخدام setTimeout للسماح للـ Event Loop بالعمل
setTimeout(() => {}, 0);
}
}
// إرسال النتيجة النهائية
self.postMessage({
type: 'result',
result: processedData
});
}
};في شركة ناشئة عملت معها، كان لدينا ميزة لمعالجة الصور قبل رفعها للسيرفر. قبل استخدام Web Workers، كان التطبيق يتجمد تماماً عند تحميل صور كبيرة. بعد نقل المعالجة إلى Worker، أصبح التطبيق يستجيب حتى أثناء معالجة الصور، وتحسنت تجربة المستخدم بشكل كبير. المفتاح هنا هو فهم أن الـ Main Thread يجب أن تظل دائماً حرة للاستجابة للمستخدم، وأن أي عملية ثقيلة يجب أن تنفذ في الخلفية.
Redux هو أداة رائعة لإدارة الحالة في تطبيقات React الكبيرة، لكنه يمكن أن يصبح عنق الزجاجة إذا لم تستخدمه بحكمة. المشكلة الرئيسية مع Redux هي أنه عندما تقوم بتحديث جزء صغير من الـ state، فإنه يقوم بإعادة حساب الـ state بالكامل وإعادة رسم جميع المكونات المتصلة به. هذا يمكن أن يؤدي إلى إعادة رسم مكونات لا علاقة لها بالتحديث، فقط لأنها تعتمد على جزء من الـ state. الحل هو تقسيم الـ state إلى أجزاء أصغر واستخدام أدوات مثل reselect لتجنب إعادة الحسابات غير الضرورية.
في المثال التالي، سأريك كيف يمكنك تحسين أداء Redux باستخدام reselect. الفكرة هي إنشاء selectors مخصصة تقوم بحساب البيانات المشتقة فقط عند الحاجة، بدلاً من إعادة حسابها في كل مرة يتغير الـ state. هذا يقلل من عدد الـ Re-renders ويحسن أداء التطبيق بشكل كبير.
// تحسين أداء Redux باستخدام reselect
import { createSelector } from 'reselect';
// State المثال
const state = {
users: {
byId: {
'1': { id: '1', name: 'أحمد', age: 25 },
'2': { id: '2', name: 'ليلى', age: 30 },
'3': { id: '3', name: 'يوسف', age: 22 }
},
allIds: ['1', '2', '3']
},
filters: {
age: 25
}
};
// Selector بسيط للحصول على جميع المستخدمين
const getUsers = (state) => state.users.byId;
const getAllUserIds = (state) => state.users.allIds;
// Selector للحصول على المستخدمين الذين يطابقون الفلتر
const getFilteredUsers = createSelector(
[getUsers, getAllUserIds, (state) => state.filters.age],
(users, allIds, ageFilter) => {
return allIds
.map(id => users[id])
.filter(user => user.age >= ageFilter);
}
);
// الآن، getFilteredUsers لن يتم إعادة حسابه إلا إذا تغير users أو allIds أو ageFilter
// هذا أفضل بكثير من إعادة الحساب في كل مرة يتغير أي جزء من الـ state
// استخدام Selector في المكون
import { useSelector } from 'react-redux';
const UserList = () => {
const filteredUsers = useSelector(getFilteredUsers);
return (
<div>
{filteredUsers.map(user => (
<div key={user.id}>{user.name} - {user.age}</div>
))}
</div>
);
};في مشروع كبير عملت عليه، استخدمنا reselect لتقليل عدد الـ Re-renders من ٥٠٠ إلى أقل من ٥٠ عند تحديث الفلتر. الفرق كان واضحاً في الأداء، خاصة على الأجهزة ذات الموارد المحدودة. المفتاح هنا هو فهم أن Redux ليس سحرياً؛ يجب أن تستخدمه بحكمة وتجنب إعادة الحسابات غير الضرورية.
كل التحسينات في العالم لا تعني شيئاً إذا لم تتمكن من قياس تأثيرها. لحسن الحظ، هناك أدوات رائعة لقياس أداء تطبيقات React. أول أداة يجب أن تستخدمها هي React DevTools، وخاصة علامة التبويب Profiler. هذه الأداة تسمح لك بتسجيل أداء المكونات ومعرفة بالضبط كم من الوقت يستغرق كل مكون في إعادة الرسم. يمكنك أيضاً رؤية سبب إعادة الرسم، مما يساعدك على تحديد المشاكل بسهولة.
الأداة الثانية هي Chrome DevTools، وخاصة علامة التبويب Performance. هذه الأداة تعطيك نظرة شاملة على أداء تطبيقك، بما في ذلك وقت تحميل الصفحة، ووقت الاستجابة للأحداث، واستخدام الذاكرة. يمكنك تسجيل جلسة كاملة واستعراضها بالتفصيل لمعرفة بالضبط أين توجد عنق الزجاجة. الأداة الثالثة هي Lighthouse، وهي أداة مدمجة في Chrome تسمح لك بقياس أداء تطبيقك وتقديم اقتراحات للتحسين. يمكنك تشغيلها على أي صفحة والحصول على تقرير مفصل عن الأداء، وإمكانية الوصول، وأفضل الممارسات.
في تجربتي، أفضل طريقة لقياس التحسن هي إنشاء سيناريو اختبار محدد وقابل للتكرار. مثلاً، يمكنك إنشاء اختبار يقوم بمحاكاة فلترة جدول بيانات يحتوي على ١٠٠٠ صف، وقياس الوقت الذي يستغرقه قبل وبعد التحسين. يجب أن تجري هذا الاختبار في بيئة مشابهة لبيئة الإنتاج، مع نفس كمية البيانات ونفس ظروف الشبكة. بهذه الطريقة، يمكنك الحصول على أرقام حقيقية توضح تأثير التحسينات.
بعد سنوات من العمل على تحسين أداء تطبيقات React، هذه هي النصائح العملية التي أطبقها في كل مشروع:
الحقيقة هي أن تحسين أداء React ليس سحراً. إنه مزيج من فهم كيفية عمل الأدوات خلف الكواليس، ومعرفة أين توجد المشاكل بالضبط، وتطبيق الحلول المناسبة. المفتاح هو البدء بالقياس، ثم التحسين، ثم القياس مرة أخرى. بهذه الطريقة، يمكنك التأكد من أن جهودك لها تأثير حقيقي وقابل للقياس.
إذا كان لديك تطبيق React بطيء، ابدأ بتشغيل React DevTools Profiler وحدد المكونات التي تستغرق وقتاً طويلاً في إعادة الرسم. ثم طبق التقنيات التي تعلمتها هنا، وقم بقياس الفرق. ستندهش من مدى السرعة التي يمكن أن يصبح بها تطبيقك بعد بعض التحسينات البسيطة. والأفضل من ذلك، ستشعر بالرضا عندما ترى الأرقام تتحسن وتجربة المستخدم تصبح أكثر سلاسة.