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

جيك أرشيبالد
جيك أرشيبالد

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

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

تعديل: عذرًا، لم أكن مخطئًا. تم إطلاق Edge 16 مع دعم الإلغاء أولاً! تهانينا لفريق 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 معرفة الإجراءات الملغاة، ولكن في معظم الأحيان لن تحتاج إلى ذلك.

وصل ذلك إلى المرحلة الأولى في إطار الشفافية والموافقة (TC39)، ولكن لم يتم التوصل إلى توافق في الآراء وتم سحب الاقتراح.

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

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

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

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

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