การดึงข้อมูลที่ล้มเลิกได้

เจค อาร์ชิบาลด์
เจค อาร์ชิบาลด์

เปิดปัญหาเดิมของ GitHub เกี่ยวกับ "การยกเลิกการดึงข้อมูล" ในปี 2015 ตอนนี้ถ้าผมเอาปี 2015 ออกจากปี 2017 (ปีปัจจุบัน) ผมจะได้รับ 2 ซึ่งเป็นการแสดงถึง ข้อบกพร่องในวิชาคณิตศาสตร์ เพราะที่จริงแล้วปี 2015 นั้น "ตลอดกาล" ที่ผ่านมา

ปี 2015 คือครั้งแรกที่เราเริ่มศึกษาล้มเลิกการดึงที่ดำเนินอยู่ และหลังจากที่มีความคิดเห็น 780 รายการใน GitHub มี 2 อย่างที่ไม่ถูกต้องในการเริ่ม และการดึงคำขอ 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 จะล้มเลิก ส่งผลให้การดึงข้อมูลภายในโปรแกรมทำงานของบริการก็ล้มเลิกเช่นกัน

หากคุณดึงข้อมูลสิ่งอื่นที่ไม่ใช่ 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

มีความพยายามที่จะทำให้การดำเนินการที่ยกเลิกแตกต่างจากข้อผิดพลาด ซึ่งรวมถึงสถานะสัญญาครั้งที่ 3 ที่ระบุว่า "cancelled" และไวยากรณ์ใหม่ที่ใช้จัดการกับการยกเลิกทั้งในการซิงค์และโค้ดที่ไม่พร้อมกัน

ไม่ควรทำ

ไม่ใช่โค้ดจริง ข้อเสนอถูกเพิกถอนแล้ว

    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 ระดับสูงขึ้นเพื่อสังเกตความคืบหน้าในการดึงข้อมูล