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

Jake Archibald
Jake Archibald

Vấn đề gốc trên GitHub liên quan đến việc "Huỷ tìm nạp" đã được phát hành vào năm 2015. Bây giờ, nếu lấy năm 2015 cách xa năm 2017 (năm hiện tại) thì tôi được nhận 2. Điều này cho thấy một lỗi trong toán học, vì thực ra năm 2015 là "vĩnh viễn" trước đây.

Năm 2015 là khi chúng tôi lần đầu tiên bắt đầu khám phá việc huỷ bỏ các lượt tìm nạp liên tục và sau 780 nhận xét trên GitHub, một vài lượt khởi động sai và 5 yêu cầu lấy dữ liệu, cuối cùng chúng tôi đã xác định được đích tìm nạp có thể huỷ được trong các trình duyệt, trong đó đầu tiên là Firefox 57.

Tin cập nhật: Không, tôi đã nhầm. Edge 16 đã hạ cánh nhờ tính năng hỗ trợ huỷ trước tiên! Xin 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, API:

Bộ điều khiển và điều khiển tín hiệu

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 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. Đây là một API có chủ ý chung, vì vậy, các tiêu chuẩn web và thư viện JavaScript khác cũng có thể sử dụng công cụ này.

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

Quá trình tìm nạp có thể lấy 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. 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 bị huỷ.

Đây là bản minh hoạ – Tại thời điểm viết, trình duyệt duy nhất hỗ trợ trình duyệt này là Firefox 57. Ngoài ra, hãy tự chuẩn bị tinh thần vì không có ai có bất kỳ kỹ năng thiết kế nào tham gia vào việc 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 một yêu cầu tìm nạp bị huỷ bỏ

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

Thường thì bạn 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 theo 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, chẳng hạn như câu lệnh ở trên để xử lý cụ thể các lỗi huỷ.

Dưới đây là ví dụ cho thấy người dùng có một nút để tải nội dung và một nút để huỷ. Nếu lỗi tìm nạp, 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;
});

Đâ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à Edge 16 và Firefox 57.

Một tín hiệu cho nhiều lượt tìm nạp

Có thể sử dụng một tín hiệu duy nhất để huỷ nhiều lượt 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, tín hiệu tương tự được dùng cho lần tìm nạp ban đầu và cho quá trình tìm nạp chương song song. Dưới đây là cách bạn 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ỷ bất kỳ lần tìm nạp nào đang diễn ra.

Tương lai

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

Edge đã làm rất tốt khi đưa ra thị trường đầu tiên và Firefox đang rất "nóng hổi" trong ngành. Các kỹ sư của họ đã triển khai từ bộ thử nghiệm trong khi đang viết thông số kỹ thuật. Đối với các trình duyệt khác, bạn nên tuân theo các yêu cầu sau:

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

Tôi cần hoàn tất thông số kỹ thuật cho các bộ phận của trình chạy dịch vụ, 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 bị huỷ nếu trang không còn quan tâm đến phản hồi. Do đó, mã như sau chỉ 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ẽ báo hiệu sẽ huỷ, khi đó, hoạt động tìm nạp trong trình chạy dịch vụ cũng sẽ bị 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ày đến(các) 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 tuân theo thông số kỹ thuật để theo dõi vấn đề này – Tôi sẽ thêm đường liên kết đến phiếu yêu cầu hỗ trợ trình duyệt khi thông số này đã sẵn sàng cho việc triển khai.

Lịch sử

Vâng... đã mất nhiều thời gian để kết hợp 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 luồng đó (và một số 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 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ì vậy một nhóm sẽ không nhận được những gì họ muốn. Nếu đó là bạn, Rất xin lỗi bạn! Nếu bạn thấy thoải mái hơn, tôi cũng thuộc nhóm đó. Tuy nhiên, việc thấy AbortSignal phù hợp với yêu cầu của các API khác có vẻ 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 có thể huỷ đượ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 })
    };
}

Giá trị False bắt đầu trong TC39

Chúng tôi đã cố gắng làm cho tác vụ đã huỷ phân biệt được 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ộ hoá 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
    }

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

Yêu cầu này đã chuyển đế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 kỹ thuật này trong TC39 không hợp lý. Mọi thứ chúng tôi cần từ JavaScript đã có sẵn, vì vậy, chúng tôi 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 đó, những người còn lại sẽ đến với nhau tương đối nhanh chóng.

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 khá mơ hồ. Không rõ thời điểm có thể tránh hay chấm dứt hoạt động mạng cơ bản, hoặc điều gì đã xảy ra nếu có một tình huống tương tranh giữa abort() được gọi và quá trình tìm nạp hoàn tất.

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

Nhưng giờ đây chúng tôi đã sẵn sàng! 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à bạn có thể kiểm soát nhiều lần tìm nạp cùng một lúc! Trong tương lai, chúng ta sẽ xem xét việc bật các thay đổi về mức độ ưu tiên trong suốt quá trình tìm nạp và một API cấp cao hơn để quan sát tiến trình tìm nạp.