طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 23 · الوحدة 10: تحسين الأداء

تحسين أداء الواجهة

واجهة تفتح بسرعة وتستجيب فورًا ⏱ نحو 40 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تقيس الأداء بمؤشرات Web Vitals وأدوات المطوّر.
  • تفسّر متى يُعاد رسم المكوّن وكيف تمنع الرسم غير الضروري.
  • تستخدم memo و useMemo و useCallback في موضعها.
  • تقلل الطلبات بالتأجيل، والحجم بالتقسيم وتحسين الصور.

١قِس قبل أن تحسّن

التحسين بلا قياس يضيّع الوقت ويعقّد الكود. ابدأ بسؤال: ما الذي يشعر به المستخدم بطيئًا؟ ثم قِسه. Google تقيس تجربة المستخدم بثلاثة مؤشرات Core Web Vitals:

المؤشريقيسالهدف الجيدأشهر أسبابه
LCP أكبر رسم للمحتوىمتى ظهر أكبر عنصر (صورة أو عنوان)أقل من 2.5 ثانيةحزمة JavaScript كبيرة، صور ضخمة، خادم بطيء
INP التفاعل حتى الرسم التاليسرعة استجابة الصفحة للنقر والكتابةأقل من 200 ملّي ثانيةحسابات ثقيلة أو إعادة رسم كثيرة
CLS إزاحة التخطيطقفز العناصر أثناء التحميلأقل من 0.1صور بلا أبعاد، محتوى يُحقن فوق الموجود

الأدوات: تبويب Lighthouse و Performance في أدوات مطوّر Chrome، وإضافة React DevTools وفيها Profiler يُظهر أي مكوّن أُعيد رسمه وكم استغرق.

٢متى يُعاد رسم المكوّن؟

React يعيد رسم المكوّن حين تتغير حالته، وحين يُعاد رسم أبيه، حتى لو لم تتغير خصائصه. هذا سريع عادة، لكنه يصبح مشكلة مع مكوّنات ثقيلة أو قوائم طويلة. هنا كل مكوّن يطبع في Console عند رسمه. اكتب في الحقل وراقب:

import { useState } from 'react'; function Header() {  console.log('رُسم Header');  return <h3>متجر الكلية</h3>;} function ProductList({ items }) {  console.log('رُسمت القائمة', items.length);  return <ul>{items.map(p => <li key={p}>{p}</li>)}</ul>;} export default function App() {  const [note, setNote] = useState('');  const items = ['قلم', 'دفتر', 'حقيبة'];  return (    <div>      <Header />      <input value={note} onChange={e => setNote(e.target.value)} placeholder="ملاحظة" />      <ProductList items={items} />    </div>  );}

كل حرف يُعيد رسم Header والقائمة رغم أنهما لا يعتمدان على الملاحظة إطلاقًا.

٣memo و useCallback و useMemo

الأداةتحفظمتى
React.memo(Comp)المكوّن: لا يُعاد رسمه إن لم تتغير خصائصهمكوّن ثقيل يُرسم كثيرًا بالخصائص نفسها
useMemo(() => قيمة, [deps])نتيجة حسابحساب مكلف، أو مصفوفة/كائن يُمرَّر لمكوّن memo
useCallback(fn, [deps])الدالة نفسهادالة تُمرَّر لمكوّن memo

لماذا نحتاج الأخيرين؟ لأن memo يقارن الخصائص بالمرجع: المصفوفة ['قلم', …] والدالة () => … تُنشآن من جديد مع كل رسم، فتبدوان «مختلفتين» وتُبطلان memo. هنا النسخة المحسّنة؛ اكتب وراقب Console:

import { useState, memo, useMemo, useCallback } from 'react'; const ProductList = memo(function ProductList({ items, onPick }) {  console.log('رُسمت القائمة');  return <ul>{items.map(p => <li key={p} onClick={() => onPick(p)}>{p}</li>)}</ul>;}); export default function App() {  const [note, setNote] = useState('');  const [picked, setPicked] = useState('');  const items = useMemo(() => ['قلم', 'دفتر', 'حقيبة'], []);  const onPick = useCallback((p) => setPicked(p), []);  return (    <div>      <input value={note} onChange={e => setNote(e.target.value)} placeholder="ملاحظة" />      <p>اخترت: {picked}</p>      <ProductList items={items} onPick={onPick} />    </div>  );}

الآن الكتابة لا تعيد رسم القائمة؛ تُرسم مرة واحدة فقط. والنقر على عنصر يعمل لأن onPick ثابتة.

٤حساب ثقيل: useMemo

هنا تصفية وترتيب 20 ألف منتج. بدون useMemo يُعاد الحساب مع كل رسم، حتى عند تبديل السمة (الذي لا علاقة له بالقائمة). مع useMemo لا يُعاد إلا إن تغيّر البحث:

