Pengambilan yang dapat dibatalkan

Jake Archibald
Jake Archibald

Masalah GitHub asli untuk "Membatalkan pengambilan" dibuka pada tahun 2015. Sekarang, jika saya mengurangi 2015 dari 2017 (tahun saat ini), saya mendapatkan 2. Hal ini menunjukkan bug dalam matematika, karena 2015 sebenarnya sudah "lama sekali".

Tahun 2015 adalah saat kami pertama kali mulai mempelajari pembatalan pengambilan yang sedang berlangsung, dan setelah 780 komentar GitHub, beberapa awal yang salah, dan 5 permintaan pull, kami akhirnya memiliki halaman pengambilan yang dapat dibatalkan di browser, dengan yang pertama adalah Firefox 57.

Pembaruan: Tidak, saya salah. Edge 16 hadir dengan dukungan pembatalan terlebih dahulu. Selamat kepada tim Edge!

Saya akan membahas historinya nanti, tetapi pertama-tama, API:

Manuver pengontrol + sinyal

Kenali AbortController dan AbortSignal:

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

Pengontrol hanya memiliki satu metode:

controller.abort();

Jika Anda melakukannya, sinyal akan diberi tahu:

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

API ini disediakan oleh standar DOM, dan itulah seluruh API. Library ini sengaja bersifat umum sehingga dapat digunakan oleh standar web dan library JavaScript lainnya.

Membatalkan sinyal dan pengambilan

Pengambilan dapat mengambil AbortSignal. Misalnya, berikut cara membuat waktu tunggu pengambilan setelah 5 detik:

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);
});

Jika Anda membatalkan pengambilan, permintaan dan respons juga akan dibatalkan, sehingga pembacaan isi respons (seperti response.text()) juga akan dibatalkan.

Berikut demonya – Pada saat penulisan, satu-satunya browser yang mendukungnya adalah Firefox 57. Selain itu, bersiaplah, tidak ada orang dengan keterampilan desain apa pun yang terlibat dalam pembuatan demo.

Atau, sinyal dapat diberikan ke objek permintaan dan kemudian diteruskan untuk mengambil:

const controller = new AbortController();
const signal = controller.signal;
const request = new Request(url, { signal });

fetch(request);

Hal ini berfungsi karena request.signal adalah AbortSignal.

Merespons pengambilan yang dibatalkan

Saat Anda membatalkan operasi asinkron, promise akan ditolak dengan DOMException bernama 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);
    }
});

Anda tidak sering ingin menampilkan pesan error jika pengguna membatalkan operasi, karena hal ini bukan "error" jika Anda berhasil melakukan apa yang diminta pengguna. Untuk menghindari hal ini, gunakan pernyataan if seperti yang di atas untuk menangani error pembatalan secara khusus.

Berikut adalah contoh yang memberi pengguna tombol untuk memuat konten, dan tombol untuk membatalkan. Jika pengambilan error, error akan ditampilkan, kecuali jika error tersebut adalah error pembatalan:

// 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;
});

Berikut demonya – Pada saat penulisan, satu-satunya browser yang mendukung ini adalah Edge 16 dan Firefox 57.

Satu sinyal, banyak pengambilan

Satu sinyal dapat digunakan untuk membatalkan banyak pengambilan sekaligus:

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);
}

Pada contoh di atas, sinyal yang sama digunakan untuk pengambilan awal, dan untuk pengambilan bagian paralel. Berikut cara menggunakan fetchStory:

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

fetchStory({ signal }).then(chapters => {
    console.log(chapters);
});

Dalam hal ini, memanggil controller.abort() akan membatalkan pengambilan yang sedang berlangsung.

Acara mendatang

Browser lainnya

Edge berhasil meluncurkannya lebih dulu, dan Firefox tidak mau kalah. Engineer mereka menerapkan dari test suite saat spesifikasi ditulis. Untuk browser lain, berikut tiket yang harus diikuti:

Di pekerja layanan

Saya perlu menyelesaikan spesifikasi untuk bagian pekerja layanan, tetapi berikut rencananya:

Seperti yang saya sebutkan sebelumnya, setiap objek Request memiliki properti signal. Dalam pekerja layanan, fetchEvent.request.signal akan memberikan sinyal pembatalan jika halaman tidak lagi tertarik dengan respons. Akibatnya, kode seperti ini akan berfungsi:

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

