Przerwane pobieranie

Jake Archibald
Jake Archibald

Pierwotny problem na GitHubie dotyczący „przerywania pobierania” został zgłoszony w 2015 r. Jeśli teraz od 2017 r. (bieżący rok) odejmiesz 2015 r., otrzymasz 2. To pokazuje błąd matematyczny, ponieważ rok 2015 to już „wieczność” temu.

W 2015 r. zaczęliśmy badać możliwość przerwania trwającego pobierania. Po 780 komentarzach na GitHubie, kilku nieudanych próbach i 5 żądaniach pull w końcu udało nam się wprowadzić tę funkcję w przeglądarkach. Pierwszą z nich była Firefox 57.

Aktualizacja: nie, nie mam racji. W Edge 16 po raz pierwszy pojawiła się obsługa anulowania. Gratulacje dla zespołu Edge!

Historię omówię później, ale najpierw o interfejsie API:

Sterowanie i manewr sygnału

Poznaj AbortControllerAbortSignal:

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

Kontroler ma tylko jedną metodę:

controller.abort();

Gdy to zrobisz, sygnał zostanie powiadomiony:

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

Ten interfejs API jest udostępniany przez standard DOM i to jest cały interfejs API. Jest on celowo ogólny, aby można było go używać w ramach innych standardów internetowych i bibliotek JavaScript.

Przerywanie sygnałów i pobierania

Pobieranie może potrwać AbortSignal. Oto przykład ustawienia limitu czasu pobierania po 5 sekundach:

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

Gdy przerwiesz pobieranie, zostanie przerwane zarówno żądanie, jak i odpowiedź, więc odczyt treści odpowiedzi (np. response.text()) zostanie również przerwany.

Tutaj znajdziesz wersję demonstracyjną – w momencie pisania tego artykułu jedyną przeglądarką, która obsługuje tę funkcję, jest Firefox 57. Uprzedzam, że nikt z doświadczeniem w zakresie projektowania nie brał udziału w tworzeniu tej wersji demonstracyjnej.

Możesz też przekazać sygnał do obiektu żądania, a potem przekazać go do funkcji pobierania:

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

fetch(request);

Działa to, ponieważ request.signal jest AbortSignal.

Reakcja na przerwany proces pobierania

Gdy przerwiesz asynchroniczną operację, obietnica zostanie odrzucona z wartością DOMException o nazwie 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);
    }
});

Nie musisz często wyświetlać komunikatu o błędzie, jeśli użytkownik przerwie operację, ponieważ nie jest to „błąd”, jeśli wykonasz to, o co prosił użytkownik. Aby tego uniknąć, użyj instrukcji if, takiej jak ta powyżej, aby obsłużyć błędy przerwania.

Oto przykład, w którym użytkownik ma przycisk wczytywania treści i przycisk anulowania. Jeśli wystąpi błąd pobierania, wyświetli się błąd, chyba że jest to błąd przerwania:

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

Tutaj możesz zobaczyć demonstrację – w momencie pisania tego artykułu jedynymi przeglądarkami, które to obsługują, są Edge 16 i Firefox 57.

Jeden sygnał, wiele pobierania

Jeden sygnał może służyć do anulowania wielu pobierania naraz:

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

W powyższym przykładzie ten sam sygnał jest używany do pobierania początkowego i równoległych pobrań rozdziałów. Oto jak używać fetchStory:

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

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

W takim przypadku wywołanie funkcji controller.abort() spowoduje przerwanie trwających pobierania.

Przyszłość

Inne przeglądarki

Edge świetnie sobie poradził z tym zadaniem, a Firefox niedługo potem. Inżynierowie korzystali z zestawu testów podczas pisania specyfikacji. Jeśli używasz innej przeglądarki, wykonaj te czynności:

W skrypcie service worker

Muszę dokończyć specyfikację części usługi dla service workera, ale oto plan:

Jak już wspomniałem, każdy obiekt Request ma właściwość signal. W ramach usługi dla pracowników fetchEvent.request.signal sygnalizuje przerwanie, jeśli strona nie jest już zainteresowana odpowiedzią. W rezultacie kod wygląda tak:

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

