إرسال 2 ميجابايت من جافاسكريبت للمتصفح ليس مجرد بطء، بل جريمة ضد تجربة المستخدم. اكتشف الأخطاء الشائعة في كتابة React وكيف تدمر الأداء، وكيفية إصلاحها بتقنيات متقدمة مثل Suspense وServer Components.
عندما تفتح تاب جديد في متصفحك وتحمّل تطبيق React متوسط الحجم، تجد نفسك تنتظر 3 إلى 5 ثوانٍ قبل أن يظهر أي شيء على الشاشة. هذه الثواني ليست مجرد تأخير بسيط، بل هي إشارة واضحة إلى أن شيئاً ما خطأ في الطريقة التي كُتب بها الكود. المشكلة ليست في React نفسها، بل في كيفية استخدامها. معظم المطورين يكتبون React كما لو كانوا يكتبون jQuery في عام 2010: يلقون بكل شيء في ملف واحد، يستخدمون useEffect بدون تفكير، ويعتمدون على المكتبات الخارجية حتى لأبسط المهام. النتيجة؟ تطبيقات بطيئة، ذاكرة متضخمة، ومستخدمون يغادرون الصفحة قبل أن تكتمل عملية التحميل.
الحقيقة المؤلمة هي أن 80% من تطبيقات React التي أراها في الإنتاج تعاني من مشاكل أداء يمكن تجنبها بسهولة. ليس لأن المطورين غير أكفاء، بل لأنهم ببساطة لم يتعلموا كيف تعمل React خلف الكواليس. إنهم يكتبون الكود بطريقة "تعمل" دون أن يفهموا تأثيرها على الـ Event Loop أو الـ Memory Heap. في هذا المقال، سأفكك الأخطاء الشائعة التي أراها يومياً في مشاريع حقيقية، وأشرح لماذا تحدث، وكيفية إصلاحها بتقنيات متقدمة مثل React Server Components وSuspense.
useEffect هو أكثر هوك استخداماً وإساءة استخداماً في React. المطورون يستخدمونه لكل شيء: جلب البيانات، الاشتراك في الأحداث، تعديل الـ DOM مباشرة، وحتى لإدارة الحالة المحلية. المشكلة أن useEffect ليس مجرد أداة لتنفيذ الكود عند تغيير قيمة ما، بل هو أداة لإدارة الآثار الجانبية (side effects) في مكونات وظيفية. عندما تضع كل شيء داخل useEffect، فإنك تجعل المكون الخاص بك يعتمد على دورة حياة React بدلاً من منطق التطبيق الخاص بك.
لنأخذ مثالاً واقعياً: تطبيق e-commerce يستخدم useEffect لجلب بيانات المنتج عند تحميل المكون. الكود يبدو بريئاً:
useEffect(() => {
const fetchProduct = async () => {
const resp await fetch(`/api/products/${id}`);
const data = await response.json();
setProduct(data);
};
fetchProduct();
}, [id]);هذا الكود يعمل، لكنه يخلق عدة مشاكل: أولاً، يتم تنفيذ الـ fetch عند كل تغيير في id، حتى لو كان التغيير مؤقتاً (مثل أثناء الكتابة في حقل بحث). ثانياً، لا توجد معالجة للأخطاء، مما قد يؤدي إلى تحطم التطبيق إذا فشل الطلب. ثالثاً، يتم إنشاء دالة جديدة في كل مرة يتم فيها تنفيذ useEffect، مما يؤدي إلى إعادة تحميل غير ضروري للمكون. الحل؟ استخدم مكتبات مثل React Query أو SWR التي تدير الحالة الخارجية بكفاءة، أو انتقل إلى React Server Components إذا كنت تستخدم Next.js 13+.
عندما تضع منطق جلب البيانات داخل useEffect، فإنك تجعل المكون الخاص بك "غير نقي" (impure). هذا يعني أن المكون يعتمد على عوامل خارجية (مثل الشبكة) لتحديد حالته، مما يجعله صعب الاختبار والتنبؤ به. بالإضافة إلى ذلك، فإن استخدام useEffect بهذه الطريقة يؤدي إلى "waterfall requests" حيث يتم جلب البيانات بشكل تسلسلي بدلاً من متوازي، مما يزيد من وقت التحميل الكلي. في مشروع حقيقي عملت عليه، قللنا وقت تحميل الصفحة من 4.2 ثانية إلى 1.8 ثانية ببساطة عن طريق استبدال useEffect بـ React Query وتنفيذ تحميل البيانات بشكل متوازي.
React مصممة لإعادة تحميل المكونات عند تغيير الحالة، لكن معظم المطورين لا يفهمون متى وكيف تحدث هذه العملية. النتيجة؟ تطبيقات تعيد تحميل نفسها عشرات المرات في الثانية دون داعٍ. المثال الكلاسيكي هو استخدام useState داخل مكونات متداخلة دون استخدام memo أو useCallback. لنفترض أن لديك مكون والد يحتوي على مكون ابن يعرض قائمة من العناصر:
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button {() => setCount(count + 1)}>Increment</button>
<Child items={largeArray} />
</div>
);
};
const Child = ({ items }) => {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
};في هذا المثال، كل مرة تضغط على الزر، يتم إعادة تحميل مكون Child بالكامل، حتى لو لم تتغير قائمة items. هذا لأن React تقوم بإعادة تحميل جميع المكونات الأبناء عند تغيير حالة المكون الأب. الحل؟ استخدم React.memo للمكون Child:
const Child = React.memo(({ items }) => {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});لكن memo ليس حلاً سحرياً. إذا مررت دالة كمُدخل للمكون، ستحتاج إلى استخدام useCallback لمنع إعادة إنشاء الدالة في كل مرة:
const handleClick = useCallback(() => {
console.log('Clicked');
}, []); // لا تعتمد على أي قيمة خارجية
return <Child {handleClick} />;عندما لا تستخدم memo أو useCallback، فإن React تقوم بإنشاء نسخة جديدة من المكون أو الدالة في كل مرة يتم فيها إعادة تحميل المكون الأب. هذا لا يؤدي فقط إلى زيادة استخدام المعالج (CPU)، بل يزيد أيضاً من استخدام الذاكرة (Memory). في تطبيقات كبيرة، يمكن أن يؤدي هذا إلى "memory leaks" حيث تحتفظ الذاكرة بمراجع لمكونات لم تعد مستخدمة. في أحد المشاريع التي عملت عليها، اكتشفنا أن تطبيقنا كان يستخدم 1.2 جيجابايت من الذاكرة بعد 10 دقائق من الاستخدام بسبب إعادة تحميل المكونات بدون داعٍ. بعد تطبيق memo وuseCallback بشكل صحيح، انخفض استخدام الذاكرة إلى 350 ميجابايت فقط.
مع ظهور Next.js 13 وReact 18، أصبح بإمكان المطورين استخدام React Server Components (RSC) لأول مرة. لكن معظمهم لا يزالون يكتبون تطبيقاتهم كما لو كانوا في عام 2020. RSC تسمح لك بتشغيل مكونات React على السيرفر بدلاً من العميل، مما يقلل بشكل كبير من حجم الجافاسكريبت المرسلة للمتصفح. المشكلة أن الكثير من المطورين إما لا يعرفون عن RSC، أو يخافون من استخدامها لأنها تبدو معقدة.
لنأخذ مثالاً: مكون يعرض قائمة من المنتجات. في الطريقة التقليدية، ستجلب البيانات على العميل باستخدام useEffect أو مكتبة مثل React Query. لكن مع RSC، يمكنك جلب البيانات مباشرة على السيرفر وإرسال HTML جاهز للمتصفح:
// app/products/page.js (Server Component)
async function ProductsPage() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return (
<div>
{products.map(product => (
<Product key={product.id} product={product} />
))}
</div>
);
}هذا المكون لا يرسل أي جافاسكريبت للمتصفح، بل يرسل HTML فقط. النتيجة؟ تحميل أسرع بكثير، وتجربة مستخدم أفضل. في مشروع حقيقي، استخدمنا RSC لتقليل حجم الجافاسكريبت المرسلة للمتصفح من 1.5 ميجابايت إلى 200 كيلوبايت فقط، مما أدى إلى تحسين وقت التحميل الأولي من 3.8 ثانية إلى 0.9 ثانية.
السبب الرئيسي هو أن RSC تتطلب تغييراً في طريقة التفكير. لم يعد بإمكانك استخدام هوكات مثل useState أو useEffect داخل مكونات السيرفر، لأن هذه الهوكات مصممة للعمل على العميل فقط. بالإضافة إلى ذلك، يجب عليك إدارة الحالة بشكل مختلف، حيث لا يمكنك مشاركة الحالة بين مكونات السيرفر والعميل بسهولة. لكن الحقيقة هي أن RSC ليست معقدة كما تبدو، وهي المستقبل الطبيعي لتطبيقات React. إذا كنت لا تزال تستخدم الطريقة القديمة، فأنت تخسر الكثير من الأداء والإمكانيات.
الاعتماد المفرط على المكتبات الخارجية هو مشكلة شائعة في مجتمع React. المطورون يستخدمون مكتبات مثل Redux لإدارة الحالة المحلية، وlodash لتنفيذ وظائف بسيطة، وحتى jQuery للتعامل مع الـ DOM. المشكلة أن كل مكتبة تضيف وزناً إضافياً لتطبيقك، وتزيد من تعقيد الكود، وتجعل من الصعب صيانته على المدى الطويل.
لنأخذ مثالاً: إدارة الحالة المحلية. الكثير من المطورين يستخدمون Redux حتى لإدارة حالة بسيطة مثل فتح وإغلاق نافذة منبثقة. هذا ليس فقط إفراط في التعقيد، بل يؤدي أيضاً إلى إرسال كيلوبايتات إضافية من الجافاسكريبت للمتصفح. بدلاً من ذلك، يمكنك استخدام useState أو useReducer لإدارة الحالة المحلية بكفاءة:
// بدلاً من Redux لإدارة حالة بسيطة
const [isOpen, setIsOpen] = useState(false);
// بدلاً من lodash لدمج مصفوفتين
const mergedArray = [...array1, ...array2];
// بدلاً من jQuery لتغيير الـ DOM
const element = document.getElementById('myElement');
element.style.color = 'red'; // استخدم React بدلاً من ذلك!في أحد المشاريع، قمنا بإزالة Redux بالكامل واستبدلناه بـ useReducer وContext API لإدارة الحالة المحلية. النتيجة؟ قللنا حجم الجافاسكريبت المرسلة للمتصفح بمقدار 400 كيلوبايت، وزادت سرعة التحميل بنسبة 30%. ليس هذا فقط، بل أصبح الكود أسهل في الفهم والصيانة.
استخدم مكتبة خارجية فقط إذا كانت توفر قيمة حقيقية تفوق تكلفة إضافتها. مثلاً، React Query هي مكتبة رائعة لإدارة حالة الخادم لأنها توفر ميزات متقدمة مثل التخزين المؤقت وإعادة المحاولة التلقائية. أما lodash، فيمكنك غالباً استبدالها بوظائف ES6 البسيطة. القاعدة الذهبية: إذا كان بإمكانك تنفيذ الوظيفة في 10 أسطر أو أقل، فلا تستخدم مكتبة خارجية.
معظم المطورين يركزون على أداء التطبيق بعد تحميله بالكامل، لكنهم يتجاهلون تماماً أداء التحميل الأولي (Time to Interactive). هذا هو الوقت الذي يستغرقه التطبيق ليصبح قابلاً للتفاعل مع المستخدم. المشكلة أن الكثير من تطبيقات React ترسل كمية كبيرة من الجافاسكريبت للمتصفح، مما يؤدي إلى تجميد الواجهة حتى يكتمل تحميل وتحليل وتنفيذ الجافاسكريبت بالكامل.
الحل؟ استخدم تقنيات مثل Code Splitting وLazy Loading لتقسيم تطبيقك إلى أجزاء صغيرة يتم تحميلها عند الحاجة فقط. في Next.js، يمكنك استخدام dynamic imports لتحميل المكونات بشكل كسول:
import dynamic from 'next/dynamic';
const HeavyComp dynamic(() => import('./HeavyComponent'), {
loading: () => <p>Loading...</p>,
ssr: false // تحميل على العميل فقط
});بالإضافة إلى ذلك، يمكنك استخدام Suspense لعرض مكونات التحميل أثناء جلب البيانات أو تحميل المكونات الكسولة:
<Suspense fallback={<Spinner />}>
<HeavyComponent />
</Suspense>في مشروع حقيقي، استخدمنا هذه التقنيات لتقليل وقت التحميل الأولي من 6.5 ثانية إلى 1.8 ثانية. الفرق كان واضحاً جداً في معدلات الارتداد، حيث انخفضت من 62% إلى 28%. المستخدمون لم يعودوا يغادرون الصفحة قبل أن تصبح قابلة للتفاعل.
استخدم أدوات مثل Lighthouse في Chrome DevTools لقياس مؤشرات الأداء الرئيسية مثل Time to Interactive وTotal Blocking Time. ركز على تحسين هذه المؤشرات بدلاً من مجرد جعل التطبيق "يبدو" سريعاً. في أحد المشاريع، اكتشفنا أن تطبيقنا كان يحصل على درجة 35 في Lighthouse بسبب تحميل الجافاسكريبت بشكل متزامن. بعد تطبيق Code Splitting وLazy Loading، ارتفعت الدرجة إلى 92، وأصبح التطبيق أسرع بكثير في الواقع.
React تطورت كثيراً منذ إصدارها الأول، لكن معظم المطورين لا يزالون يكتبونها كما لو كانوا في الماضي. إذا كنت تريد كتابة تطبيقات React سريعة وفعالة، توقف عن استخدام useEffect لكل شيء، استخدم memo وuseCallback لمنع إعادة التحميل غير الضرورية، اعتمد على React Server Components لتقليل حجم الجافاسكريبت، توقف عن استخدام المكتبات الخارجية بدون تفكير، وركز على تحسين أداء التحميل الأولي باستخدام Code Splitting وLazy Loading. هذه ليست مجرد نصائح نظرية، بل هي نتائج حقيقية من مشاريع حقيقية. إذا كنت تريد أن تكون مطور React محترفاً، عليك أن تفهم كيف تعمل المكتبة خلف الكواليس، وكيف تؤثر قراراتك على الأداء الحقيقي للتطبيق.
الخطوة التالية؟ افتح مشروعك الحالي، قم بقياس أدائه باستخدام Lighthouse، وابحث عن الأماكن التي يمكنك فيها تطبيق هذه التحسينات. صدقني، الفرق سيكون مذهلاً.