Tìm nạp có thể hủy

Jake Archibald
Jake Archibald

Vấn đề ban đầu trên GitHub về "Huỷ tìm nạp" được mở vào năm 2015. Bây giờ, nếu tôi lấy 2015 trừ đi 2017 (năm hiện tại), tôi sẽ được 2. Điều này cho thấy một lỗi trong toán học, vì năm 2015 thực sự là "mãi mãi" trước đây.

Năm 2015 là lần đầu tiên chúng tôi bắt đầu khám phá việc huỷ các lượt tìm nạp đang diễn ra. Sau 780 bình luận trên GitHub, một vài lần khởi động không thành công và 5 yêu cầu gộp các thay đổi mà bạn thực hiện vào mã nguồn ban đầu, cuối cùng chúng tôi đã có trang đích tìm nạp có thể huỷ trong trình duyệt, trong đó Firefox 57 là trình duyệt đầu tiên.

Cập nhật: Không, tôi đã nhầm. Edge 16 đã ra mắt với tính năng hỗ trợ huỷ trước tiên! Chúc mừng nhóm Edge!

Tôi sẽ đi sâu vào lịch sử sau, nhưng trước tiên, hãy xem API:

Bộ điều khiển + thao tác tín hiệu

Giới thiệu về AbortControllerAbortSignal:

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

Trình điều khiển chỉ có một phương thức:

controller.abort();

Khi bạn làm như vậy, hệ thống sẽ thông báo cho tín hiệu:

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

API này do tiêu chuẩn DOM cung cấp và đó là toàn bộ API. Lớp này được thiết kế chung để các tiêu chuẩn web và thư viện JavaScript khác có thể sử dụng.

Tín hiệu huỷ và tìm nạp

Phương thức tìm nạp có thể lấy AbortSignal. Ví dụ: sau đây là cách bạn đặt thời gian chờ tìm nạp sau 5 giây:

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

Khi bạn huỷ một lượt tìm nạp, thao tác này sẽ huỷ cả yêu cầu và phản hồi, vì vậy, mọi hoạt động đọc nội dung phản hồi (chẳng hạn như response.text()) cũng sẽ bị huỷ.

Sau đây là một bản minh hoạ – Tại thời điểm viết bài, trình duyệt duy nhất hỗ trợ tính năng này là Firefox 57. Ngoài ra, hãy chuẩn bị tinh thần, không có ai có kỹ năng thiết kế nào tham gia vào việc tạo bản minh hoạ này.

Ngoài ra, bạn có thể cung cấp tín hiệu cho đối tượng yêu cầu và sau đó truyền tín hiệu đó để tìm nạp:

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

fetch(request);

Cách này hoạt động vì request.signal là một AbortSignal.

Phản ứng với một lượt tìm nạp bị huỷ

Khi bạn huỷ một thao tác không đồng bộ, lời hứa sẽ từ chối bằng một DOMException có tên là 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);
    }
});

Bạn thường không muốn hiển thị thông báo lỗi nếu người dùng huỷ thao tác, vì đó không phải là "lỗi" nếu bạn thực hiện thành công yêu cầu của người dùng. Để tránh điều này, hãy sử dụng câu lệnh if-statement (câu lệnh if) như câu lệnh ở trên để xử lý các lỗi huỷ một cách cụ thể.

Sau đây là ví dụ cung cấp cho người dùng một nút để tải nội dung và một nút để huỷ. Nếu quá trình tìm nạp lỗi, một lỗi sẽ xuất hiện, trừ phi đó là lỗi huỷ:

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

Sau đây là một bản minh hoạ – Tại thời điểm viết bài, các trình duyệt duy nhất hỗ trợ tính năng này là Edge 16 và Firefox 57.

Một tín hiệu, nhiều lần tìm nạp

Bạn có thể sử dụng một tín hiệu để huỷ nhiều lệnh tìm nạp cùng một lúc:

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

Trong ví dụ trên, cùng một tín hiệu được dùng cho lần tìm nạp ban đầu và cho các lần tìm nạp chương song song. Sau đây là cách sử dụng fetchStory:

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

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

Trong trường hợp này, việc gọi controller.abort() sẽ huỷ mọi lượt tìm nạp đang diễn ra.

Tương lai

Các trình duyệt khác

Edge đã làm rất tốt khi ra mắt tính năng này trước tiên và Firefox đang theo sát họ. Các kỹ sư của họ đã triển khai từ bộ kiểm thử trong khi viết thông số kỹ thuật. Đối với các trình duyệt khác, hãy làm theo các phiếu yêu cầu hỗ trợ sau:

Trong trình chạy dịch vụ

Tôi cần hoàn tất thông số kỹ thuật cho các phần của worker dịch vụ, nhưng đây là kế hoạch:

