العربية

أتقن React Suspense لجلب البيانات. تعلم إدارة حالات التحميل التصريحية، تحسين تجربة المستخدم بالانتقالات، ومعالجة الأخطاء بحدود الأخطاء.

حدود React Suspense: نظرة معمقة على إدارة حالة التحميل التصريحية

في عالم تطوير الويب الحديث، يعد إنشاء تجربة مستخدم سلسة وسريعة الاستجابة أمرًا بالغ الأهمية. أحد أكثر التحديات المستمرة التي يواجهها المطورون هو إدارة حالات التحميل. من جلب البيانات لملف تعريف المستخدم إلى تحميل قسم جديد من التطبيق، تعتبر لحظات الانتظار حاسمة. تاريخيًا، كان هذا يتضمن شبكة معقدة من متغيرات الحالة المنطقية مثل isLoading، وisFetching، وhasError، المنتشرة في جميع مكوناتنا. هذا النهج الحتمي يسبب فوضى في الكود الخاص بنا، ويعقد المنطق، وهو مصدر متكرر للأخطاء، مثل حالات التسابق (race conditions).

وهنا يأتي دور React Suspense. تم تقديمه في البداية لتقسيم الكود مع React.lazy()، وقد توسعت قدراته بشكل كبير مع React 18 ليصبح آلية قوية وأساسية للتعامل مع العمليات غير المتزامنة، خاصة جلب البيانات. يسمح لنا Suspense بإدارة حالات التحميل بطريقة تصريحية، مما يغير بشكل أساسي كيفية كتابة مكوناتنا والتفكير فيها. فبدلاً من أن نسأل "هل أنا في حالة تحميل؟"، يمكن لمكوناتنا أن تقول ببساطة: "أنا بحاجة إلى هذه البيانات للعرض. أثناء انتظاري، يرجى عرض واجهة المستخدم البديلة هذه."

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

الطريقة القديمة: عبء إدارة حالات التحميل اليدوية

قبل أن نتمكن من تقدير أناقة Suspense تمامًا، من الضروري أن نفهم المشكلة التي يحلها. دعنا نلقي نظرة على مكون نموذجي يجلب البيانات باستخدام خطافي useEffect و useState.

تخيل مكونًا يحتاج إلى جلب وعرض بيانات المستخدم:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // إعادة تعيين الحالة لـ userId الجديد
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // إعادة الجلب عند تغير userId

  if (isLoading) {
    return <p>جاري تحميل الملف الشخصي...</p>;
  }

  if (error) {
    return <p>خطأ: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>البريد الإلكتروني: {user.email}</p>
    </div>
  );
}

هذا النمط عملي، لكن به عدة عيوب:

إليكم React Suspense: نقلة نوعية

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

عندما يعلق مكون ما، يتسلق React شجرة المكونات للعثور على أقرب حد Suspense (Suspense Boundary). حد Suspense هو مكون تحدده في شجرتك باستخدام <Suspense>. سيعرض هذا الحد بعد ذلك واجهة مستخدم بديلة (مثل مؤشر تحميل دوار أو هيكل عظمي) حتى تنتهي جميع المكونات داخله من حل اعتماديات بياناتها.

الفكرة الأساسية هي تحديد موقع اعتمادية البيانات مع المكون الذي يحتاجها، مع تركيز واجهة مستخدم التحميل في مستوى أعلى في شجرة المكونات. هذا ينظف منطق المكونات ويمنحك تحكمًا قويًا في تجربة تحميل المستخدم.

كيف "يعلق" المكون؟

يكمن السحر وراء Suspense في نمط قد يبدو غير عادي في البداية: رمي Promise. يعمل مصدر بيانات يدعم Suspense كالتالي:

  1. عندما يطلب مكون ما بيانات، يتحقق مصدر البيانات مما إذا كانت البيانات مخزنة مؤقتًا.
  2. إذا كانت البيانات متاحة، فإنه يعيدها بشكل متزامن.
  3. إذا كانت البيانات غير متاحة (أي، يتم جلبها حاليًا)، فإن مصدر البيانات يرمي Promise الذي يمثل طلب الجلب الجاري.

