Skip to main content
frontend21 ديسمبر 20257 دقائق قراءة

5 أخطاء في الأداء تقتل تطبيق React الخاص بك

معظم تطبيقات React أبطأ مما ينبغي أن تكون عليه. إليك 5 أخطاء الأداء الأكثر شيوعًا التي أراها في قواعد الأكواد الإنتاجية — وكيفية إصلاح كل منها.

reactperformancenextjs
5 أخطاء في الأداء تقتل تطبيق React الخاص بك

لقد راجعت مئات من قواعد أكواد React على مر السنين. مشاريع العملاء، مستودعات مفتوحة المصدر، أدوات داخلية، منتجات أولية للشركات الناشئة. تظهر نفس مشاكل الأداء مرارًا وتكرارًا. ليست حالات حافة غريبة — بل أخطاء أساسية يمكن الوقاية منها تتراكم حتى يصبح التطبيق بطيئًا ويبدأ المستخدمون في المغادرة.

إليك الخمسة التي أراها غالبًا، مع إصلاحات حقيقية يمكنك تطبيقها اليوم.

1. إعادة العرض غير الضرورية بسبب نقص التخزين المؤقت (Memoization)

هذه هي مشكلة الأداء الأكثر شيوعًا في تطبيقات React. عندما يعاد عرض مكون أب، يعاد عرض كل مكون ابن معه — حتى لو لم تتغير خصائصه (props). اضرب هذا عبر شجرة مكونات عميقة تحتوي على عشرات العناصر في قائمة، وسيكون لديك تطبيق يتلعثم مع كل ضغطة مفتاح.

المشكلة:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

في كل مرة يتغير فيها searchQuery، يتم تشغيل calculateStats مرة أخرى — على الرغم من أن user.transactions لم يتغير. كما يعاد عرض StatsPanel و TransactionList بدون سبب.

الحل:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

ثلاثة أشياء حدثت هنا:

  1. useMemo يمنع calculateStats من التشغيل في كل عملية عرض. يقوم بإعادة الحساب فقط عندما يتغير user.transactions بالفعل.
  2. useCallback يثبت مرجع معالج التغيير بحيث لا يرى SearchBar دالة "جديدة" في كل عملية عرض.
  3. memo يغلف المكونات الفرعية بحيث تتخطى إعادة العرض عندما تكون خصائصها متساوية بشكل سطحي.

كلمة تحذير: لا تغلف كل مكون بـ memo بشكل أعمى. قم بالتحليل أولاً باستخدام React DevTools Profiler. للتخزين المؤقت (Memoization) تكلفة — وهي المقارنة السطحية نفسها. طبقها حيثما يكون الأمر مهمًا: العمليات الحسابية المكلفة، القوائم الكبيرة، والمكونات التي تعاد عرضها بشكل متكرر بخصائص لم تتغير.

2. حزم ضخمة بسبب عدم تقسيم الكود

أرى بانتظام تطبيقات React التي تشحن كل شيء في حزمة JavaScript واحدة. لوحة تحكم المسؤول، صفحة الإعدادات، مركز المساعدة الذي نادرًا ما يزار — كل ذلك يتم تحميله مقدمًا عند زيارة الصفحة الأولى. أراد المستخدم رؤية صفحة هبوط، وقام متصفحه للتو بتنزيل 2 ميجابايت من JavaScript لا يحتاجها بعد.

المشكلة:

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

الاستيرادات الثابتة تعني أن المجمّع (bundler) يضم كل شيء في جزء واحد. بغض النظر عن المسار الذي يزوره المستخدم، فإنه يقوم بتنزيل كل شيء.

الحل:

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

React.lazy مع import() الديناميكي يخبر المجمّع بتقسيم هذه الصفحات إلى أجزاء منفصلة. يتم تحميلها عند الطلب عندما ينتقل المستخدم إلى هذا المسار. تبقى حزمتك الأولية صغيرة، وينخفض وقت التفاعل (Time to Interactive) بشكل كبير.

إذا كنت تستخدم Next.js، فإنك تحصل على تقسيم الكود بناءً على المسار مجانًا مع App Router. ولكن لا يزال عليك أن تكون متعمدًا بشأن تقسيم الكود على مستوى المكونات للمكتبات الثقيلة التابعة لجهات خارجية:

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

هذا أمر بالغ الأهمية للمكتبات مثل محررات النصوص الغنية، مكتبات الرسوم البيانية، أو عارضات PDF التي يمكن أن تضيف بسهولة 200-500 كيلوبايت إلى حزمتك.

3. وضع الحالة (State) في مستوى عالٍ جدًا في شجرة المكونات

نموذج العرض في React هو من الأعلى إلى الأسفل. عندما تتغير حالة مكون ما، فإنه يعاد عرضه — وكذلك شجرته الفرعية بأكملها. عندما ترفع الحالة إلى أعلى الشجرة "للسهولة"، فإنك تخبر React بإعادة عرض كل شيء أسفلها عند كل تغيير في الحالة.

المشكلة:

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

الكتابة في شريط البحث تؤدي إلى تحديث الحالة في App. هذا يعيد عرض Header و Sidebar و Footer و ProductList و Modal — ولا يهتم أي منها باستعلام البحث.

الحل:

ادفع الحالة إلى حيث يتم استخدامها فعليًا:

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

الآن، الكتابة في شريط البحث تعيد عرض SearchSection وأبنائه فقط. كل شيء آخر يبقى دون تغيير.

