Przerwane pobieranie

Jake Archibald
Jake Archibald

Oryginalny problem z serwisem GitHub związany z przerwaniem pobierania został opublikowany w 2015 roku. Jeśli teraz odbiorę rok 2015 od roku 2017 (rok bieżący), dostanę 2. To pokazuje błąd w matematyce, bo rok 2015 był tak naprawdę zawsze „przeżycie”.

W 2015 roku zaczęliśmy badać przerywanie pobierania. Po 780 komentarzach na GitHubie, kilku fałszywych uruchomieniach i 5 żądaniach pull w przeglądarkach okazało się, że pobieranie treści nie jest możliwe – pierwszy z nich to Firefox 57.

Aktualizacja: nieee, myliłam się. Na urządzeniach Edge 16 najpierw wyłączono obsługę przerwania. Gratulacje dla zespołu Edge!

Omówię ją później, ale najpierw zajmę się interfejsem API:

Sterowanie kontrolerem i sterowanie sygnałem

Poznaj AbortController i AbortSignal:

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

Kontroler ma tylko 1 metodę:

controller.abort();

Gdy to zrobisz, sygnał będzie sygnalizowany:

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

Ten interfejs API jest udostępniany przez standard DOM. To cały interfejs API. Ma charakter ogólny, więc może być używany przez inne standardy internetowe i biblioteki JavaScript.

Przerwij sygnały i pobierz

Pobieranie może zająć AbortSignal. Oto jak ustawić limit 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);
});

Przerwanie pobierania powoduje przerwanie zarówno żądania, jak i odpowiedzi, więc odczyt treści odpowiedzi (np. response.text()) również jest przerwany.

Oto prezentacja – w momencie tworzenia tego tekstu jedyną przeglądarką obsługującą tę funkcję będzie Firefox w wersji 57. Pamiętaj też, że nikt z umiejętnościami projektowania nie angażował się w tworzenie wersji demonstracyjnej.

Sygnał można też przekazać do obiektu żądania, a później przekazać do pobrania:

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

fetch(request);

Działa, ponieważ request.signal ma status AbortSignal.

Reagowanie na przerwane pobieranie

Gdy przerwiesz operację asynchroniczną, obietnica zostanie odrzucona z 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 warto często wyświetlać komunikatu o błędzie po przerwaniu operacji przez użytkownika, ponieważ wykonanie czynności, o które prosił użytkownik, nie jest „błędem”. Aby tego uniknąć, używaj instrukcji if (takiej jak ta powyżej) do obsługi błędów przerwania.

Oto przykład, który daje użytkownikowi przycisk umożliwiający wczytanie treści i przycisk przerwania działania. 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;
});

Oto prezentacja – w momencie tworzenia tego tekstu jedynymi przeglądarkami, które obsługują tę funkcję, były Edge 16 i Firefox 57.

Jeden sygnał, wiele pobrań

Za pomocą jednego sygnału można przerwać wiele 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 przy początkowym pobieraniu i równoległym pobieraniu rozdziałów. fetchStory:

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

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

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

Przyszłość

Inne przeglądarki

Edge wykonała świetną robotę, żeby wprowadzić tę wersję na rynek, a Firefox jest na wyciągnięcie ręki. W trakcie pisania specyfikacji inżynierowie wdrażali kod z pakietu testów. W przypadku innych przeglądarek dostępne są następujące bilety:

W mechanizmie service worker

Muszę dokończyć specyfikację części mechanizmu service worker, ale wygląda to tak:

Jak już wspomnieliśmy, każdy obiekt Request ma właściwość signal. W skrypcie service worker fetchEvent.request.signal sygnalizuje, że przerwiemy, jeśli strona nie będzie już zainteresowana odpowiedzią. W efekcie ten kod po prostu działa:

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

Jeśli strona przerwie pobieranie, sygnał fetchEvent.request.signal zostanie przerwany, więc pobieranie w skrypcie service worker też zostanie przerwane.

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

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 to śledzić, postępuj zgodnie ze specyfikacją – dodam linki do zgłoszeń w przeglądarce, gdy będą gotowe do implementacji.

Historia

Tak... Opracowanie stosunkowo prostego interfejsu API zajęło dużo czasu. Wyjaśnijmy to:

Niezgodność z interfejsem API

Jak widać, dyskusja na GitHubie jest dość długa. W tym wątku jest wiele niuansów (i pewność braku niuansów), jednak jedna z najważniejszych rozbieżności wynika z tego, że jedna z grup chciała, aby metoda abort istniała w obiekcie zwróconym przez fetch(), a druga – o oddzielenie odpowiedzi od odpowiedzi.

Te wymagania są niezgodne, więc jedna z grup nie otrzyma tego, czego chciała. Jeśli to Twoja wina, przepraszamy! Jeśli masz nadzieję, że poczujesz się lepiej, też należałem do tej grupy. Jednak ze względu na to, że AbortSignal spełnia wymagania innych interfejsów API, wydaje się, że jest to właściwy wybór. Poza tym dopuszczenie do złożonego obietnicy złożonej w pionie staje się bardzo skomplikowane, a nawet niemożliwe.

Jeśli chcesz zwrócić obiekt, który zwraca odpowiedź, ale może też przerwać procedurę, możesz utworzyć prosty kod:

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

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

Fałszywe zaczyna się w TC39

Podjęto próbę odróżniania anulowanego działania od błędu. Obejmował on trzeci stan obietnicy wskazujący na „cancelled” oraz nową składnię do obsługi anulowania zarówno w kodzie synchronizacji, jak i asynchronicznych:

Nie

To nie jest prawdziwy kod – oferta pakietowa została wycofana

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

Najczęstsze czynności, jakie należy wykonać, gdy działanie zostanie anulowane, to nic. Powyższa oferta pakietowa oddzielała anulowanie od błędów, dzięki czemu nie trzeba było specjalnie obsługiwać błędów przerwania. catch cancel informuje o anulowanych działaniach, ale w większości przypadków nie jest to konieczne.

Osiągnęliśmy pierwszy etap w ramach TC39, ale nie osiągnięto konsensusu i oferta została wycofana.

Nasza alternatywna propozycja (AbortController) nie wymagała nowej składni, więc jej określenie w ramach TC39 nie miało sensu. Wszystko, czego potrzebowaliśmy od JavaScriptu, było już dostępne, dlatego zdefiniowaliśmy interfejsy na platformie internetowej, a w szczególności standard DOM. Gdy podjęliśmy tę decyzję, reszta prac szybko nadeszła.

Duża zmiana specyfikacji

XMLHttpRequest to narzędzie, które można przerwać od lat, ale specyfikacja była dość niejasna. Nie było jasne, w których momentach można uniknąć lub przerwać podstawową aktywność w sieci albo co się stało, jeśli między wywołaniem funkcji abort() a zakończeniem pobierania wystąpił warunek wyścigu.

Chcieliśmy, aby wszystko działało jak należy, ale spowodowało to dużą zmianę w specyfikacji, która wymagała wielu sprawdzenia (to moja wina. Dziękuję Anne van Kesteren i Domenicowi Denicoli za opanowanie tego procesu) i przyzwoity zestaw testów.

Jesteśmy na miejscu Opracowaliśmy nowy element internetowy do przerywania działań asynchronicznych. Można też kontrolować wiele pobrań jednocześnie. W dalszej części omówimy włączanie zmian priorytetowych w trakcie pobierania oraz interfejs API wyższego poziomu do obserwowania postępu pobierania.