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

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.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

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

מה אסור לעשות

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

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

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

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