إرسال 5 ميجابايتات من جافاسكريبت للمتصفح ليس مجرد بطء، بل جريمة ضد تجربة المستخدم. اكتشف الأخطاء الشائعة في كتابة React وكيفية تجنبها بذكاء هندسي حقيقي.
في أحد المشاريع الكبيرة التي عملت عليها، كان لدينا تطبيق React يُحمّل 3.2 ميجابايت من جافاسكريبت قبل أن يظهر أي شيء على الشاشة. الفريق بأكمله كان يعتقد أن المشكلة في الـ API البطيء أو قاعدة البيانات الضخمة، لكن الحقيقة كانت أبسط بكثير: كنا نكتب React بطريقة خاطئة منذ اليوم الأول. ليس لأننا لا نعرف الأساسيات، بل لأننا وقعنا في فخاخ الأداء والتصميم التي يقع فيها معظم المطورين دون أن يدركوا ذلك. المشكلة ليست في React نفسها، بل في كيفية استخدامها - وكيف أن الممارسات الخاطئة تتسلل إلى الكود دون أن نلاحظ حتى تصبح كارثة حقيقية.
عندما نتحدث عن كتابة React بطريقة خاطئة، لا نقصد الأخطاء النحوية أو الـ Syntax Errors التي تظهر في الـ Console. نقصد الأخطاء المعمارية التي تجعل التطبيق بطيئاً، صعب الصيانة، ومعرضاً للـ Memory Leaks. هذه الأخطاء لا تظهر في مرحلة التطوير، بل تظهر عندما يصل التطبيق إلى آلاف المستخدمين الحقيقيين الذين يضغطون على الأزرار بسرعة، أو عندما يحاول فريق جديد الانضمام إلى المشروع ويجد نفسه أمام كود غير مفهوم. في هذا المقال، سنفكك هذه الأخطاء واحداً تلو الآخر، ونشرح لماذا تحدث، وكيف تؤثر على الأداء، وما هي الحلول العملية التي يمكن تطبيقها فوراً.
أحد أكثر الأخطاء شيوعاً في تطبيقات React هو إعادة إنشاء الـ State والـ Callbacks في كل مرة يحدث فيها رندر. هذا الخطأ ليس واضحاً في البداية لأنه لا يسبب أخطاء في الـ Console، لكن تأثيره على الأداء كارثي. عندما تقوم بتعريف دالة داخل الـ Component، مثل دالة handleClick، فإنها تُعاد إنشاؤها في كل رندر. إذا كان لديك مكون يحتوي على عدة أزرار وكل زر له دالة خاصة به، فأنت تقوم بإنشاء عدة دوال جديدة في كل مرة يتغير الـ State أو الـ Props. هذا ليس مجرد إهدار للذاكرة، بل يسبب أيضاً إعادة رندر غير ضرورية للمكونات الأبناء التي تعتمد على هذه الدوال كـ Props.
لنأخذ مثالاً عملياً: لديك مكون Parent يحتوي على مكون Child. المكون Child يستقبل دالة كـ Prop من المكون Parent. إذا كانت هذه الدالة تُعاد إنشاؤها في كل رندر، فإن المكون Child سيرى أن الـ Prop قد تغير، وبالتالي سيُعاد رندره حتى لو لم يتغير أي شيء آخر. هذا يسبب سلسلة من إعادة الرندر غير الضرورية التي يمكن أن تجعل التطبيق بطيئاً جداً، خاصة إذا كان لديك شجرة مكونات عميقة. المشكلة تزداد سوءاً عندما تستخدم مكتبات مثل Redux أو Context، حيث يمكن أن ينتشر هذا التأثير إلى مكونات بعيدة جداً في الشجرة.
// مثال خاطئ: الدالة تُعاد إنشاؤها في كل رندر
function ParentComponent() {
const [count, setCount] = useState(0);
// هذه الدالة تُعاد إنشاؤها في كل رندر
const handleClick = () => {
setCount(count + 1);
};
return <ChildComponent {handleClick} />;
}
// الحل: استخدام useCallback لمنع إعادة الإنشاء
function ParentComponentFixed() {
const [count, setCount] = useState(0);
// الدالة لن تُعاد إنشاؤها إلا إذا تغير count
const handleClick = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []); // لاحظ أن المصفوفة الفارغة تعني عدم الاعتماد على أي متغير خارجي
return <ChildComponent onClick={handleClick} />;
}لكن استخدام useCallback ليس حلاً سحرياً. إذا وضعت متغيرات في مصفوفة الاعتمادات بشكل غير صحيح، فقد ينتهي بك الأمر بدالة لا تُحدث قيمتها، مما يسبب سلوكاً غير متوقع. مثلاً، إذا كنت تعتمد على متغير count داخل الدالة، لكنك نسيت إضافته إلى مصفوفة الاعتمادات، فإن الدالة ستستخدم القيمة القديمة لـ count في كل مرة. هذا خطأ شائع جداً ويمكن أن يكون صعب التصحيح لأنه لا يظهر بوضوح في الـ Console.
useEffect هو أحد أقوى الـ Hooks في React، لكنه أيضاً أحد أكثرها سوء استخدام. الكثير من المطورين يستخدمون useEffect بدون فهم حقيقي لكيفية عمله خلف الكواليس، مما يؤدي إلى مشاكل مثل الـ Infinite Loops، أو تنفيذ التأثيرات الجانبية في الوقت الخطأ، أو حتى تحميل البيانات بشكل غير ضروري. المشكلة الأساسية هي أن useEffect يُشغل بعد كل رندر افتراضياً، وهذا يعني أنه يمكن أن يُشغل أكثر مما تحتاج، خاصة إذا كان لديك عدة حالات في الـ State تتغير بشكل متكرر.
لنأخذ مثالاً شائعاً: لديك مكون يعرض قائمة من البيانات التي تأتي من API. تريد تحميل هذه البيانات عند تحميل المكون لأول مرة، ثم إعادة تحميلها إذا تغير الـ ID الخاص بها. الكثير من المطورين يكتبون الكود التالي:
// مثال خاطئ: إعادة تحميل البيانات في كل رندر
function UserProfile({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUserData(data));
}); // لا توجد مصفوفة اعتماد!
return userData ? <div>{userData.name}</div> : <div>Loading...</div>;
}هذا الكود يبدو بريئاً، لكنه كارثة حقيقية. لأنه لا توجد مصفوفة اعتماد، فإن useEffect يُشغل بعد كل رندر، مما يعني أنه سيقوم بإرسال طلب إلى الـ API في كل مرة يتغير أي شيء في المكون. إذا كان لديك مكونات أخرى في الصفحة تسبب إعادة رندر، فإن هذا الكود سيقوم بإرسال طلبات لا نهاية لها إلى السيرفر. حتى إذا أضفت مصفوفة الاعتماد، فقد تقع في فخ آخر: إذا وضعت userId في مصفوفة الاعتماد، فإن useEffect سيُشغل كلما تغير userId، وهذا جيد، لكن ماذا لو تغير userId بشكل متكرر؟ قد ينتهي بك الأمر بإرسال عدة طلبات متزامنة إلى السيرفر، مما يسبب تحميلاً غير ضروري على الشبكة والـ Backend.
// الحل: استخدام مصفوفة الاعتماد بشكل صحيح والتحكم في الطلبات
function UserProfileFixed({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
let isCancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!isCancelled) {
setUserData(data);
}
});
return () => {
isCancelled = true;
};
}, [userId]); // فقط عندما يتغير userId
return userData ? <div>{userData.name}</div> : <div>Loading...</div>;
}في هذا الحل، أضفنا متغير isCancelled للتحكم في حالة المكون بعد إلغاء الـ Effect. هذا مهم جداً لأن الـ API قد يستغرق وقتاً طويلاً للرد، وإذا غادر المستخدم الصفحة قبل أن ينتهي الطلب، فإن setUserData قد يحاول تحديث مكون غير موجود بعد، مما يسبب خطأ في الـ Console. أيضاً، لاحظ أننا وضعنا userId في مصفوفة الاعتماد، مما يعني أن الـ Effect لن يُشغل إلا عندما يتغير userId. هذا يمنع إعادة تحميل البيانات بشكل غير ضروري.
الكثير من المطورين يعتقدون أن React سحرية وأنها ستجعل تطبيقهم سريعاً تلقائياً. الحقيقة هي أن React تعتمد على خوارزمية تسمى الـ Reconciliation Algorithm لتحديد ما الذي يجب إعادة رندره عند تغير الـ State أو الـ Props. هذه الخوارزمية ذكية، لكنها ليست سحرية، وإذا كتبت الكود بطريقة تجعلها تعمل بجد أكثر مما يجب، فإن تطبيقك سيصبح بطيئاً جداً. المشكلة تكمن في أن الكثير من المطورين لا يفهمون كيف تعمل هذه الخوارزمية خلف الكواليس، مما يجعلهم يكتبون كوداً يجعلها تعمل بشكل غير فعال.
الـ Reconciliation Algorithm تعتمد على مقارنة الـ Virtual DOM بالشجرة الحقيقية في المتصفح لتحديد التغييرات. عندما يتغير الـ State أو الـ Props، React تنشئ نسخة جديدة من الـ Virtual DOM وتقارنها بالنسخة السابقة. إذا وجدت اختلافات، فإنها تحدث فقط العناصر التي تغيرت في الـ DOM الحقيقي. هذه العملية سريعة جداً، لكنها ليست مجانية. إذا كان لديك شجرة مكونات كبيرة ومعقدة، فإن عملية المقارنة هذه يمكن أن تستغرق وقتاً طويلاً، خاصة إذا كانت المكونات لا تحتوي على مفاتيح فريدة (keys) أو إذا كانت الـ Keys تتغير بشكل عشوائي.
// مثال خاطئ: عدم استخدام keys بشكل صحيح
function UserList({ users }) {
return (
<ul>
{users.map(user => (
<li>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
// الحل: استخدام keys فريدة وثابتة
function UserListFixed({ users }) {
return (
<ul>
{users.map(user => (
<li key={user.id}> {/* استخدم معرف فريد وثابت */}
{user.name} - {user.email}
</li>
))}
</ul>
);
}في المثال الأول، لا توجد keys، مما يجعل React تعيد إنشاء كل عنصر li في القائمة عند كل رندر. هذا ليس مجرد إهدار للذاكرة، بل يسبب أيضاً فقدان الـ State المحلي للمكونات إذا كانت تحتوي على حالة داخلية (مثل الـ State في مكونات الأبناء). في المثال الثاني، استخدمنا key فريدة وثابتة (user.id)، مما يسمح لـ React بمقارنة العناصر بشكل أكثر كفاءة وتحديث فقط العناصر التي تغيرت فعلاً. هذا يجعل التطبيق أسرع بكثير، خاصة إذا كانت القائمة كبيرة وتحتوي على مكونات معقدة داخل كل عنصر.
الكثير من المطورين يستخدمون index كـ key عند عرض القوائم، وهذا خطأ شائع جداً. استخدام index كـ key يمكن أن يسبب مشاكل كبيرة إذا كانت القائمة قابلة للتغيير، مثل إضافة أو إزالة عناصر من القائمة. عندما تستخدم index كـ key، فإن React تعتمد على موقع العنصر في القائمة لتحديد هويته. إذا أضفت عنصراً جديداً في بداية القائمة، فإن جميع الـ keys ستتغير، مما يجعل React تعيد إنشاء كل عنصر في القائمة بدلاً من تحديث العنصر الجديد فقط. هذا يسبب إعادة رندر غير ضرورية وفقدان الـ State المحلي للمكونات.
// مثال خاطئ: استخدام index كـ key
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, index) => (
<li key={index}> {/* خطأ: index ليس فريداً وثابتاً */}
{todo.text}
</li>
))}
</ul>
);
}
// الحل: استخدام معرف فريد لكل عنصر
function TodoListFixed({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}> {/* استخدم معرف فريد */}
{todo.text}
</li>
))}
</ul>
);
}في أحد المشاريع التي عملت عليها، كان لدينا تطبيق React يحتوي على أكثر من 200 مكون، وكان حجم الـ Bundle النهائي يتجاوز 8 ميجابايتات. الفريق كان يعتقد أن هذا طبيعي لأن التطبيق كبير، لكن الحقيقة كانت مختلفة تماماً. المشكلة لم تكن في حجم التطبيق، بل في أننا كنا نحمّل كل شيء دفعة واحدة، حتى الأجزاء التي لا يحتاجها المستخدم فوراً. هذا ليس مجرد مشكلة أداء، بل مشكلة تجربة مستخدم حقيقية. المستخدم لا يريد أن ينتظر تحميل لوحة التحكم الكاملة إذا كان سيستخدم فقط صفحة تسجيل الدخول.
React توفر أدوات قوية للـ Code Splitting والـ Lazy Loading، لكن الكثير من المطورين يتجاهلونها إما لأنهم لا يعرفون عنها، أو لأنهم يعتقدون أنها معقدة. الحقيقة هي أن استخدام هذه الأدوات بسيط جداً ويمكن أن يقلل حجم الـ Bundle الأولي بشكل كبير، مما يجعل التطبيق أسرع بكثير. الـ Code Splitting يسمح لك بتقسيم الكود إلى أجزاء صغيرة تُحمّل عند الحاجة فقط، بدلاً من تحميل كل شيء دفعة واحدة. الـ Lazy Loading يسمح لك بتحميل المكونات فقط عندما يحتاجها المستخدم، مما يقلل من كمية الكود التي يجب تحميلها في البداية.
// بدون Code Splitting: تحميل كل شيء دفعة واحدة
import Dashboard from './Dashboard';
import Settings from './Settings';
import Profile from './Profile';
function App() {
return (
<Router>
<Route path="/dashboard" comp{Dashboard} />
<Route path="/settings" component={Settings} />
<Route path="/profile" component={Profile} />
</Router>
);
}
// مع Code Splitting والـ Lazy Loading
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Profile = lazy(() => import('./Profile'));
function App() {
return (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
<Route path="/profile" component={Profile} />
</Suspense>
</Router>
);
}في المثال الأول، كل المكونات تُحمّل دفعة واحدة عند تحميل التطبيق، حتى لو لم يستخدمها المستخدم أبداً. في المثال الثاني، المكونات تُحمّل فقط عندما يزور المستخدم الصفحة الخاصة بها، مما يقلل حجم الـ Bundle الأولي بشكل كبير. لاحظ أننا استخدمنا Suspense لعرض رسالة تحميل أثناء تحميل المكون، وهذا مهم لتجربة المستخدم. بدون Suspense، قد يرى المستخدم صفحة فارغة حتى ينتهي تحميل المكون، وهذا ليس جيداً.
عندما لا تستخدم الـ Code Splitting، فإن المتصفح يجب أن يحمّل كل الكود قبل أن يظهر أي شيء على الشاشة. هذا يعني أن المستخدم قد ينتظر عدة ثوانٍ قبل أن يرى الصفحة الأولى، خاصة إذا كان يستخدم اتصال إنترنت بطيئاً أو جهازاً ضعيفاً. مع الـ Code Splitting، يمكنك تحميل الجزء الأساسي من التطبيق أولاً، ثم تحميل الأجزاء الأخرى عند الحاجة. مثلاً، يمكنك تحميل صفحة تسجيل الدخول أولاً، ثم تحميل لوحة التحكم فقط عندما يدخل المستخدم إلى حسابه. هذا يجعل التطبيق يبدو أسرع بكثير، حتى لو كان الحجم الكلي للكود هو نفسه.
أيضاً، الـ Code Splitting يساعد في تقليل كمية الذاكرة التي يستخدمها التطبيق. عندما تحمّل كل شيء دفعة واحدة، فإن المتصفح يجب أن يحتفظ بكل الكود في الذاكرة، حتى لو لم يستخدمه المستخدم أبداً. مع الـ Code Splitting، يمكنك تحميل الكود فقط عندما يحتاجه المستخدم، ثم إزالته من الذاكرة عندما لا يحتاجه بعد الآن. هذا مهم جداً للتطبيقات الكبيرة التي تحتوي على الكثير من المكونات المعقدة.
في أحد المشاريع التي عملت عليها، كان الفريق يستخدم أكثر من 30 مكتبة خارجية، وكان حجم الـ Bundle النهائي يتجاوز 12 ميجابايتات. عندما قمنا بتحليل الـ Bundle، وجدنا أن 70% من الحجم يأتي من مكتبات لم نكن نستخدمها بالفعل، أو كنا نستخدم جزء صغير جداً منها. المشكلة ليست في استخدام المكتبات الخارجية نفسها، بل في الاعتماد عليها دون فهم تأثيرها على الأداء وحجم الـ Bundle. الكثير من المطورين يضيفون مكتبات جديدة عند مواجهة مشكلة بسيطة، دون أن يدركوا أن هذه المكتبة قد تضيف مئات الكيلوبايتات إلى حجم التطبيق.
لنأخذ مثالاً شائعاً: استخدام مكتبة مثل lodash. lodash مكتبة رائعة وتحتوي على الكثير من الأدوات المفيدة، لكن الكثير من المطورين يستخدمونها بطريقة خاطئة. بدلاً من استيراد الدوال التي يحتاجونها فقط، يقومون باستيراد المكتبة بأكملها، مما يضيف مئات الكيلوبايتات إلى حجم الـ Bundle. مثلاً، إذا كنت تريد استخدام دالة debounce فقط، فلا داعي لاستيراد المكتبة بأكملها. يمكنك استيراد الدالة فقط، أو حتى كتابتها بنفسك إذا كانت بسيطة.
// مثال خاطئ: استيراد المكتبة بأكملها
import _ from 'lodash';
function SearchComponent() {
const [query, setQuery] = useState('');
const handleSearch = _.debounce(() => {
console.log('Searching for:', query);
}, 300);
return <input {(e) => { setQuery(e.target.value); handleSearch(); }} />;
}
// الحل: استيراد الدوال المطلوبة فقط
import debounce from 'lodash/debounce';
function SearchComponentFixed() {
const [query, setQuery] = useState('');
const handleSearch = debounce(() => {
console.log('Searching for:', query);
}, 300);
return <input onChange={(e) => { setQuery(e.target.value); handleSearch(); }} />;
}
// أو الأفضل: كتابة الدالة بنفسك إذا كانت بسيطة
function debounceFn(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
function SearchComponentBest() {
const [query, setQuery] = useState('');
const handleSearch = debounceFn(() => {
console.log('Searching for:', query);
}, 300);
return <input onChange={(e) => { setQuery(e.target.value); handleSearch(); }} />;
}في المثال الأول، استوردنا المكتبة بأكملها، مما أضاف أكثر من 70 كيلوبايت إلى حجم الـ Bundle. في المثال الثاني، استوردنا الدالة المطلوبة فقط، مما قلل الحجم بشكل كبير. في المثال الثالث، كتبنا الدالة بأنفسنا، مما جعل الكود أخف وزناً وأكثر كفاءة. هذا ليس دائماً ممكناً، خاصة إذا كانت الدالة معقدة، لكن في كثير من الحالات، كتابة الدوال البسيطة بنفسك يمكن أن يكون أفضل من الاعتماد على مكتبات خارجية.
قبل إضافة أي مكتبة خارجية إلى مشروعك، اسأل نفسك هذه الأسئلة: هل حقاً أحتاج هذه المكتبة؟ هل يمكنني كتابة الكود بنفسي بسهولة؟ ما هو حجم المكتبة؟ هل هناك بدائل أخف وزناً؟ كم عدد الـ Downloads الأسبوعية للمكتبة؟ هل المكتبة نشطة وتتلقى تحديثات بانتظام؟ الإجابة على هذه الأسئلة يمكن أن تساعدك في اتخاذ قرار أفضل بشأن استخدام المكتبات الخارجية.
الكتابة الصحيحة لـ React ليست مجرد معرفة الـ Syntax أو الـ Hooks، بل فهم كيف تعمل خلف الكواليس وكيف تؤثر قراراتك على الأداء وتجربة المستخدم. من تجربتي، معظم الأخطاء تأتي من عدم فهم الأساسيات العميقة لـ React، مثل كيفية عمل الـ Reconciliation Algorithm، أو كيف يؤثر استخدام useEffect بشكل عشوائي على الأداء. الحل ليس في تعلم المزيد من المكتبات أو الأدوات، بل في فهم الأساسيات بشكل أعمق وتطبيقها بحكمة.
ابدأ دائماً بالتفكير في الأداء منذ اليوم الأول. لا تنتظر حتى يصبح التطبيق بطيئاً لتصحيح الأخطاء. استخدم أدوات مثل React DevTools وwebpack-bundle-analyzer لتحليل تطبيقك وفهم أين تكمن المشاكل. تجنب إعادة إنشاء الدوال والـ State في كل رندر، واستخدم useCallback وuseMemo بحكمة. اهتم بالـ Code Splitting والـ Lazy Loading لتقليل حجم الـ Bundle الأولي. وأخيراً، لا تعتمد على المكتبات الخارجية بشكل أعمى، بل افهم تأثيرها على تطبيقك واخترها بحكمة.
React أداة قوية، لكنها ليست سحرية. الطريقة التي تستخدمها بها تحدد مدى نجاح تطبيقك. إذا كتبت الكود بطريقة ذكية وواعية، فإن React ستجعلك تبدو كمطور ممتاز. إذا كتبت الكود بطريقة عشوائية، فإن React ستجعل تطبيقك بطيئاً ومعقداً. الخيار لك.