Như đã đề cập trước đó, mỗi đối tượng Request đều có một thuộc tính signal. Trong một worker dịch vụ, fetchEvent.request.signal sẽ báo hiệu huỷ nếu trang không còn quan tâm đến phản hồi. Do đó, mã như sau sẽ hoạt động:

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

Nếu trang huỷ quá trình tìm nạp, fetchEvent.request.signal sẽ gửi tín hiệu huỷ, vì vậy, quá trình tìm nạp trong worker dịch vụ cũng sẽ huỷ.

Nếu đang tìm nạp nội dung khác ngoài event.request, bạn cần truyền tín hiệu đến(các) lệnh tìm nạp tuỳ chỉnh.

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

Hãy làm theo quy cách để theo dõi vấn đề này – tôi sẽ thêm đường liên kết đến các phiếu yêu cầu hỗ trợ về trình duyệt khi đã sẵn sàng triển khai.

Nhật ký

Vâng… phải mất một thời gian dài để API tương đối đơn giản này được hoàn thiện. Dưới đây là lý do:

Không đồng ý về API

Như bạn có thể thấy, cuộc thảo luận trên GitHub khá dài. Có rất nhiều điểm khác biệt trong luồng đó (và một số điểm không có sự khác biệt), nhưng điểm bất đồng chính là một nhóm muốn phương thức abort tồn tại trên đối tượng do fetch() trả về, trong khi nhóm còn lại muốn tách biệt giữa việc nhận phản hồi và ảnh hưởng đến phản hồi.

Các yêu cầu này không tương thích với nhau, vì vậy, một nhóm sẽ không nhận được những gì họ muốn. Nếu đó là bạn, xin lỗi bạn! Để bạn cảm thấy dễ chịu hơn, tôi cũng từng ở trong nhóm đó. Tuy nhiên, việc thấy AbortSignal phù hợp với các yêu cầu của các API khác khiến nó có vẻ như là lựa chọn phù hợp. Ngoài ra, việc cho phép các lời hứa theo chuỗi trở thành có thể huỷ sẽ trở nên rất phức tạp, nếu không muốn nói là không thể.

Nếu muốn trả về một đối tượng cung cấp phản hồi nhưng cũng có thể huỷ, bạn có thể tạo một trình bao bọc đơn giản:

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

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

Bắt đầu sai trong TC39

Chúng tôi đã cố gắng phân biệt hành động bị huỷ với lỗi. Điều này bao gồm trạng thái lời hứa thứ ba để biểu thị "đã huỷ" và một số cú pháp mới để xử lý việc huỷ trong cả mã đồng bộ và không đồng bộ:

Không nên

Không phải mã thực — đề xuất đã bị rút lại

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

Việc thường làm nhất khi một hành động bị huỷ là không làm gì cả. Đề xuất trên đã phân tách thao tác huỷ với lỗi để bạn không cần phải xử lý riêng lỗi huỷ. catch cancel cho phép bạn biết về các thao tác bị huỷ, nhưng hầu hết thời gian bạn sẽ không cần đến.

Đề xuất này đã đạt đến giai đoạn 1 trong TC39, nhưng không đạt được sự đồng thuận và đề xuất đã bị rút lại.

Đề xuất thay thế của chúng tôi, AbortController, không yêu cầu bất kỳ cú pháp mới nào, vì vậy, việc chỉ định cú pháp này trong TC39 là không hợp lý. Mọi thứ chúng ta cần từ JavaScript đều đã có sẵn, vì vậy, chúng ta đã xác định các giao diện trong nền tảng web, cụ thể là tiêu chuẩn DOM. Sau khi chúng tôi đưa ra quyết định đó, mọi thứ còn lại diễn ra tương đối nhanh chóng.

Thay đổi lớn về thông số kỹ thuật

XMLHttpRequest đã có thể huỷ trong nhiều năm, nhưng thông số kỹ thuật khá mơ hồ. Không rõ tại thời điểm nào có thể tránh hoặc chấm dứt hoạt động mạng cơ bản, hoặc điều gì sẽ xảy ra nếu có điều kiện tương tranh giữa abort() đang được gọi và quá trình tìm nạp hoàn tất.

Lần này, chúng tôi muốn làm đúng ngay từ đầu, nhưng điều đó dẫn đến một thay đổi lớn về thông số kỹ thuật, cần phải xem xét kỹ lưỡng (đó là lỗi của tôi và tôi rất cảm ơn Anne van KesterenDomenic Denicola đã giúp tôi vượt qua vấn đề này) và một bộ kiểm thử hợp lý.

Nhưng giờ đây, chúng ta đã có mặt ở đây! Chúng tôi có một nguyên hàm web mới để huỷ các thao tác không đồng bộ và có thể kiểm soát nhiều lần tìm nạp cùng một lúc! Tiếp theo, chúng ta sẽ xem xét việc bật thay đổi mức độ ưu tiên trong suốt thời gian tìm nạp và một API cấp cao hơn để quan sát tiến trình tìm nạp.