Jika halaman membatalkan pengambilan, fetchEvent.request.signal akan memberikan sinyal pembatalan, sehingga pengambilan dalam pekerja layanan juga akan dibatalkan.

Jika mengambil sesuatu selain event.request, Anda harus meneruskan sinyal ke pengambilan kustom.

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 })
    );
    }
});

Ikuti spesifikasi untuk melacaknya. Saya akan menambahkan link ke tiket browser setelah siap diterapkan.

Histori

Ya… perlu waktu lama untuk membuat API yang relatif sederhana ini. Berikut ini alasannya:

Ketidaksesuaian API

Seperti yang dapat Anda lihat, diskusi GitHub cukup panjang. Ada banyak nuansa dalam rangkaian pesan tersebut (dan beberapa nuansa yang tidak ada), tetapi perbedaan pendapat utamanya adalah satu grup ingin metode abort ada di objek yang ditampilkan oleh fetch(), sedangkan grup lainnya ingin pemisahan antara mendapatkan respons dan memengaruhi respons.

Persyaratan ini tidak kompatibel, sehingga satu grup tidak akan mendapatkan apa yang mereka inginkan. Jika itu Anda, mohon maaf. Jika itu membuat Anda merasa lebih baik, saya juga berada di grup tersebut. Namun, melihat AbortSignal sesuai dengan persyaratan API lain, AbortSignal tampak seperti pilihan yang tepat. Selain itu, mengizinkan promise berantai untuk dapat dibatalkan akan menjadi sangat rumit, bahkan tidak mungkin.

Jika ingin menampilkan objek yang memberikan respons, tetapi juga dapat dibatalkan, Anda dapat membuat wrapper sederhana:

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

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

False starts di TC39

Ada upaya untuk membuat tindakan yang dibatalkan berbeda dari error. Hal ini mencakup status promise ketiga untuk menunjukkan "dibatalkan", dan beberapa sintaksis baru untuk menangani pembatalan dalam kode sinkron dan asinkron:

Larangan

Bukan kode sungguhan — proposal telah ditarik

    try {
      // Start spinner, then:
      await someAction();
    }
    catch cancel (reason) {
      // Maybe do nothing?
    }
    catch (err) {
      // Show error message
    }
    finally {
      // Stop spinner
    }

Tindakan yang paling umum dilakukan saat tindakan dibatalkan adalah tidak melakukan apa pun. Proposal di atas memisahkan pembatalan dari error sehingga Anda tidak perlu menangani error pembatalan secara khusus. catch cancel memungkinkan Anda mengetahui tindakan yang dibatalkan, tetapi biasanya Anda tidak perlu melakukannya.

Proposal ini mencapai tahap 1 di TC39, tetapi tidak mencapai konsensus, dan proposal ditarik.

Proposal alternatif kami, AbortController, tidak memerlukan sintaksis baru, sehingga tidak masuk akal untuk menentukannya dalam TC39. Semua yang kita butuhkan dari JavaScript sudah ada, jadi kita menentukan antarmuka dalam platform web, khususnya standar DOM. Setelah kami membuat keputusan tersebut, hal lainnya berjalan relatif cepat.

Perubahan spesifikasi yang besar

XMLHttpRequest telah dapat dibatalkan selama bertahun-tahun, tetapi spesifikasinya cukup samar. Tidak jelas pada titik mana aktivitas jaringan yang mendasarinya dapat dihindari, atau dihentikan, atau apa yang terjadi jika ada kondisi perlombaan antara abort() yang dipanggil dan pengambilan selesai.

Kami ingin melakukannya dengan benar kali ini, tetapi hal itu mengakibatkan perubahan spesifikasi besar yang memerlukan banyak peninjauan (itu adalah kesalahan saya, dan terima kasih banyak kepada Anne van Kesteren dan Domenic Denicola karena telah membantu saya mengatasinya) dan serangkaian pengujian yang memadai.

Namun, kita sudah sampai. Kami memiliki primitif web baru untuk membatalkan tindakan asinkron, dan beberapa pengambilan dapat dikontrol sekaligus. Selanjutnya, kita akan melihat cara mengaktifkan perubahan prioritas selama proses pengambilan, dan API tingkat yang lebih tinggi untuk mengamati progres pengambilan.