استرجاع قابل للإلغاء

تم فتح المشكلة الأصلية في GitHub بشأن "إيقاف عملية جلب" في عام 2015. الآن، إذا طرحتُ عام 2015 من عام 2017 (السنة الحالية)، أحصل على 2. يشير ذلك إلى خطأ في عملية حسابية، لأنّ عام 2015 كان في الواقع "منذ زمن بعيد".

في عام 2015، بدأنا في استكشاف إمكانية إلغاء عمليات الجلب الجارية، وبعد 780 تعليقًا على GitHub ومحاولة اثنتان أو ثلاث محاولات غير ناجحة و5 طلبات سحب، أصبح بإمكاننا أخيرًا توفير ميزة إلغاء عمليات الجلب في المتصفّحات، وكانت أول عملية هي Firefox 57.

تعديل: لقد تبين أنّني كنت مخطئًا. تم طرح الإصدار 16 من Edge مع إتاحة ميزة إلغاء التحميل أولاً. تهانينا لفريق Edge.

سأتناول بالتفصيل السجلّ لاحقًا، ولكن أولاً، واجهة برمجة التطبيقات:

وحدة التحكّم + المناورة في الإشارة

تعرَّف على AbortController وAbortSignal:

const controller = new AbortController();
const signal = controller.signal;

تتوفّر طريقة واحدة فقط لوحدة التحكّم:

controller.abort();

عند إجراء ذلك، يتم إرسال إشعار إلى الإشارة:

signal.addEventListener('abort', () => {
    // Logs true:
    console.log(signal.aborted);
});

توفّر معيار DOM واجهة برمجة التطبيقات هذه، وهي واجهة برمجة التطبيقات بالكامل. وهو عامّ عن قصد حتى يمكن استخدامها من قِبل معايير الويب الأخرى ومكتبات JavaScript.

إيقاف الإشارات واستردادها

قد يستغرق جلب البيانات AbortSignal. على سبيل المثال، في ما يلي كيفية ضبط مهلة استرجاع بعد 5 ثوانٍ:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
});

عند إلغاء عملية استرجاع، يتم إلغاء كل من الطلب والاستجابة، وبالتالي يتم أيضًا إلغاء أي قراءة لنص الاستجابة (مثل response.text()).

في ما يلي عرض تجريبي: في وقت كتابة هذه المقالة، المتصفّح الوحيد الذي يتوافق مع هذه الميزة هو Firefox 57. ونريد تذكيرك أيضًا بأنّه لم يشارك في إنشاء العرض التجريبي أي شخص لديه أي مهارات تصميم.

بدلاً من ذلك، يمكن تقديم الإشارة إلى عنصر طلب وإرسالها لاحقًا لاسترداد البيانات:

const controller = new AbortController();
const signal = controller.signal;
const request = new Request(url, { signal });

fetch(request);

يعمل هذا الإجراء لأنّ request.signal هو AbortSignal.

الاستجابة لعملية استرجاع تم إلغاؤها

عند إلغاء عملية غير متزامنة، يرفض الوعد باستخدام DOMException باسم AbortError:

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
}).catch(err => {
    if (err.name === 'AbortError') {
    console.log('Fetch aborted');
    } else {
    console.error('Uh oh, an error!', err);
    }
});

لا تريد في أغلب الأحيان عرض رسالة خطأ إذا أوقف المستخدم العملية، لأنّه ليس هناك "خطأ" إذا نجحت في تنفيذ ما طلبه المستخدم. لتجنُّب ذلك، استخدِم عبارة if مثل العبارة أعلاه لمعالجة أخطاء الإيقاف على وجه التحديد.

في ما يلي مثال يمنح المستخدم زرًا لتحميل المحتوى وزرًا للإيقاف. إذا حدث خطأ في عملية الجلب، يتم عرض رسالة خطأ، إلا إذا كان خطأ في عملية الإيقاف:

// This will allow us to abort the fetch.
let controller;

