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

Jake Archibald
Jake Archibald

הבעיה המקורית בנושא 'ביטול אחזור' ב-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 יש רק method אחד:

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)

אני צריך לסיים את המפרט של החלקים של שירות העבודה, אבל הנה התוכנית:

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

אם רוצים להחזיר אובייקט שמספק תשובה, אבל אפשר גם לבטל אותו, אפשר ליצור מעטפת פשוטה:

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

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

התחלות שגויות ב-TC39

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

מה אסור לעשות

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

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

רצינו לעשות את זה נכון הפעם, אבל זה הוביל לשינוי משמעותי במפרט שדרש הרבה בדיקות (זוהי אשמתי, ותודה רבה ל-Anne van Kesteren ול-Domenic Denicola שגררו אותי דרך התהליך) וקבוצה נכבדה של בדיקות.

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