אחזור שניתן לבטל

ג'ייק ארצ'יבלד
ג'ייק ארצ'יבלד

הבעיה המקורית ב-GitHub בנושא 'הפסקת אחזור' נפתחה ב-2015. עכשיו, אם לוקחים את 2015 מ-2017 (השנה הנוכחית), מקבלים 2. הדבר מדגים באג במתמטיקה, מכיוון ש-2015 למעשה הייתה "לפני זמן".

ב-2015 התחלנו לנסות לבטל שליפות שוטפות. אחרי 780 תגובות ב-GitHub, כמה תגובות שגויות ו-5 בקשות משיכה, סוף סוף יש לנו אפשרות לבטל את השליפה בדפדפנים, כשהראשונה הייתה Firefox 57.

עדכון: לא הבנתי, טעיתי. Edge 16 הגיעה קודם עם תמיכה בביטול! כל הכבוד לצוות Edge!

ארחיב על ההיסטוריה מאוחר יותר, אבל קודם כול, ה-API:

השלט הרחוק + תמרון אותות

אנחנו שמחים להציג את AbortController ואת AbortSignal:

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

לשלט רחוק יש רק שיטה אחת:

controller.abort();

כשעושים זאת, נשלחת התראה לאות:

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

ה-API הזה מסופק על ידי תקן DOM והוא ה-API כולו. זו הגדרה כללית בכוונה, כך שאפשר להשתמש בה בתקני אינטרנט אחרים ובספריות 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 נמצא ממש פופולרי. המהנדסים שלהם הטמיעו מחבילת הבדיקה בזמן שהמפרט נכתב. בדפדפנים אחרים, הנה הכרטיסים לביצוע:

בקובץ שירות (service worker)

עליי לסיים את המפרט של החלקים של קובץ השירות (service worker), אבל זו התוכנית:

כמו שציינתי קודם, לכל אובייקט Request יש מאפיין signal. בתוך קובץ שירות (service worker), fetchEvent.request.signal מסמן 'מבוטל' אם הדף כבר לא מעוניין בתגובה. כתוצאה מכך, קוד כזה פשוט פועל:

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

אם הדף מבטל את האחזור, fetchEvent.request.signal מאותת, ולכן גם האחזור בתוך ה-Service Worker מתבטל.

אם מאחזרים משהו שאינו 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 })
    );
    }
});

כדי לעקוב אחרי השלבים האלה, מומלץ לפעול לפי המפרט – אוסיף קישורים לכרטיסים בדפדפן ברגע שהם יהיו מוכנים להטמעה.

ההיסטוריה

כן... לקח הרבה זמן עד שה-API הפשוט יחסית הזה התחבר. אלו הסיבות לכך:

אי-הסכמה לגבי ה-API

כמו שאפשר לראות, הדיון ב-GitHub די ארוך. בשרשור הזה יש הרבה ניואנסים (ויש גם חוסר ניואנסים), אבל אחת מהבעיות המרכזיות היא שאחת הקבוצות הייתה שרצתה שהשיטה abort תתקיים באובייקט שהחזיר fetch(), ואילו השנייה הייתה רצונית להפריד בין קבלת התשובה לבין השפעה על התשובה.

דרישות אלה אינן תואמות, ולכן קבוצה אחת לא התאפשרה להשיג את מבוקשה. אם זה קרה, סליחה! אם זה גורם לך להרגיש טוב יותר, גם אני הייתי בקבוצה הזו. עם זאת, רואים שהממשק AbortSignal מתאים לדרישות של ממשקי API אחרים, וזה נראה כמו הבחירה הנכונה. בנוסף, האפשרות להפוך הבטחות משורשרות להפוך לבלתי אפשריות יהיה מסובך מאוד, אם לא בלתי אפשרי.

אם אתם רוצים להחזיר אובייקט שנותן תגובה אבל גם יכול לבטל, תוכלו ליצור wrapper פשוט:

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

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

False מתחיל ב-TC39

נעשה מאמץ להבדיל בין פעולה שבוטלה לשגיאה. זה כלל מצב הבטחה שלישי לסימון 'canceled', ותחביר חדש לטיפול בביטול גם בקוד הסנכרון וגם בקוד האסינכרוני:

מה אסור לעשות

לא קוד אמיתי - ההצעה בוטלה

    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() לבין השלמת האחזור.

רצינו לעשות את זה כמו שצריך הפעם, אבל הדבר גרם לשינוי גדול במפרט שדרש מספר בדיקות רבות (זו אשמתי, ותודה גדולה לאן ואן קסטרן ולדומניק דניקולה שדרשו אותי) ולמערך בדיקות לא רע.

אבל אנחנו כאן עכשיו! יש לנו פרימיטיב חדש באינטרנט לביטול פעולות אסינכרוניות, וניתן לשלוט במספר אחזורים בבת אחת! בהמשך נפרט איך להפעיל שינויי עדיפות במהלך האחזור של האחזור, ו-API ברמה גבוהה יותר לתצפית על התקדמות האחזור.