AbortController بالعربي: امنع نتائج البحث القديمة من كسر الواجهة
مستوى القارئ: متوسط
هتخرج من المقال ده بطريقة عملية تمنع طلبات fetch القديمة من تحديث الواجهة بنتائج غلط، خصوصًا في search box أو filters بتتغير بسرعة.
المشكلة باختصار
افترض إن عندك صفحة منتجات. المستخدم كتب iph، وبعد 200ms كتب iphone. الواجهة بعتت طلبين: طلب قديم لكلمة iph وطلب جديد لكلمة iphone. اللي بيحصل فعلاً إن الشبكة مش بتضمن ترتيب الرجوع. ممكن الطلب القديم يرجع بعد الجديد، فيعمل setState ويعرض نتائج مش مطابقة لآخر كلمة كتبها المستخدم.
الطريقة الشائعة الغلط هنا إنك تعتمد على إن آخر طلب اتبعت هو آخر طلب هيرجع. الطريقة دي بتفشل مع latency متغير، CDN، mobile network، أو backend عليه ضغط. ركز: المشكلة مش بس في استهلاك الشبكة. المشكلة الأكبر إن UI ممكن تبقى كاذبة.
الفكرة بمثال بسيط
اعتبر كل بحث عامل زي موظف بيطلع يجيب ملف من الأرشيف. لو المستخدم غيّر رأيه وطلب ملفًا ثانيًا، مش منطقي تسيب الموظف الأول يرجع بعد خمس دقائق ويحط الملف القديم على مكتبك. الأفضل تبعت له إشارة واضحة: وقف المهمة دي، الطلب اتغيّر.
في JavaScript، الإشارة دي اسمها AbortSignal، واللي بيصدرها هو AbortController. بتنشئ controller لكل طلب، تمرر controller.signal إلى fetch، ولما الطلب يبقى قديم تستدعي controller.abort(). حسب توثيق MDN، abort() يقدر يوقف طلب fetch وقراءة جسم الاستجابة والـ streams المرتبطة به.
تطبيق فعلي في React
الافتراض إن عندك React component بيجيب نتائج بحث من endpoint اسمه /api/search?q=.... كل مرة query تتغير، هنلغي الطلب السابق قبل ما نبدأ طلب جديد. ده يطابق فكرة cleanup في useEffect: React يشغّل cleanup للـ effect القديم قبل تشغيل effect الجديد عند تغيّر dependencies.
import { useEffect, useState } from "react";
export function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setResults(data.items);
} catch (err) {
if (err.name === "AbortError") return;
setError(err.message);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
load();
return () => controller.abort();
}, [query]);
return null;
}في المثال ده، لو المستخدم كتب 8 حروف في ثانية واحدة، من غير إلغاء ممكن يبقى عندك 8 طلبات نشطة. مع AbortController غالبًا هيفضل طلب واحد مهم: آخر query فقط. الرقم تقديري لكنه واقعي في search boxes اللي بتعمل request مع كل تغيير.
إضافة timeout بدل الانتظار المفتوح
في بعض الواجهات، الطلب البطيء أسوأ من فشل واضح. مثال: autocomplete لازم يرد خلال 300 إلى 800ms. لو استنى 5 ثواني، المستخدم أصلًا كمل كتابة أو غادر الصفحة.
لو بيئتك تدعم AbortSignal.timeout()، تقدر تعمل timeout مباشر. MDN يذكر إن هذه الدالة ترجع signal يتم إلغاؤه تلقائيًا بعد عدد محدد من المللي ثانية، وهي Baseline 2024. لو بتدعم متصفحات قديمة، استخدم fallback يدوي بـ setTimeout.
const res = await fetch("/api/search?q=iphone", {
signal: AbortSignal.timeout(800),
});الـ trade-off هنا واضح: هتكسب واجهة أسرع وفشلًا قابلًا للتحكم، لكن ممكن تقطع طلبًا كان هينجح بعد 900ms. لذلك لا تختار timeout عدواني من غير قياس P95 latency عندك.
هل ده بديل لـ Debounce؟
لا. Debounce يقلل عدد الطلبات قبل ما تخرج من المتصفح. AbortController يتعامل مع الطلبات اللي خرجت بالفعل وبقت قديمة. أفضل طريقة في search box مزدحم هي استخدام الاثنين معًا: Debounce بسيط 200ms، ثم AbortController لإلغاء أي طلب سابق.
لو عندك موقع بـ 50K زيارة يوميًا، وكل مستخدم يعمل 10 عمليات بحث، تقليل 5 طلبات زائدة لكل جلسة يعني 250K طلب أقل يوميًا. المكسب: ضغط أقل على API ونتائج أدق. الخسارة: كود أكثر قليلًا، ولازم تتعامل مع AbortError كحالة طبيعية مش خطأ يستحق alert.
متى لا تستخدم هذه الطريقة
لا تستخدمها لإلغاء عمليات لازم تكتمل على السيرفر، مثل الدفع أو إنشاء order، إلا لو backend عنده idempotency وتصميم واضح للتعامل مع الإلغاء. كمان لا تعتمد عليها كوسيلة حماية أمنية. إلغاء الطلب من المتصفح لا يضمن إن السيرفر لم يستقبل الطلب أصلًا.
لو بتستخدم TanStack Query أو SWR أو React Router loaders، راجع آلية الإلغاء والكاش داخل الأداة قبل ما تضيف controller يدوي في كل component. أحيانًا المكتبة already بتديك signal أو بتمنع stale updates بطريقة أفضل.
مصادر اعتمدت عليها
- MDN: AbortController.abort()
- MDN: AbortSignal
- MDN: AbortSignal.timeout()
- React Docs: useEffect cleanup
- React Docs: Race conditions in Effects
الخطوة التالية
افتح أول component عندك فيه fetch مع useEffect وdependency متغيرة، وضيف AbortController في cleanup. بعد كده افتح Network tab واكتب بسرعة. لو الطلبات القديمة بقت canceled والواجهة تعرض آخر query فقط، التنفيذ شغال بالظبط.