Jeśli strona przerwie pobieranie, fetchEvent.request.signal sygnalizuje przerwanie, więc pobieranie w ramach service workera również zostanie przerwane.

Jeśli pobierasz coś innego niż event.request, musisz przekazać sygnał do niestandardowych funkcji pobierania.

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

Aby śledzić te dane, postępuj zgodnie z specyfikacją. Dodam linki do zgłoszeń dotyczących przeglądarki, gdy będą gotowe do wdrożenia.

Historia

Tak, zajęło to dużo czasu, aby ten stosunkowo prosty interfejs API został ukończony. Wyjaśnijmy to:

Niezgodność interfejsu API

Jak widzisz, dyskusja na GitHubie jest dość długa. Wątek zawiera wiele niuansów (i trochę braku niuansów), ale główne rozbieżności dotyczą metody abort w obiekcie zwracanym przez funkcję fetch(). Jedna grupa chciała, aby metoda abort istniała w obiekcie zwracanym przez funkcję fetch(), a druga grupa chciała oddzielić od siebie uzyskiwanie odpowiedzi i wpływanie na nią.

Te wymagania są ze sobą niezgodne, więc jedna grupa nie uzyskała tego, czego chciała. Jeśli tak, przepraszam. Jeśli to pomoże, to ja też należę do tej grupy. Jednak AbortSignal spełnia wymagania innych interfejsów API, więc wydaje się być właściwym wyborem. Poza tym umożliwienie anulowania łańcuchów obietnic byłoby bardzo skomplikowane, a nawet niemożliwe.

Jeśli chcesz zwrócić obiekt, który dostarcza odpowiedź, ale może też zostać przerwany, możesz utworzyć prosty opakowanie:

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

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

False starts in TC39

Staraliśmy się odróżnić anulowane działanie od błędu. Dodano trzeci stan obietnicy, aby oznaczać stan „anulowano”, oraz nową składnię do obsługi anulowania zarówno w kodowaniu synchronicznym, jak i asynchronicznym:

Nie

Nieprawdziwy kod – oferta została wycofana

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

Najczęstszym działaniem po anulowaniu działania jest brak działania. W wyniku powyższego rozwiązania anulowanie zostało odseparowane od błędów, więc nie musisz osobno obsługiwać błędów przerwania. catch cancel informują o anulowanych działaniach, ale w większości przypadków nie jest to konieczne.

Dotarło do etapu 1 w TC39, ale nie udało się osiągnąć konsensusu, więc propozycja została wycofana.

Nasza propozycja alternatywna, AbortController, nie wymagała nowej składni, więc nie miało sensu specyfikowanie jej w TC39. Wszystko, czego potrzebowaliśmy z JavaScriptu, było już dostępne, więc zdefiniowaliśmy interfejsy w ramach platformy internetowej, w szczególności standard DOM. Po podjęciu tej decyzji reszta poszła dość szybko.

Duża zmiana specyfikacji

Funkcja XMLHttpRequest była od lat możliwa do przerwania, ale specyfikacja była dość niejasna. Nie było jasne, w jakich momentach można uniknąć lub zakończyć podstawową aktywność sieciową ani co się dzieje, jeśli wystąpiła kolizja między wywołaniem funkcji abort() a zakończeniem pobierania.

Chcieliśmy, aby tym razem wszystko było w porządku, ale wiązało się to z dużą zmianą specyfikacji, która wymagała dużo pracy (to moja wina, ale dziękuję Anne van KesterenDomenicowi Denicola za pomoc w tym procesie) i odpowiedniego zestawu testów.

Jesteśmy już na miejscu. Mamy nowy element webowy do anulowania działań asynchronicznych, a także możliwość jednoczesnego kontrolowania wielu pobierania. W przyszłości rozważymy umożliwienie zmian priorytetów w trakcie pobierania oraz udostępnienie na wyższym poziomie interfejsu API do obserwowania postępów pobierania.