// Abort if the user clicks:
abortBtn.addEventListener('click', () => {
    if (controller) controller.abort();
});

// Load the content:
loadBtn.addEventListener('click', async () => {
    controller = new AbortController();
    const signal = controller.signal;

    // Prevent another click until this fetch is done
    loadBtn.disabled = true;
    abortBtn.disabled = false;

    try {
    // Fetch the content & use the signal for aborting
    const response = await fetch(contentUrl, { signal });
    // Add the content to the page
    output.innerHTML = await response.text();
    }
    catch (err) {
    // Avoid showing an error message if the fetch was aborted
    if (err.name !== 'AbortError') {
        output.textContent = "Oh no! Fetching failed.";
    }
    }

    // These actions happen no matter how the fetch ends
    loadBtn.disabled = false;
    abortBtn.disabled = true;
});

إليك عرض توضيحي: في وقت كتابة هذه المقالة، المتصفّحان الوحيدان اللذان يتيحان ذلك هما Edge 16 وFirefox 57.

إشارة واحدة، عمليات استرجاع متعددة

يمكن استخدام إشارة واحدة لإيقاف العديد من عمليات الجلب في آنٍ واحد:

async function fetchStory({ signal } = {}) {
    const storyResponse = await fetch('/story.json', { signal });
    const data = await storyResponse.json();

    const chapterFetches = data.chapterUrls.map(async url => {
    const response = await fetch(url, { signal });
    return response.text();
    });

    return Promise.all(chapterFetches);
}

في المثال أعلاه، يتم استخدام الإشارة نفسها لعمليات الجلب الأولية وعمليات الجلب المتزامنة للمقاطع. في ما يلي كيفية استخدام fetchStory:

const controller = new AbortController();
const signal = controller.signal;

fetchStory({ signal }).then(chapters => {
    console.log(chapters);
});

في هذه الحالة، سيؤدي استدعاء controller.abort() إلى إلغاء عمليات الجلب الجارية.

المستقبل

المتصفحات الأخرى

لقد أبدع فريق Edge في طرح هذه الميزة أولاً، ويسير فريق Firefox على خطاه. نفَّذ المهندسون ذلك من مجموعة الاختبار أثناء كتابة المواصفات. بالنسبة إلى المتصفحات الأخرى، إليك الخطوات التي يجب اتّباعها:

في مشغّل خدمات

أحتاج إلى إنهاء المواصفات الخاصة بأجزاء الخدمة، ولكن إليك الخطة:

كما ذكرتُ من قبل، يحتوي كلّ عنصر من عناصر Request على سمة signal. في أحد موظّفي الخدمة، سيُرسِل الرمز fetchEvent.request.signal إشارة إلى إلغاء الطلب إذا لم تعُد الصفحة مهتمة بالاستجابة. نتيجةً لذلك، يعمل الرمز البرمجي على النحو التالي:

addEventListener('fetch', event => {
    event.respondWith(fetch(event.request));
});

إذا أوقفت الصفحة عملية الجلب، تُرسِل fetchEvent.request.signal إشارة بالتوقف، وبالتالي يتم أيضًا إيقاف عملية الجلب ضمن عامل الخدمة.

إذا كنت تُجلب بيانات غير event.request، عليك تمرير الإشارة إلى عمليات الجلب المخصّصة.

addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (event.request.method == 'GET' && url.pathname == '/about/') {
    // Modify the URL
    url.searchParams.set('from-service-worker', 'true');
    // Fetch, but pass the signal through
    event.respondWith(
        fetch(url, { signal: event.request.signal })
    );
    }
});

اتّبِع المواصفات لتتبُّع ذلك. سأضيف روابط إلى طلبات المتصفّح فور استعدادها للتنفيذ.

السجلّ

نعم، استغرق إنشاء واجهة برمجة التطبيقات هذه البسيطة نسبيًا وقتًا طويلاً. وفي ما يلي السبب في ذلك:

عدم تطابق واجهة برمجة التطبيقات

