تطبيقات React البطيئة تقتل تجربة المستخدم وتزيد معدل الارتداد. اكتشف التقنيات العملية التي استخدمتها في شركات حقيقية لخفض زمن التحميل من ٤ ثوانٍ إلى ٨٠٠ مللي ثانية، مع أكواد قابلة للتنفيذ فوراً.
عندما يفتح المستخدم تطبيقك ويجد نفسه ينتظر أربع ثوانٍ كاملة ليظهر له زر تسجيل الدخول، فأنت لا تخسر مجرد ثوانٍ — أنت تخسر مستخدمين. الأرقام لا تكذب: دراسة من Google تظهر أن ٥٣٪ من المستخدمين يغادرون صفحة ويب إذا استغرق تحميلها أكثر من ثلاث ثوانٍ. لكن المشكلة الحقيقية ليست في الانتظار بحد ذاته، بل في ما يحدث خلف الكواليس: مكونات React التي تعيد الرسم بدون داعٍ، استعلامات API التي تسد الـ Event Loop، وحزم جافاسكريبت بحجم صور عالية الدقة. في هذا المقال، سأريك بالضبط كيف تحولنا في مشروعنا الأخير من ٣.٧ ثوانٍ تحميل أولي إلى ٧٨٠ مللي ثانية، باستخدام تقنيات قابلة للقياس وليست مجرد نصائح نظرية.
لنبدأ بتشريح المشكلة الأساسية: React مصمم لإعادة رسم الواجهة كلما تغيرت الحالة، وهذا رائع للمطورين لكنه كابوس للأداء. عندما تغير قيمة متغير واحد في useState، React يعيد تقييم الشجرة بأكملها من المكونات، حتى لو كان التغيير يؤثر على عنصر واحد في DOM. المشكلة تتفاقم عندما تستخدم مكتبات خارجية مثل Redux أو Context API، حيث يمكن لتغيير بسيط في متجر الحالة أن يؤدي إلى إعادة رسم مئات المكونات. الحل ليس في التخلي عن React، بل في فهم متى وكيف يجب أن يعيد رسم الواجهة — وهذا ما سنفعله هنا.
الاعتقاد السائد أن React.memo و useMemo هما حلول سحرية للأداء هو نصف الحقيقة فقط. الحقيقة الكاملة هي أن استخدامهما بدون فهم عميق لكيفية عمل الـ Reconciliation Algorithm في React يمكن أن يجعل الأداء أسوأ. مثلاً، إذا استخدمت React.memo على مكون يستقبل props تتغير باستمرار (مثل كائن جديد في كل render)، فأنت تضيف عبء مقارنة Props بدون أي فائدة حقيقية. في مشروعنا الأخير، وجدنا أن ٦٠٪ من المكونات التي استخدمنا معها React.memo كانت تعيد الرسم بنفس التكرار قبل وبعد التطبيق — ببساطة لأننا لم نفهم أن Props كانت تتغير في كل مرة.
الحل العملي هنا هو استخدام أداة مثل React DevTools Profiler لقياس عدد مرات إعادة الرسم لكل مكون قبل وبعد تطبيق التحسينات. القاعدة الذهبية التي نتبعها هي: إذا كان المكون يعيد الرسم أكثر من مرتين في الثانية بدون تغيير حقيقي في المخرجات، فهو مرشح جيد لـ React.memo. لكن يجب أن تكون Props مستقرة — وهذا يعني استخدام useMemo لتثبيت القيم المعقدة مثل الكائنات والمصفوفات. إليك مثال عملي:
// قبل التحسين: المكون يعيد الرسم في كل تغيير حالة الأب
const UserCard = ({ user }) => {
console.log('Rendering UserCard'); // يظهر في كل تغيير حالة الأب
return <div>{user.name}</div>;
};
// بعد التحسين: استخدام React.memo مع props مستقرة
const UserCard = React.memo(({ user }) => {
console.log('Rendering UserCard'); // يظهر فقط عند تغيير user
return <div>{user.name}</div>;
});
// في المكون الأب: تثبيت الكائن باستخدام useMemo
const ParentComp () => {
const [count, setCount] = useState(0);
const user = useMemo(() => ({ name: 'Ahmed', id: 123 }), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<UserCard user={user} />
</div>
);
};لاحظ كيف استخدمنا useMemo لتثبيت الكائن user. بدون هذا، كان React.memo سيفشل لأن كل render سينشئ كائن user جديد، مما يجعل المقارنة دائماً تعطي false. هذه التفاصيل الصغيرة هي ما يميز المطورين الذين يفهمون الأداء عن أولئك الذين يتبعون النصائح بشكل أعمى.
هناك اعتقاد خاطئ أن استخدام useMemo في كل مكان سيحسن الأداء. الحقيقة هي أن useMemo نفسه له تكلفة — فهو يضيف عبء مقارنة القيم السابقة مع الجديدة. في مشروع سابق، أضفنا useMemo لكل قيمة محسوبة في مكون كبير، فقط لنكتشف أن زمن Render زاد من ١٢ مللي ثانية إلى ٢٨ مللي ثانية. القاعدة التي نتبعها الآن هي: استخدم useMemo فقط للقيم المعقدة التي تتطلب حسابات ثقيلة (مثل فلترة مصفوفة كبيرة) أو للقيم التي تستخدم كProps لمكونات مضمّنة بـ React.memo. للمتغيرات البسيطة مثل الأرقام والنصوص، فإن تكلفة useMemo تفوق فوائدها.
في أحد المشاريع التي عملت عليها، كان تطبيق React يرسل ١.٢ ميجابايت من جافاسكريبت للمتصفح في التحميل الأولي، رغم أن الصفحة الرئيسية لا تستخدم إلا ٢٠٪ من هذا الكود. المشكلة؟ كنا نستخدم استيرادات مباشرة لكل شيء، بما في ذلك مكتبات ثقيلة مثل Chart.js و PDF.js التي لا تستخدم إلا في صفحات محددة. الحل كان في تقسيم الكود باستخدام React.lazy و Suspense، لكن مع استراتيجية ذكية لتجنب ما يسمى بـ "Waterfall Requests".
الخطأ الشائع هنا هو تقسيم الكود لكل مكون على حدة، مما يؤدي إلى سلسلة من الطلبات المتتالية التي تبطئ التحميل. بدلاً من ذلك، يجب تجميع المكونات ذات الصلة معاً في chunks كبيرة بما يكفي لتجنب طلبات الشبكة الكثيرة، لكن صغيرة بما يكفي لتحميلها فقط عند الحاجة. إليك كيفية فعل ذلك بشكل صحيح:
// تقسيم الكود بطريقة ذكية باستخدام React.lazy و Suspense
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Reports = React.lazy(() => import('./pages/Reports'));
const Settings = React.lazy(() => import('./pages/Settings'));
// استخدام Preload لتحميل المكونات قبل الحاجة إليها
const preloadDashboard = () => {
import('./pages/Dashboard').then(module => {
// المكون محمل في الذاكرة الآن
});
};
function App() {
return (
<Router>
<Suspense fallback={<Spinner />}>
<Route path="/dashboard" comp{Dashboard} />
<Route path="/reports" component={Reports} />
<Route path="/settings" component={Settings} />
</Suspense>
</Router>
);
}
// في الصفحة الرئيسية، نبدأ تحميل المكونات التي قد يحتاجها المستخدم لاحقاً
useEffect(() => {
preloadDashboard();
}, []);لاحظ كيف استخدمنا Preload لتحميل مكون Dashboard في الخلفية أثناء تواجد المستخدم في الصفحة الرئيسية. هذا يقلل زمن الانتظار عند الانتقال إلى صفحة Dashboard من ٨٠٠ مللي ثانية إلى ١٥٠ مللي ثانية فقط. لكن هناك مشكلة: إذا كان المستخدم لن ينتقل إلى Dashboard أبداً، فقد حمّلنا كوداً بدون داعٍ. الحل هو استخدام تحليلات المستخدم لتحديد الصفحات الأكثر زيارة وتحميلها مسبقاً فقط للمستخدمين الذين من المحتمل أن يزوروها.
في أحد المشاريع التي عملت عليها، كان لدينا مكون يعرض قائمة المستخدمين مع صورهم. المشكلة كانت أن تحميل الصور كان يحدث بشكل متزامن داخل useEffect، مما يؤدي إلى تجميد الواجهة لمدة تتراوح بين ٣٠٠ و٦٠٠ مللي ثانية في كل مرة. السبب؟ كنا نستخدم استعلامات API مباشرة داخل المكون بدون معالجة غير متزامنة صحيحة. الحل لم يكن فقط في استخدام async/await، بل في فهم كيفية عمل الـ Event Loop وكيفية تجنب الـ Blocking Calls تماماً.
الخطأ الأساسي هنا هو الاعتقاد أن وضع async أمام دالة useEffect يجعل كل شيء غير متزامن. الحقيقة هي أن الكود داخل useEffect يعمل بشكل متزامن حتى يصل إلى await، مما يعني أن أي كود قبل await سيؤثر على زمن Render. الحل هو نقل جميع العمليات الثقيلة خارج الـ Main Thread باستخدام Web Workers أو على الأقل تجنب تنفيذها أثناء Render الأولي. إليك كيفية إعادة هيكلة الكود بشكل صحيح:
// قبل التحسين: تحميل البيانات بشكل متزامن داخل useEffect
useEffect(() => {
const fetchUsers = async () => {
const resp await fetch('/api/users');
const data = await response.json();
setUsers(data); // هذا يحدث بعد Render الأول
};
fetchUsers();
}, []);
// المشكلة: إذا كان fetch بطيئاً، الواجهة ستتجمد حتى ينتهي
// بعد التحسين: استخدام useEffect مع تحميل مسبق
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let isMounted = true;
const fetchUsers = async () => {
try {
const response = await fetch('/api/users');
const data = await response.json();
if (isMounted) {
setUsers(data);
setIsLoading(false);
}
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
// تحميل البيانات بعد Render الأول
setTimeout(fetchUsers, 0); // يسمح للـ Event Loop بمعالجة Render أولاً
return () => {
isMounted = false; // تجنب تحديث الحالة بعد إلغاء المكون
};
}, []);الفرق الرئيسي هنا هو استخدام setTimeout مع تأخير صفر. هذا لا يضيف أي تأخير حقيقي، لكنه يسمح للـ Event Loop بمعالجة Render الأول قبل بدء تحميل البيانات، مما يمنع تجميد الواجهة. بالإضافة إلى ذلك، أضفنا متغير isMounted لمنع تحديث الحالة بعد إلغاء المكون، وهي مشكلة شائعة تؤدي إلى أخطاء في وحدة التحكم.
إذا كانت لديك عمليات تتطلب معالجة مكثفة (مثل معالجة الصور أو حسابات معقدة)، فإن استخدام Web Workers هو الحل الأمثل. في أحد المشاريع، كان لدينا مكون يعالج صور المستخدمين قبل تحميلها، مما كان يؤدي إلى تجميد الواجهة لمدة تصل إلى ثانيتين. بعد نقل المعالجة إلى Web Worker، أصبح زمن المعالجة صفراً بالنسبة للمستخدم — كل شيء يحدث في الخلفية. إليك مثال مبسط:
// worker.js
self. function(e) {
const { imageData } = e.data;
// معالجة الصورة (مثال: تحويل إلى أبيض وأسود)
const processedData = processImage(imageData);
self.postMessage({ processedData });
};
function processImage(data) {
// معالجة ثقيلة هنا
return data;
}
// في المكون React
const ImageProcessor = () => {
const [processedImage, setProcessedImage] = useState(null);
const handleImageUpload = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
const worker = new Worker('worker.js');
worker.postMessage({ imageData: event.target.result });
worker.onmessage = (e) => {
setProcessedImage(e.data.processedImage);
worker.terminate(); // إنهاء Worker بعد الانتهاء
};
};
reader.readAsDataURL(file);
};
return (
<div>
<input type="file" onChange={handleImageUpload} accept="image/*" />
{processedImage && <img src={processedImage} alt="Processed" />}
</div>
);
};لاحظ كيف أن المعالجة تحدث بالكامل في Worker، مما يترك الـ Main Thread حراً لمعالجة تفاعلات المستخدم. هذا النوع من التحسينات هو ما يجعل الفرق بين تطبيق يشعر بأنه "بطيء" وتطبيق يشعر بأنه "فوري".
في أحد المشاريع التي عملت عليها، لاحظنا أن ذاكرة المتصفح كانت تزيد بمقدار ٥٠ ميجابايت في كل مرة ينتقل فيها المستخدم بين الصفحات. المشكلة؟ كنا ننسى إلغاء الاشتراكات في useEffect عند إلغاء المكون، مما يؤدي إلى تراكم الـ Event Listeners والاشتراكات في الذاكرة. هذا النوع من الـ Memory Leaks هو القاتل الصامت لتطبيقات React — لا ترى أخطاء في وحدة التحكم، لكن التطبيق يصبح أبطأ تدريجياً حتى يتجمد تماماً.
السبب الرئيسي لهذه المشكلة هو عدم فهم دورة حياة المكونات في React. عندما تستخدم useEffect، يجب أن تعيد دالة تنظيف (cleanup function) لإلغاء أي اشتراكات أو مؤقتات أو استعلامات. إذا لم تفعل ذلك، فإن الـ Event Listeners والاشتراكات تبقى نشطة حتى بعد إلغاء المكون، مما يؤدي إلى تسرب الذاكرة. إليك كيفية تجنب هذه المشكلة:
// مشكلة: عدم إلغاء الاشتراك عند إلغاء المكون
useEffect(() => {
const subscription = someAPI.subscribe(data => {
setState(data);
});
// لا توجد دالة تنظيف — تسرب ذاكرة!
}, []);
// الحل: إضافة دالة تنظيف
useEffect(() => {
const subscription = someAPI.subscribe(data => {
setState(data);
});
return () => {
subscription.unsubscribe(); // إلغاء الاشتراك عند إلغاء المكون
};
}, []);
// مثال آخر: مؤقتات setInterval
useEffect(() => {
const interval = setInterval(() => {
console.log('This runs every second');
}, 1000);
return () => {
clearInterval(interval); // تنظيف المؤقت
};
}, []);القاعدة الذهبية هنا هي: إذا أنشأت أي شيء في useEffect يمكن أن يبقى نشطاً بعد إلغاء المكون، يجب أن تلغيه في دالة التنظيف. هذا يشمل الاشتراكات في مكتبات مثل Redux أو RxJS، والمؤقتات، والاستعلامات، وحتى الـ Event Listeners المضافة مباشرة إلى window أو document.
أفضل طريقة لاكتشاف تسربات الذاكرة هي استخدام أدوات مطوري المتصفح. في Chrome، يمكنك فتح تبويب Memory وتسجيل لقطة للذاكرة قبل وبعد تنفيذ سيناريو معين (مثل التنقل بين الصفحات). إذا رأيت أن الذاكرة لا تعود إلى قيمتها الأصلية بعد إلغاء المكونات، فهذا يعني وجود تسرب. إليك خطوات بسيطة للتشخيص:
في أحد المشاريع، كان لدينا قائمة تحتوي على ١٠,٠٠٠ عنصر، وكان زمن Render الأولي يصل إلى ٢.٥ ثانية. المشكلة لم تكن في حجم البيانات فقط، بل في أن React كان يحاول رسم ١٠,٠٠٠ عنصر في DOM دفعة واحدة، مما يؤدي إلى تجميد الواجهة. الحل؟ استخدام تقنية Virtualization لعرض العناصر المرئية فقط. المكتبات مثل react-window و react-virtualized تجعل هذا سهلاً، لكنها ليست مجرد أدوات لتحسين الأداء — إنها ضرورية لأي قائمة تحتوي على أكثر من ١٠٠ عنصر.
الفكرة الأساسية وراء Virtualization هي رسم العناصر التي تظهر حالياً في نافذة العرض فقط، وإعادة استخدام العناصر التي تخرج من العرض بدلاً من إعادة إنشائها. هذا يقلل عدد العناصر في DOM من آلاف إلى عشرات فقط، مما يحسن زمن Render بشكل كبير. إليك مثال عملي باستخدام react-window:
import { FixedSizeList as List } from 'react-window';
const LargeList = ({ items }) => {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name} - {items[index].value}
</div>
);
return (
<List
height={400}
itemCount={items.length}
itemSize={50} // ارتفاع كل عنصر
width={300}
>
{Row}
</List>
);
};
// الاستخدام
const App = () => {
const items = Array.from({ length: 10000 }, (_, i) => ({
name: `Item ${i}`,
value: Math.random() * 100
}));
return <LargeList items={items} />;
};لاحظ كيف أن المكون List يرسم فقط العناصر التي تظهر في نافذة العرض (حوالي ٨ عناصر في هذه الحالة)، بدلاً من ١٠,٠٠٠ عنصر. هذا يقلل زمن Render من ٢.٥ ثانية إلى أقل من ٥٠ مللي ثانية. لكن هناك مشكلة: إذا كانت العناصر ذات ارتفاعات مختلفة، فإن FixedSizeList لن يعمل بشكل جيد. في هذه الحالة، يمكنك استخدام VariableSizeList وتحديد دالة لحساب ارتفاع كل عنصر.
إذا أخذت شيئاً واحداً من هذا المقال، فليكن هذا: تحسين أداء React ليس عن استخدام مكتبات سحرية أو تقنيات معقدة، بل عن فهم كيفية عمل المكتبة تحت الغطاء واتخاذ قرارات ذكية بناءً على ذلك. ابدأ بقياس الأداء الحالي لتطبيقك باستخدام أدوات مثل Lighthouse و React DevTools Profiler، ثم طبق التحسينات التالية بالترتيب:
الخطوة التالية؟ افتح مشروعك الآن وقم بقياس زمن Render لكل مكون باستخدام React DevTools. ابحث عن المكونات التي تعيد الرسم أكثر من مرتين في الثانية وقم بتحسينها باستخدام التقنيات التي تعلمتها هنا. تذكر: الأداء ليس شيئاً تضيفه في النهاية — إنه شيء تبنيه من اليوم الأول.