المبدأ بسيط: يجب أن تكون الحالة قريبة قدر الإمكان من المكونات التي تقرأها. إذا احتاج مكونان شقيقان نفس الحالة، ارفعها إلى أقرب والد مشترك لهما — ولكن ليس أعلى من ذلك. للحالة العالمية حقًا (المصادقة، المظهر، اللغة)، استخدم مكتبة لإدارة الحالة تدعم الاشتراكات الانتقائية، مثل Zustand أو Jotai، بحيث لا يعاد عرض سوى المكونات التي تستهلك جزءًا معينًا من الحالة عندما يتغير هذا الجزء.

4. صور غير محسّنة في Next.js

الصور هي عادةً الأصول الأثقل في صفحة الويب. لقد رأيت تطبيقات Next.js حيث يستخدم المطورون علامات <img> عادية مع صور PNG ضخمة وغير مضغوطة. النتيجة: صفحة تحمل 8 ميجابايت من الصور، بدون تحميل كسول (lazy loading)، بدون تحديد حجم متجاوب، ووقت Largest Contentful Paint يجعل Lighthouse يبكي.

المشكلة:

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

هذا يقدم الصورة بدقتها الكاملة بحجمها الأصلي، بغض النظر عن منفذ عرض المستخدم أو جهازه. لا يوجد تحويل تنسيق، لا تحميل كسول، لا srcset متجاوب.

الحل:

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

مكون Image في Next.js يمنحك عدة أشياء تلقائيًا:

  • تحويل التنسيق التلقائي إلى WebP أو AVIF بناءً على دعم المتصفح
  • تحديد الحجم المتجاوب عبر خاصية sizes — يقوم المتصفح بتنزيل الحجم الذي يحتاجه فقط
  • التحميل الكسول افتراضيًا — لا يتم جلب الصور الموجودة أسفل الشاشة حتى يقوم المستخدم بالتمرير بالقرب منها
  • عنصر نائب ضبابي (Blur placeholder) — يعرض معاينة خفيفة الوزن أثناء تحميل الصورة الكاملة، مما يزيل تغيير التخطيط (layout shift)

للصور الموجودة أعلى الشاشة (لافتات البطل، المحتوى الأساسي)، أضف priority لتعطيل التحميل الكسول وتحميلها مسبقًا:

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

هذا التغيير وحده يمكن أن يقلل وزن الصفحة بنسبة 60-80% ويحسن بشكل كبير درجات Core Web Vitals الخاصة بك.

5. حظر الخيط الرئيسي (Main Thread) بعمليات حسابية ثقيلة

يتعامل الخيط الرئيسي للمتصفح مع العرض، إدخال المستخدم، الرسوم المتحركة، وتنفيذ JavaScript — كل ذلك على خيط واحد. عندما تقوم بتشغيل عملية حسابية ثقيلة بشكل متزامن، يتجمد كل شيء آخر. ينقر المستخدم على زر ولا يحدث شيء لمدة 500 مللي ثانية. تتوقف الرسوم المتحركة. يتوقف التمرير عن الاستجابة.

المشكلة:

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset يعمل بشكل متزامن على الخيط الرئيسي. إذا استغرق 400 مللي ثانية، فإن واجهة المستخدم بأكملها تتجمد لمدة 400 مللي ثانية. لا يمكن للمستخدم التمرير أو الكتابة أو التفاعل مع أي شيء.

الحل — نقله إلى Web Worker:

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

العملية الحسابية الثقيلة تعمل الآن على خيط منفصل. يبقى الخيط الرئيسي حرًا للتعامل مع تفاعلات المستخدم، الرسوم المتحركة، والعرض. تظل واجهة المستخدم سريعة الاستجابة طوال الوقت.

بالنسبة للحالات الأقل تطرفًا حيث تستغرق العملية الحسابية 50-150 مللي ثانية، يمكنك تقسيم العمل إلى أجزاء أصغر باستخدام requestIdleCallback أو scheduler.yield() بدلاً من تشغيل Worker كامل. ولكن لأي شيء يعالج مجموعات بيانات كبيرة، أو يولد تقارير، أو يشغل خوارزميات معقدة، فإن Web Workers ليست اختيارية — بل ضرورية.

التأثير التراكمي

لن يؤدي أي من هذه الأخطاء إلى تعطل تطبيقك بمفرده. وهذا ما يجعلها خطيرة. يضيف كل خطأ 100-300 مللي ثانية من التأخير، ويختبر المستخدمون المجموع الكلي. مكون يعاد عرضه بشكل غير ضروري بالإضافة إلى حزمة ضخمة بالإضافة إلى صور غير محسّنة بالإضافة إلى عملية حسابية على الخيط الرئيسي يعني أن تطبيقك يستغرق 4 ثوانٍ ليصبح تفاعليًا بدلاً من ثانية واحدة.

قم بالتحليل قبل التحسين. استخدم React DevTools Profiler للعثور على نقاط إعادة العرض الساخنة. استخدم Lighthouse و Web Vitals لقياس تأثير المستخدم الحقيقي. استخدم علامة تبويب الشبكة (Network) في متصفحك لاكتشاف الحزم والصور ذات الحجم الزائد. استخدم علامة تبويب الأداء (Performance) لتحديد المهام الطويلة التي تحظر الخيط الرئيسي.

هذه هي الأنماط التي أطبقها في كل مشروع أبنيها — من منصات المطاعم إلى أنظمة SaaS الطبية. الأداء ليس ميزة تضيفها لاحقًا. إنه جودة تحافظ عليها من أول التزام (commit).

DU

Danil Ulmashev

Full Stack Developer

مهتم بالعمل معًا؟