كما ترى، مناقشة GitHub طويلة جدًا. هناك الكثير من التفاصيل الدقيقة في سلسلة المحادثات هذه (وبعض النقص في التفاصيل الدقيقة)، ولكنّ الاختلاف الرئيسي هو أنّ إحدى المجموعات أرادت أن تكون طريقة abort متوفّرة في العنصر الذي يعرضه fetch()، في حين أرادت المجموعة الأخرى فصلًا بين الحصول على الردّ والتأثير فيه.

هذه المتطلبات غير متوافقة، لذا لن تحصل إحدى المجموعات على ما تريده. نعتذر عن ذلك. إذا كان ذلك يريحك، كنتُ أيضًا في تلك المجموعة. ولكن بما أنّ AbortSignal تتوافق مع متطلبات واجهات برمجة التطبيقات الأخرى، يبدو أنّها الخيار المناسب. بالإضافة إلى ذلك، سيؤدي السماح بتحويل الوعود المتسلسلة إلى قابلية الإيقاف إلى تعقيد العملية بشكل كبير، إن لم يكن مستحيلاً.

إذا أردت عرض عنصر يقدّم استجابة، ولكن يمكن أيضًا إيقافه، يمكنك إنشاء ملف ملتفٍ بسيط:

function abortableFetch(request, opts) {
    const controller = new AbortController();
    const signal = controller.signal;

    return {
    abort: () => controller.abort(),
    ready: fetch(request, { ...opts, signal })
    };
}

يبدأ الخطأ في TC39

تمّ بذل جهد لتمييز الإجراء المُلغى عن الخطأ. وشمل ذلك حالة وعد ثالثة للإشارة إلى "تم الإلغاء"، وبعض البنية الجديدة للتعامل مع الإلغاء في كل من رمز المعالجة المتزامنة وغير المتزامنة:

الإجراءات غير المُوصى بها

الرمز غير صالح، تم سحب الاقتراح

    try {
      // Start spinner, then:
      await someAction();
    }
    catch cancel (reason) {
      // Maybe do nothing?
    }
    catch (err) {
      // Show error message
    }
    finally {
      // Stop spinner
    }

إنّ الإجراء الأكثر شيوعًا عند إلغاء إجراء معيّن هو عدم اتّخاذ أي إجراء. يفصل الاقتراح أعلاه بين الإلغاء والأخطاء لكي لا تحتاج إلى معالجة أخطاء الإيقاف بشكلٍ خاص. catch cancel تُعلمك بالإجراءات المُلغاة، ولكن في معظم الأحيان لن تحتاج إلى ذلك.

وصل هذا الاقتراح إلى المرحلة 1 في TC39، ولكن لم يتمّ التوصل إلى توافق، وتمّ سحب الاقتراح.

لم يتطلّب الاقتراح البديل، AbortController، أي بنية جديدة، لذا لم يكن من المنطقي تحديده ضمن TC39. كان كل ما نحتاجه من JavaScript متوفّرًا، لذلك حدّدنا الواجهات ضمن منصة الويب، وتحديدًا معيار DOM. بعد اتّخاذ هذا القرار، تم الانتهاء من الباقي بسرعة نسبية.

تغيير كبير في المواصفات

كان من الممكن إيقاف XMLHttpRequest منذ سنوات، ولكن كانت المواصفات غامضة جدًا. لم يكن من الواضح في أي نقاط يمكن تجنُّب نشاط الشبكة الأساسي أو إنهائه، أو ما يحدث إذا حدث تعارض بين وقت استدعاء abort() ووقت اكتمال الجلب.

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

ولكننا هنا الآن. لدينا عنصر ويب أساسي جديد لإيقاف الإجراءات غير المتزامنة، ويمكن التحكّم في عمليات الجلب المتعدّدة في آنٍ واحد. في وقت لاحق، سننظر في تفعيل تغييرات الأولوية طوال مدة الجلب، وواجهة برمجة تطبيقات أعلى مستوى لمراقبة مستوى تقدّم الجلب.