import { useState, useMemo } from 'react'; const PRODUCTS = Array.from({ length: 20000 }, (_, i) => ({ id: i, name: 'منتج ' + i, price: (i * 37) % 500 })); export default function App() {  const [q, setQ] = useState('');  const [dark, setDark] = useState(false);   const result = useMemo(() => {    const t0 = performance.now();    const r = PRODUCTS.filter(p => p.name.includes(q)).sort((a, b) => a.price - b.price);    console.log(`حُسبت القائمة في ${(performance.now() - t0).toFixed(1)} ms`);    return r;  }, [q]);   return (    <div style={{ background: dark ? '#222' : '#fff', color: dark ? '#eee' : '#000', padding: 8 }}>      <input value={q} onChange={e => setQ(e.target.value)} placeholder="ابحث برقم" />      <button onClick={() => setDark(!dark)}>بدّل السمة</button>      <p>{result.length} نتيجة · الأرخص: {result[0]?.name}</p>    </div>  );}

اضغط «بدّل السمة» مرات: لا يظهر سطر حساب جديد في Console. احذف useMemo في «جرّب وعدّل» لترى الحساب يتكرر مع كل نقرة.

٥التأجيل Debounce: لا ترسل طلبًا مع كل حرف

بحث يرسل طلبًا للخادم مع كل حرف يكتبه المستخدم = عشرات الطلبات لكلمة واحدة. التأجيل ينتظر حتى يتوقف المستخدم عن الكتابة فترة قصيرة (مثل 400 ملّي ثانية) ثم يرسل مرة واحدة. Hook صغير يكفي:

import { useState, useEffect } from 'react';import { useDebounce } from './useDebounce.js'; export default function App() {  const [q, setQ] = useState('');  const dq = useDebounce(q, 400);  const [sent, setSent] = useState(0);   useEffect(() => {    if (!dq) return;    setSent(n => n + 1);    console.log('طلب بحث عن:', dq);        // هنا يكون fetch  }, [dq]);   return (    <div>      <input value={q} onChange={e => setQ(e.target.value)} placeholder="اكتب بسرعة…" />      <p>طلبات أُرسلت: <b className="sent">{sent}</b></p>    </div>  );}

اكتب كلمة كاملة بسرعة: يُرسل طلب واحد بعد توقفك، لا طلب لكل حرف.

٦حجم الحزمة والتحميل

التقنيةكيفتؤثر في
تقسيم الكودlazy(() => import('./Admin.jsx')) للصفحات الثقيلة والنادرةLCP
مكتبات أخفاستورد الدالة لا المكتبة كاملة؛ وافحص الحجم قبل التثبيتLCP
الصورصيغ WebP/AVIF، وأبعاد مناسبة، و loading="lazy" لما تحت الشاشةLCP
أبعاد ثابتةwidth و height للصور والإطاراتCLS
القوائم الطويلةالترقيم، أو «النافذة» Virtualization: رسم الظاهر فقط (مكتبات مثل react-window)INP
الضغط والتخزينgzip/brotli، و Cache-Control للأصول المجزّأة (الدرس 21)LCP
<img src="/img/hero-800.webp" alt="منتجات الأسبوع"     width="800" height="400"                // يمنع قفز الصفحة (CLS)     loading="lazy" decoding="async" />      // لا تُحمَّل حتى تقترب من الشاشة

توقّع وتحقّق

أي مؤشر يقيس سرعة استجابة الصفحة للنقر؟

Interaction.

متى يُعاد رسم مكوّن ابن عادة؟

الأب.

لماذا قد لا يعمل memo مع الخاصية onClick={() => …}؟

useCallback.

فائدة التأجيل debounce في البحث:

طلبات أقل.

الخلاصة

تحدٍّ برمجي: قائمة لا تُعاد بلا داع

متقدم

التطبيق فيه عدّاد وقائمة طلاب ثقيلة الرسم. كل نقرة على العداد تعيد رسم القائمة دون داع. حسّنه دون تغيير ما يظهر.

المطلوب:
  • لفّ StudentList بـ memo.
  • اجعل المصفوفة students والدالة onSelect ثابتتين بين الرسمات (useMemo و useCallback).
  • بعد ذلك: النقر على العداد لا يطبع «رُسمت القائمة» من جديد، والنقر على طالب ما زال يعمل.
👀 اعرض الناتج المطلوب (وجرّبه)
✓ التحقق الآلي من حلّك:
  • الكود يعمل بلا أخطاء في Console
  • تظهر القائمة بثلاثة طلاب
  • ثلاث نقرات على العداد لا تعيد رسم القائمة
  • النقر على طالب ما زال يعمل
  • يستخدم memo و useCallback

💡 تلميح 1
const StudentList = memo(function StudentList(…) {…});
💡 تلميح 2
المصفوفة: useMemo(() => [...], [])، والدالة: useCallback((s) => setSelected(s), []).

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

import { useState, memo, useMemo, useCallback } from 'react'; const StudentList = memo(function StudentList({ students, onSelect }) {  console.log('رُسمت القائمة');  return <ul>{students.map(s => <li key={s} onClick={() => onSelect(s)}>{s}</li>)}</ul>;}); export default function App() {  const [count, setCount] = useState(0);  const [selected, setSelected] = useState('');  const students = useMemo(() => ['ريم', 'فهد', 'سارة'], []);  const onSelect = useCallback((s) => setSelected(s), []);   return (    <div>      <button className="inc" onClick={() => setCount(count + 1)}>العداد: {count}</button>      <p className="sel">المختار: {selected}</p>      <StudentList students={students} onSelect={onSelect} />    </div>  );}
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس