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

Jake Archibald
Jake Archibald

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

Năm 2015 là thời điểm chúng tôi lần đầu tiên bắt đầu khám phá việc huỷ các lần tìm nạp đang diễn ra và sau 780 nhận xét trên GitHub, một vài lần khởi động sai và 5 yêu cầu kéo, cuối cùng chúng ta đã có trang đích tìm nạp có thể huỷ trong trình duyệt, đầu tiên là Firefox 57.

Tin cập nhật: Ôi, tôi đã nhầm. Edge 16 đã hạ cánh với tính năng hỗ trợ huỷ trước tiên! Xin chúc mừng Đội ngũ lợi hại!

Tôi sẽ tìm hiểu sâu hơn về lịch sử sau, nhưng trước tiên là API:

Bộ điều khiển + hoạt động điều khiển tín hiệu

Xin giới thiệu AbortControllerAbortSignal:

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

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

controller.abort();

Khi bạn làm như vậy, Trợ lý sẽ thông báo 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. Bây giờ cố ý chung chung để có thể dùng cho các tiêu chuẩn web và thư viện JavaScript khác.

Huỷ bỏ các tín hiệu và tìm nạp

Quá trình tìm nạp có thể mất AbortSignal. Ví dụ: dưới đây là cách bạn sẽ tạo 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ỷ tìm nạp, thao tác này sẽ huỷ cả yêu cầu và phản hồi, do đó mọi lần đọc nội dung phản hồi (chẳng hạn như response.text()) cũng bị huỷ.

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

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

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

fetch(request);

Phương thức này hiệu quả vì request.signal là một AbortSignal.

Phản ứng với quá trình tìm nạp bị huỷ

Khi bạn huỷ một hoạt động không đồng bộ, lời hứa sẽ từ chối bằng 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 làm thành công những gì người dùng yêu cầu. Để tránh điều này, hãy sử dụng câu lệnh if như ở trên để xử lý cụ thể lỗi huỷ.

Dưới đâ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ỷ bỏ. Nếu phương thức tìm nạp lỗi sẽ xuất hiện, trừ phi đó là lỗi huỷ bỏ:

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

Đây là bản minh hoạ – Tại thời điểm viết bài, trình duyệt duy nhất hỗ trợ 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ể dùng một tín hiệu để huỷ nhiều lần 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 sử dụng cho lần tìm nạp ban đầu và cho chương song song tìm nạp. Dưới đây là cách bạn sẽ 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ần 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 trong việc đưa sản phẩm này đầu tiên và Firefox rất thu hút họ. Các kỹ sư của công ty được triển khai từ bộ kiểm thử trong khi thông số kỹ thuật được đang được viết. Đối với các trình duyệt khác, bạn nên làm theo các bước sau:

Trong một trình chạy dịch vụ

Tôi cần hoàn thành thông số kỹ thuật cho các bộ phận của worker bảo dưỡng, nhưng sau đây là kế hoạch:

Như tôi đã đề cập trước đó, mỗi đối tượng Request đều có một thuộc tính signal. Trong một trình chạy 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ư thế này chỉ hoạt động:

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

Nếu trang huỷ tìm nạp, fetchEvent.request.signal sẽ báo hiệu sẽ huỷ, do đó, quá trình tìm nạp trong Service worker cũng huỷ.

Nếu đang tìm nạp dữ liệu nào đó không phải là event.request, bạn cần truyền tín hiệu đến (các) lần 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 })
    );
    }
});

Làm theo thông số kỹ thuật để theo dõi điều này – Tôi sẽ thêm liên kết tới khi sẵn sàng để triển khai.

Lịch sử

Đúng vậy... mất nhiều thời gian để sử dụng API tương đối đơn giản này. 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 sắc thái trong chủ đề đó (và một số điểm thiếu sắc thái), nhưng 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 được fetch() trả về, trong khi phương thức khác muốn tách biệt giữa việc nhận phản hồi và tác độ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 không có được những gì họ muốn. Nếu trường hợp đó rất tiếc! Nếu bạn thấy thoải mái hơn, thì tôi cũng thuộc nhóm đó. Nhưng thấy AbortSignal phù hợp với yêu cầu của các API khác khiến đây có vẻ là lựa chọn phù hợp. Ngoài ra, việc cho phép hứa hẹn theo chuỗi có thể huỷ bỏ được 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 báo cáo False trong TC39

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

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
    }

Trường hợp phổ biến nhất cần làm khi một tác vụ bị huỷ là không có gì. Đề xuất ở trên đã tách riêng để huỷ bỏ lỗi, nhờ đó bạn không cần phải xử lý cụ thể lỗi huỷ. catch cancel cho phép bạn nghe về các hành động bị huỷ, nhưng hầu hết thời gian bạn không cần làm vậy.

Đề xuất này đã chuyển sang 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 nó không hợp lý để chỉ định mã đó trong TC39. Tất cả những gì chúng tôi cần từ JavaScript đã có sẵn ở đó, vì vậy chúng tôi đã xác định trong nền tảng web, cụ thể là tiêu chuẩn DOM. Sau khi đưa ra quyết định đó, phần còn lại được kết hợp tương đối nhanh.

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

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

Chúng tôi muốn thực hiện ngay lần này, nhưng điều đó đã dẫn đến một sự thay đổi lớn về thông số kỹ thuật và cần rất nhiều bài đánh giá (đó là lỗi của tôi và chân thành cảm ơn Anne van KesterenDomenic Denicola vì đã khuyến khích tôi khám phá) và một loạt các bài kiểm tra ưng ý.

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