تطبيق React بطيء؟ اكتشف التقنيات المجربة التي استخدمتها في مشاريع حقيقية لتسريع الأداء بنسبة 300%، مع أمثلة كود حقيقية وتحليلات للأداء خلف الكواليس.
في آخر مشروع لي مع شركة ناشئة في مجال التجارة الإلكترونية، كان لدينا تطبيق React يعرض ٥٠٠ منتج في الصفحة الرئيسية. عند فتح الصفحة على الهاتف، كان المستخدم ينتظر ٨ ثوانٍ كاملة قبل أن تظهر أي شيء. ليس فقط هذا مزعج، بل كان يكلفنا ١٢٪ من المبيعات يومياً. المشكلة لم تكن في السيرفر أو الإنترنت، بل في الطريقة التي كنا نكتب بها الكود في الـ Frontend. بعد أسبوع من التحليل باستخدام أدوات مثل React DevTools و Lighthouse، اكتشفنا أن ٦٠٪ من الوقت كان يضيع في إعادة رسم الـ DOM بلا داعٍ، و٣٠٪ في معالجة بيانات غير ضرورية. هذه ليست مشكلة أكاديمية، بل كارثة تجارية. دعنا نكسرها قطعة قطعة.
الـ React بطبيعته سريع، لكن المطورين غالباً ما يجعلونه بطيئاً دون قصد. السبب الرئيسي؟ عدم فهم كيف يعمل الـ Reconciliation تحت الغطاء. عندما تغير الـ State في مكون، React لا يعيد رسم الشجرة كاملة، بل يحاول معرفة أقل عدد من التغييرات المطلوبة في الـ DOM. لكن إذا كتبت الكود بطريقة تجعل React يعتقد أن كل شيء تغير، فسيعيد رسم كل شيء. هذا بالضبط ما كان يحدث في مشروعنا: مكونات كانت تعيد الرسم ٥٠ مرة في الثانية بسبب استخدام غير صحيح للـ useEffect و الـ props غير مستقرة.
أول شيء فعلته هو تشغيل React DevTools في وضع الـ Profiler. كانت المفاجأة أن مكون ProductCard كان يعيد الرسم ١٥ مرة عند تحميل الصفحة، رغم أنه لا يحتاج إلى ذلك. السبب؟ كنا نمرر دالة فلترة جديدة في كل مرة من المكون الأب، مما يجعل React يعتقد أن الـ props تغيرت. الحل؟ استخدام useCallback لتثبيت الدالة. لكن هذا ليس كافياً، لأن حتى مع useCallback، إذا كانت الدالة تعتمد على قيمة متغيرة من الـ State، فإنها ستتغير في كل مرة يتغير فيها الـ State. الحل الحقيقي هو استخدام useMemo داخل المكون الأب لتثبيت القيمة التي تعتمد عليها الدالة.
هناك خدعة أخرى لم تذكرها معظم المقالات: استخدام React.memo بحكمة. الكثير من المطورين يستخدمونه بشكل أعمى على كل مكون، وهذا خطأ. إذا كان المكون يحتوي على children أو يمرر props كثيرة، فإن الـ memoization قد يكلف أكثر مما يوفر. القاعدة الذهبية: استخدم React.memo فقط للمكونات التي تعيد الرسم كثيراً ولها props قليلة ومستقرة. في مشروعنا، استخدمنا React.memo فقط على مكونات مثل ProductCard و Button، مما قلل عدد عمليات إعادة الرسم من ١٥ إلى ٣ فقط.
// قبل التحسين: إعادة رسم غير ضرورية
const ProductList = ({ products }) => {
const [filter, setFilter] = useState('all');
// هذه الدالة تتغير في كل مرة يتغير filter
const filteredProducts = products.filter(p =>
filter === 'all' || p.category === filter
);
return (
<div>
{filteredProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
// بعد التحسين: استخدام useMemo لتثبيت النتيجة
const ProductList = ({ products }) => {
const [filter, setFilter] = useState('all');
// الآن filteredProducts لن يتغير إلا إذا تغير products أو filter
const filteredProducts = useMemo(() =>
products.filter(p => filter === 'all' || p.category === filter),
[products, filter]
);
// استخدام useCallback لتثبيت الدالة الممررة
const handleAddToCart = useCallback((productId) => {
// منطق إضافة المنتج إلى السلة
}, []); // لا تعتمد على أي قيمة متغيرة
return (
<div>
{filteredProducts.map(product => (
<ProductCardMemo
key={product.id}
product={product}
{handleAddToCart}
/>
))}
</div>
);
};
// استخدام React.memo لتجنب إعادة رسم المكون
const ProductCardMemo = React.memo(({ product, onAddToCart }) => {
return (
<div className="product-card">
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>أضف إلى السلة</button>
</div>
);
});الـ useEffect هو أحد أقوى أدوات React، لكنه أيضاً أحد أكثرها سوء استخدام. في مشروعنا، كان لدينا useEffect يتابع scroll events وينفذ فلترة معقدة في كل مرة يتحرك فيها المستخدم. النتيجة؟ الصفحة كانت تتجمد عند التمرير السريع. المشكلة هنا ليست في useEffect نفسه، بل في عدم فهم أن الـ Side Effects يجب أن تكون خفيفة جداً. إذا كان الـ Effect الخاص بك ينفذ عملية حسابية ثقيلة أو طلب API، فأنت تفعلها بطريقة خاطئة.
الحل؟ فصل الـ Side Effects إلى قسمين: Effects خفيفة تنفذ في كل مرة، و Effects ثقيلة تنفذ فقط عند الحاجة. استخدم useEffect للـ Effects الخفيفة، واستخدم مكتبات مثل react-query أو swr للـ Effects الثقيلة مثل طلبات API. في مثال الـ scroll، استخدمنا مكتبة lodash.debounce لتأخير تنفيذ الفلترة حتى يتوقف المستخدم عن التمرير لمدة ٣٠٠ مللي ثانية. هذا قلل عدد مرات تنفيذ الفلترة من ٦٠ مرة في الثانية إلى مرة واحدة فقط عند توقف المستخدم.
// خطأ شائع: useEffect ثقيل
useEffect(() => {
const handleScroll = () => {
// فلترة ثقيلة تنفذ في كل مرة يتحرك فيها المستخدم
const visibleProducts = products.filter(p => isElementInViewport(p));
setVisibleProducts(visibleProducts);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [products]);
// الحل: استخدام debounce لتقليل عدد مرات التنفيذ
useEffect(() => {
const handleScroll = debounce(() => {
const visibleProducts = products.filter(p => isElementInViewport(p));
setVisibleProducts(visibleProducts);
}, 300);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
handleScroll.cancel(); // إلغاء الـ debounce عند إزالة المكون
};
}, [products]);
// استخدام react-query للـ API Calls
const { data: products, isLoading } = useQuery('products', fetchProducts, {
staleTime: 5 * 60 * 1000, // البيانات تبقى صالحة لمدة ٥ دقائق
cacheTime: 30 * 60 * 1000, // البيانات تبقى في الكاش لمدة ٣٠ دقيقة
});هناك حالة خاصة يجب أن تعرفها: إذا كان الـ Effect الخاص بك يغير الـ DOM بطريقة تؤثر على تخطيط الصفحة (layout)، مثل تغيير حجم عنصر أو موقعه، فاستخدم useLayoutEffect بدلاً من useEffect. الفرق الرئيسي هو أن useLayoutEffect ينفذ بشكل متزامن بعد حساب الـ DOM ولكن قبل أن يرسم المتصفح الصفحة، بينما useEffect ينفذ بعد أن يرسم المتصفح الصفحة. في مشروعنا، كنا نستخدم مكتبة لرسم رسوم بيانية، وكانت الرسوم تومض عند التحميل لأننا كنا نستخدم useEffect. بعد التحويل إلى useLayoutEffect، اختفت الومضات تماماً.
في بداية المشروع، كان حجم الـ Bundle الخاص بنا ٣.٢ ميجابايت. هذا حجم كارثي لتطبيق ويب، خاصة في المناطق التي لا يتوفر فيها إنترنت سريع. بعد تحليل باستخدام webpack-bundle-analyzer، اكتشفنا أن ٦٠٪ من الحجم كان يأتي من مكتبات خارجية، خاصة مكتبة الرسوم البيانية التي كنا نستخدمها. المشكلة أن هذه المكتبة كانت تأتي بحجم ١.٨ ميجابايت كاملة، رغم أننا كنا نستخدم فقط جزء صغير منها.
الحل الأول كان استبدال المكتبة بمكتبة أخف مثل Chart.js، لكن حتى هذه كانت تأتي بحجم ٥٠٠ كيلوبايت. الحل الحقيقي كان استخدام ميزة Tree Shaking في webpack. معظم المكتبات الحديثة تدعم هذه الميزة، لكن يجب أن تكتب الكود بطريقة تسمح لـ webpack بإزالة الكود غير المستخدم. على سبيل المثال، بدلاً من استيراد المكتبة كاملة: ```javascript import * as Chart from 'chart.js'; ``` استخدم الاستيراد المحدد: ```javascript import { Line } from 'chart.js'; ``` هذا قلل حجم المكتبة من ٥٠٠ كيلوبايت إلى ١٢٠ كيلوبايت فقط.
الحل الثاني كان استخدام Code Splitting. بدلاً من تحميل كل الكود في الصفحة الأولى، قسمنا التطبيق إلى أجزاء صغيرة تُحمل عند الحاجة. استخدمنا React.lazy و Suspense لتحميل المكونات الكبيرة فقط عند الحاجة إليها. على سبيل المثال، صفحة لوحة التحكم كانت تُحمل فقط عندما يدخل المستخدم إليها، وليس عند فتح الصفحة الرئيسية. هذا قلل حجم الـ Bundle الأولي من ٣.٢ ميجابايت إلى ٨٠٠ كيلوبايت فقط، مما جعل الصفحة تُحمل في أقل من ثانية حتى على شبكات ٣G.
// قبل التحسين: تحميل كل شيء في البداية
import Dashboard from './Dashboard';
import Analytics from './Analytics';
import Settings from './Settings';
// بعد التحسين: Code Splitting باستخدام React.lazy
const Dashboard = React.lazy(() => import('./Dashboard'));
const Analytics = React.lazy(() => import('./Analytics'));
const Settings = React.lazy(() => import('./Settings'));
function App() {
return (
<div>
<Suspense fallback={<div>جار التحميل...</div>}>
<Route path="/dashboard" comp{Dashboard} />
<Route path="/analytics" component={Analytics} />
<Route path="/settings" component={Settings} />
</Suspense>
</div>
);
}
// تحسين إضافي: تحميل المكونات عند التمرير (Lazy Loading)
const ProductList = ({ products }) => {
const [visibleCount, setVisibleCount] = useState(10);
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + window.scrollY >=
document.body.offsetHeight - 500
) {
setVisibleCount(prev => prev + 10);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div>
{products.slice(0, visibleCount).map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};في بداية المشروع، استخدمنا Redux لإدارة كل الـ State في التطبيق. النتيجة؟ كان لدينا ١٥ reducer و ٢٠ action، وكان الكود يصبح معقداً جداً حتى لإجراء تغييرات بسيطة. بعد تحليل باستخدام أداة Redux DevTools، اكتشفنا أن ٨٠٪ من الـ State كان يُستخدم في مكون واحد فقط أو مكونين متجاورين. هذا يعني أننا كنا نستخدم Redux في أماكن لا يحتاج إليها.
الحل كان إعادة التفكير في إدارة الـ State. استخدمنا قاعدة بسيطة: إذا كان الـ State يُستخدم في مكون واحد أو مكونين متجاورين، استخدم useState أو useReducer. إذا كان الـ State يُستخدم في عدة مكونات بعيدة عن بعضها، استخدم Context. وإذا كان الـ State يحتاج إلى إدارة معقدة مثل Middleware أو Persistence، استخدم Zustand أو Redux. في مشروعنا، استبدلنا ٦٠٪ من استخدام Redux بـ Context و useState، مما قلل حجم الكود بنسبة ٤٠٪ وجعل التطبيق أسرع بكثير لأننا توقفنا عن إعادة رسم المكونات البعيدة عند تغيير الـ State المحلي.
// قبل التحسين: استخدام Redux لكل شيء
// actions.js
export const addToCart = (product) => ({
type: 'ADD_TO_CART',
payload: product
});
// reducers.js
export const cartReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TO_CART':
return [...state, action.payload];
default:
return state;
}
};
// بعد التحسين: استخدام useReducer للمكون المحلي
const CartC React.createContext();
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_TO_CART':
return [...state, action.payload];
default:
return state;
}
};
export const CartProvider = ({ children }) => {
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<CartContext.Provider value={{ cart, dispatch }}>
{children}
</CartContext.Provider>
);
};
// استخدام Zustand لإدارة الـ State المعقد
import create from 'zustand';
const useStore = create(set => ({
cart: [],
addToCart: (product) => set(state => ({
cart: [...state.cart, product]
})),
removeFromCart: (productId) => set(state => ({
cart: state.cart.filter(p => p.id !== productId)
})),
clearCart: () => set({ cart: [] })
}));
// استخدام Context للـ State الذي يحتاج إلى مشاركة بين مكونات بعيدة
const ThemeContext = React.createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};هناك قاعدة بسيطة: إذا كان الـ State الخاص بك يحتوي على منطق معقد أو يعتمد على الـ State السابق، استخدم useReducer. على سبيل المثال، إذا كان لديك سلة تسوق تحتوي على عمليات إضافة وحذف وتعديل للكميات، فإن useReducer سيكون أفضل بكثير من useState لأنك ستحتاج إلى منطق معقد لتحديث الـ State بناءً على الـ Action. في مشروعنا، استخدمنا useReducer لإدارة سلة التسوق، مما جعل الكود أكثر نظافة وسهولة في الصيانة.
في التجارة الإلكترونية، الصور هي كل شيء. لكن في مشروعنا، كانت الصور تأتي بحجم ٥ ميجابايت للقطعة الواحدة، وكانت تُحمّل كلها في الصفحة الرئيسية. النتيجة؟ الصفحة كانت تستغرق ١٥ ثانية للتحميل على شبكات ٣G. الحل لم يكن فقط ضغط الصور، بل أيضاً استخدام تقنيات حديثة مثل Lazy Loading و WebP Format و CDN متخصص للصور.
أول شيء فعلناه هو تحويل جميع الصور إلى صيغة WebP، التي توفر نفس الجودة بحجم أقل بنسبة ٣٠٪ مقارنة بـ JPEG. ثم استخدمنا مكتبة مثل sharp لضغط الصور بشكل أكبر دون فقدان ملحوظ في الجودة. بعد ذلك، استخدمنا ميزة Lazy Loading المدمجة في المتصفحات لتحميل الصور فقط عندما تصبح قريبة من منطقة العرض. هذا قلل عدد الصور المحملة في الصفحة الرئيسية من ٥٠ صورة إلى ٥ صور فقط، مما قلل وقت التحميل بنسبة ٧٠٪.
<!-- قبل التحسين: تحميل كل الصور في البداية -->
<img src="product-1.jpg" alt="منتج 1" />
<img src="product-2.jpg" alt="منتج 2" />
<!-- 48 صورة أخرى -->
<!-- بعد التحسين: Lazy Loading وصيغة WebP -->
<img
src="product-1.webp"
alt="منتج 1"
loading="lazy"
width="400"
height="400"
/>
<img
src="product-2.webp"
alt="منتج 2"
loading="lazy"
width="400"
height="400"
/>
<!-- بقية الصور -->الحل الآخر كان استخدام CDN متخصص للصور مثل Cloudinary أو Imgix. هذه الخدمات لا تخزن الصور فقط، بل تقوم بضغطها وتحسينها وتقديمها بصيغ مختلفة بناءً على جهاز المستخدم. على سبيل المثال، إذا كان المستخدم يستخدم هاتفاً قديماً، فإن CDN سيرسل صورة بحجم أصغر ودقة أقل. هذا قلل حجم الصور المرسلة بنسبة ٥٠٪ إضافية.
الفيديوهات هي أسوأ من الصور عندما يتعلق الأمر بالأداء. في مشروعنا، كان لدينا فيديو قصير في الصفحة الرئيسية بحجم ٥٠ ميجابايت. الحل؟ استخدمنا تقنية تسمى Adaptive Bitrate Streaming، حيث يُقسم الفيديو إلى أجزاء صغيرة ويُقدم بمعدلات بت مختلفة بناءً على سرعة الإنترنت لدى المستخدم. استخدمنا مكتبة مثل hls.js لتقديم الفيديو باستخدام بروتوكول HLS، مما جعل الفيديو يبدأ في أقل من ثانية حتى على شبكات بطيئة.
كل التحسينات التي ذكرتها لن تكون ذات قيمة إذا لم تقم بقياس تأثيرها. في مشروعنا، استخدمنا مجموعة من الأدوات لقياس الأداء قبل وبعد التحسينات. الأداة الأولى هي Lighthouse المدمجة في Chrome DevTools، التي تعطيك تقييم شامل للأداء وتجربة المستخدم. لكن Lighthouse وحده ليس كافياً، لأنه يقيس الأداء في بيئة مثالية. لذلك استخدمنا أيضاً أداة مثل WebPageTest لقياس الأداء في ظروف حقيقية، مثل شبكات ٣G و ٤G على أجهزة مختلفة.
الأداة الأخرى المهمة هي React DevTools، خاصة وضع الـ Profiler. هذا الوضع يسمح لك بتسجيل أداء المكونات ومعرفة كم مرة تعيد الرسم وكم تستغرق كل عملية رسم. في مشروعنا، اكتشفنا أن مكوناً واحداً كان يستغرق ٢٠٠ مللي ثانية لإعادة الرسم، مما كان يسبب تجمد الصفحة. بعد التحسين، قلصنا هذا الوقت إلى ١٠ مللي ثانية فقط.
تقرير Lighthouse يحتوي على الكثير من الأرقام، لكن هناك ثلاثة أرقام يجب أن تركز عليها: First Contentful Paint (FCP)، Time to Interactive (TTI)، و Largest Contentful Paint (LCP). FCP هو الوقت الذي يستغرقه المتصفح لعرض أول جزء من المحتوى، ويجب أن يكون أقل من ١.٨ ثانية. TTI هو الوقت الذي يستغرقه التطبيق ليصبح تفاعلياً بالكامل، ويجب أن يكون أقل من ٣.٨ ثانية. LCP هو الوقت الذي يستغرقه أكبر عنصر في الصفحة للتحميل، ويجب أن يكون أقل من ٢.٥ ثانية. في مشروعنا، كان FCP قبل التحسين ٤.٢ ثانية، وبعد التحسين أصبح ١.١ ثانية فقط.
بعد كل هذه التجارب، هناك ثلاث نصائح ذهبية لن أنساها أبداً: أولاً، لا تخمن أين المشكلة، قم بقياس الأداء دائماً قبل وبعد التحسينات. ثانياً، لا تحاول تحسين كل شيء دفعة واحدة، ركز على الجزء الذي يسبب أكبر بطء. ثالثاً، لا تستخدم مكتبات خارجية إلا إذا كنت بحاجة ماسة إليها، لأن كل مكتبة تأتي بثمن في الأداء. وأخيراً، تذكر أن الأداء ليس مجرد سرعة، بل هو أيضاً تجربة المستخدم. تطبيق سريع لا يشعر المستخدم بأنه سريع هو تطبيق فاشل. ركز على جعل التطبيق يتفاعل فوراً مع المستخدم، حتى لو كان لا يزال يحمّل بعض المحتوى في الخلفية.
إذا كنت تريد خطوة واحدة فقط لتحسين أداء تطبيق React الخاص بك اليوم، فابدأ بقياس الأداء باستخدام Lighthouse و React DevTools Profiler. اكتشف أين يضيع الوقت، ثم ركز على تحسين هذا الجزء فقط. لا تحاول تحسين كل شيء دفعة واحدة، لأنك ستفقد التركيز وستضيع وقتك في تحسين أجزاء لا تؤثر على الأداء بشكل كبير. ابدأ بالأجزاء التي تسبب أكبر بطء، ثم انتقل إلى الأجزاء الأصغر. بهذه الطريقة، ستحقق أقصى استفادة من وقتك وجهدك.