يلتقط React هذا الـ Promise المرمي. لا يتسبب في تعطل تطبيقك. بدلاً من ذلك، يفسره كإشارة: "هذا المكون ليس جاهزًا للعرض بعد. أوقفه مؤقتًا، وابحث عن حد Suspense فوقه لعرض واجهة بديلة." بمجرد أن يتم حل الـ Promise، سيحاول React إعادة عرض المكون، والذي سيتلقى الآن بياناته ويعرض بنجاح.

حد <Suspense>: مُعلِن واجهة التحميل الخاصة بك

مكون <Suspense> هو قلب هذا النمط. استخدامه بسيط للغاية، حيث يأخذ خاصية واحدة مطلوبة: fallback.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>تطبيقي</h1>
      <Suspense fallback={<p>جاري تحميل المحتوى...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

في هذا المثال، إذا علق SomeComponentThatFetchesData، سيرى المستخدم رسالة "جاري تحميل المحتوى..." حتى تصبح البيانات جاهزة. يمكن أن يكون الـ fallback أي عقدة React صالحة، من سلسلة نصية بسيطة إلى مكون هيكل عظمي معقد.

حالة الاستخدام الكلاسيكية: تقسيم الكود مع React.lazy()

الاستخدام الأكثر رسوخًا لـ Suspense هو لتقسيم الكود. يسمح لك بتأجيل تحميل JavaScript لمكون ما حتى يتم طلبه فعليًا.


import React, { Suspense, lazy } from 'react';

// كود هذا المكون لن يكون في الحزمة الأولية.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>بعض المحتوى الذي يتم تحميله فورًا</h2>
      <Suspense fallback={<div>جاري تحميل المكون...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

هنا، سيقوم React فقط بجلب JavaScript الخاص بـ HeavyComponent عندما يحاول عرضه لأول مرة. أثناء جلبه وتحليله، يتم عرض الـ fallback الخاص بـ Suspense. هذه تقنية قوية لتحسين أوقات تحميل الصفحة الأولية.

الحدود الحديثة: جلب البيانات مع Suspense

بينما يوفر React آلية Suspense، فإنه لا يوفر عميلًا محددًا لجلب البيانات. لاستخدام Suspense لجلب البيانات، تحتاج إلى مصدر بيانات يتكامل معه (أي، مصدر يرمي Promise عندما تكون البيانات معلقة).

أطر العمل مثل Relay و Next.js لديها دعم مدمج وأساسي لـ Suspense. كما تقدم مكتبات جلب البيانات الشهيرة مثل TanStack Query (المعروفة سابقًا باسم React Query) و SWR دعمًا تجريبيًا أو كاملاً لها.

لفهم المفهوم، دعنا ننشئ غلافًا بسيطًا ومفاهيميًا حول fetch API لجعله متوافقًا مع Suspense. ملاحظة: هذا مثال مبسط للأغراض التعليمية وليس جاهزًا للإنتاج. إنه يفتقر إلى تعقيدات التخزين المؤقت ومعالجة الأخطاء المناسبة.


// data-fetcher.js
// ذاكرة تخزين مؤقت بسيطة لتخزين النتائج
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // هذا هو السحر!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Fetch failed with status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

يحافظ هذا الغلاف على حالة بسيطة لكل عنوان URL. عند استدعاء fetchData، فإنه يتحقق من الحالة. إذا كانت معلقة، فإنه يرمي الـ promise. إذا كانت ناجحة، فإنه يعيد البيانات. الآن، دعنا نعيد كتابة مكون UserProfile الخاص بنا باستخدام هذا.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// المكون الذي يستخدم البيانات فعليًا
function ProfileDetails({ userId }) {
  // حاول قراءة البيانات. إذا لم تكن جاهزة، فسيتم تعليق هذا.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>البريد الإلكتروني: {user.email}</p>
    </div>
  );
}

// المكون الأب الذي يحدد واجهة المستخدم لحالة التحميل
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>جاري تحميل الملف الشخصي...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

انظر إلى الفرق! مكون ProfileDetails نظيف ويركز فقط على عرض البيانات. لا يحتوي على حالات isLoading أو error. إنه يطلب ببساطة البيانات التي يحتاجها. تم نقل مسؤولية إظهار مؤشر التحميل إلى المكون الأب، UserProfile، الذي يحدد بشكل تصريحي ما يجب إظهاره أثناء الانتظار.

تنظيم حالات التحميل المعقدة

تظهر القوة الحقيقية لـ Suspense عند بناء واجهات مستخدم معقدة ذات تبعيات غير متزامنة متعددة.

حدود Suspense المتداخلة لواجهة مستخدم متدرجة

يمكنك تداخل حدود Suspense لإنشاء تجربة تحميل أكثر دقة. تخيل صفحة لوحة تحكم بها شريط جانبي، ومنطقة محتوى رئيسية، وقائمة بالأنشطة الأخيرة. قد يتطلب كل من هذه جلب بيانات خاص به.


function DashboardPage() {
  return (
    <div>
      <h1>لوحة التحكم</h1>
      <div className="layout">
        <Suspense fallback={<p>جاري تحميل التنقل...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

مع هذه البنية:

يسمح لك هذا بعرض محتوى مفيد للمستخدم في أسرع وقت ممكن، مما يحسن بشكل كبير الأداء المتصور.

تجنب "فرقعة" واجهة المستخدم

في بعض الأحيان، يمكن أن يؤدي النهج المتدرج إلى تأثير مزعج حيث تظهر وتختفي العديد من مؤشرات التحميل في تتابع سريع، وهو تأثير يسمى غالبًا "فرقعة" (popcorning). لحل هذه المشكلة، يمكنك نقل حد Suspense إلى مستوى أعلى في الشجرة.


function DashboardPage() {
  return (
    <div>
      <h1>لوحة التحكم</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

في هذا الإصدار، يتم عرض DashboardSkeleton واحد حتى تحصل جميع المكونات الفرعية (Sidebar، MainContent، ActivityFeed) على بياناتها. ثم تظهر لوحة التحكم بأكملها دفعة واحدة. يعد الاختيار بين الحدود المتداخلة وحد واحد عالي المستوى قرار تصميم تجربة مستخدم يجعل Suspense تنفيذه أمرًا بسيطًا.

معالجة الأخطاء باستخدام حدود الأخطاء

يتعامل Suspense مع الحالة المعلقة لـ promise، ولكن ماذا عن الحالة المرفوضة؟ إذا تم رفض الـ promise الذي يرميه مكون ما (على سبيل المثال، خطأ في الشبكة)، فسيتم التعامل معه مثل أي خطأ عرض آخر في React.

الحل هو استخدام حدود الأخطاء (Error Boundaries). حد الخطأ هو مكون من نوع class يعرّف دالة دورة حياة خاصة، componentDidCatch() أو دالة ثابتة getDerivedStateFromError(). يلتقط أخطاء JavaScript في أي مكان في شجرة المكونات الفرعية الخاصة به، ويسجل تلك الأخطاء، ويعرض واجهة مستخدم بديلة.

إليك مكون حد خطأ بسيط:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // تحديث الحالة حتى يُظهر العرض التالي واجهة المستخدم البديلة.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // يمكنك أيضًا تسجيل الخطأ في خدمة تقارير الأخطاء
    console.error("Caught an error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // يمكنك عرض أي واجهة مستخدم بديلة مخصصة
      return <h1>حدث خطأ ما. يرجى المحاولة مرة أخرى.</h1>;
    }

    return this.props.children; 
  }
}

يمكنك بعد ذلك دمج حدود الأخطاء مع Suspense لإنشاء نظام قوي يتعامل مع الحالات الثلاث: المعلقة، والناجحة، والخاطئة.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>معلومات المستخدم</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>جاري التحميل...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

مع هذا النمط، إذا نجح جلب البيانات داخل UserProfile، يتم عرض الملف الشخصي. إذا كان معلقًا، يتم عرض الـ fallback الخاص بـ Suspense. إذا فشل، يتم عرض الـ fallback الخاص بـ Error Boundary. المنطق تصريحي، وتركيبي، وسهل الفهم.

الانتقالات (Transitions): مفتاح تحديثات واجهة المستخدم غير المعيقة

هناك قطعة أخيرة في اللغز. فكر في تفاعل مستخدم يؤدي إلى جلب بيانات جديد، مثل النقر على زر "التالي" لعرض ملف تعريف مستخدم مختلف. مع الإعداد أعلاه، في اللحظة التي يتم فيها النقر على الزر وتغيير خاصية userId، سيعلق مكون UserProfile مرة أخرى. هذا يعني أن الملف الشخصي المعروض حاليًا سيختفي وسيتم استبداله بالـ fallback الخاص بالتحميل. يمكن أن يكون هذا مفاجئًا ومزعجًا.

هنا يأتي دور الانتقالات (transitions). الانتقالات هي ميزة جديدة في React 18 تتيح لك تمييز تحديثات حالة معينة بأنها غير عاجلة. عندما يتم تغليف تحديث حالة في انتقال، سيستمر React في عرض واجهة المستخدم القديمة (المحتوى القديم) بينما يقوم بإعداد المحتوى الجديد في الخلفية. لن يقوم بتطبيق تحديث واجهة المستخدم إلا بعد أن يكون المحتوى الجديد جاهزًا للعرض.

الواجهة البرمجية الأساسية لهذا هي خطاف useTransition.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        المستخدم التالي
      </button>

      {isPending && <span> جاري تحميل الملف الشخصي الجديد...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>جاري تحميل الملف الشخصي الأولي...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

إليك ما يحدث الآن:

  1. يتم تحميل الملف الشخصي الأولي لـ userId: 1، مع عرض الـ fallback الخاص بـ Suspense.
  2. ينقر المستخدم على "المستخدم التالي".
  3. يتم تغليف استدعاء setUserId في startTransition.
  4. يبدأ React في عرض UserProfile مع userId الجديد 2 في الذاكرة. هذا يتسبب في تعليقه.
  5. بشكل حاسم، بدلاً من عرض الـ fallback الخاص بـ Suspense، يحتفظ React بواجهة المستخدم القديمة (الملف الشخصي للمستخدم 1) على الشاشة.
  6. تصبح قيمة isPending المنطقية التي يعيدها useTransition true، مما يسمح لنا بعرض مؤشر تحميل خفي ومضمن دون إزالة المحتوى القديم.
  7. بمجرد جلب بيانات المستخدم 2 وتمكن UserProfile من العرض بنجاح، يقوم React بتطبيق التحديث، ويظهر الملف الشخصي الجديد بسلاسة.

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

أفضل الممارسات والاعتبارات العالمية

الخاتمة

يمثل React Suspense أكثر من مجرد ميزة جديدة؛ إنه تطور أساسي في كيفية تعاملنا مع التزامن في تطبيقات React. من خلال الابتعاد عن متغيرات التحميل اليدوية والحتمية وتبني نموذج تصريحي، يمكننا كتابة مكونات أنظف وأكثر مرونة وأسهل في التركيب.

من خلال الجمع بين <Suspense> للحالات المعلقة، وحدود الأخطاء لحالات الفشل، و useTransition للتحديثات السلسة، لديك مجموعة أدوات كاملة وقوية تحت تصرفك. يمكنك تنظيم كل شيء من مؤشرات التحميل البسيطة إلى عروض لوحة التحكم المعقدة والمتدرجة بأقل قدر من الكود القابل للتنبؤ. عندما تبدأ في دمج Suspense في مشاريعك، ستجد أنه لا يحسن فقط أداء تطبيقك وتجربة المستخدم، بل يبسط أيضًا بشكل كبير منطق إدارة الحالة لديك، مما يتيح لك التركيز على ما يهم حقًا: بناء ميزات رائعة.