Прерываемая выборка

Исходная проблема GitHub для «Прерывания выборки» была открыта в 2015 году. Теперь, если я отниму 2015 год от 2017 года (текущий год), я получу 2. Это демонстрирует математическую ошибку, потому что 2015 год фактически был «навсегда» назад. .

В 2015 году мы впервые начали изучать возможность прерывания текущей выборки, и после 780 комментариев GitHub, пары фальстартов и пяти запросов на включение мы, наконец, получили возможность прерывания выборки в браузерах, первым из которых был Firefox 57.

Обновление: Неееет, я был неправ. Edge 16 первым появился с поддержкой прерывания! Поздравляем команду Edge!

В историю я углублюсь позже, но сначала API:

Контроллер + сигнальный маневр

Встречайте AbortController и AbortSignal :

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

Контроллер имеет только один метод:

controller.abort();

Когда вы это сделаете, он уведомит сигнал:

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

Этот API предоставляется стандартом DOM , и это весь API. Он намеренно является универсальным, чтобы его можно было использовать в других веб-стандартах и ​​библиотеках JavaScript.

Сигналы прерывания и выборка

Fetch может принять AbortSignal . Например, вот как можно сделать тайм-аут выборки через 5 секунд:

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

Когда вы прерываете выборку, она прерывает как запрос, так и ответ, поэтому любое чтение тела ответа (например, response.text() ) также прерывается.

Вот демо . На момент написания статьи единственным браузером, поддерживающим это, был Firefox 57. Кроме того, будьте готовы: в создании демо не участвовал никто с какими-либо дизайнерскими навыками.

Альтернативно, сигнал может быть передан объекту запроса, а затем передан для выборки:

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

fetch(request);

Это работает, потому что request.signal — это AbortSignal .

Реакция на прерванную выборку

Когда вы прерываете асинхронную операцию, обещание отклоняется с исключением DOMException с именем 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);
    }
});

Нечасто нужно показывать сообщение об ошибке, если пользователь прервал операцию, поскольку это не будет «ошибкой», если вы успешно выполните то, что попросил пользователь. Чтобы избежать этого, используйте оператор if, подобный приведенному выше, специально для обработки ошибок прерывания.

Вот пример, который дает пользователю кнопку для загрузки контента и кнопку для отмены. Если при выборке возникают ошибки, отображается ошибка, если только это не ошибка прерывания:

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

Вот демо . На момент написания статьи это поддерживают только Edge 16 и Firefox 57.

Один сигнал, много выборок

Один сигнал может использоваться для одновременного прерывания нескольких выборок:

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

В приведенном выше примере один и тот же сигнал используется для начальной выборки и для параллельной выборки глав. Вот как можно использовать fetchStory :

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

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

В этом случае вызов controller.abort() прервет все текущие выборки.

Будущее

Другие браузеры

Edge проделал отличную работу, выпустив это первым, и Firefox идет по горячим следам. Их инженеры реализовали их из набора тестов во время написания спецификации. Для других браузеров следующие билеты:

У сервисного работника

Мне нужно закончить спецификацию деталей сервис-воркера , но вот план:

Как я упоминал ранее, каждый объект Request имеет свойство signal . Внутри сервис-воркера fetchEvent.request.signal подаст сигнал об отмене, если страница больше не заинтересована в ответе. В результате такой код просто работает:

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

Если страница прерывает выборку, fetchEvent.request.signal сигнализирует об отмене, поэтому выборка внутри сервис-воркера также прерывается.

Если вы извлекаете что-то отличное от event.request , вам нужно будет передать сигнал вашим пользовательским функциям выборки.

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

Следуйте спецификации , чтобы отслеживать это — я добавлю ссылки на билеты браузера, как только они будут готовы к реализации.

История

Да… потребовалось много времени, чтобы создать этот относительно простой API. Вот почему:

Разногласия по API

Как видите, обсуждение на GitHub довольно долгое . В этом потоке много нюансов (и некоторых нюансов не хватает), но ключевое разногласие заключается в том, что одна группа хотела, чтобы метод abort существовал для объекта, возвращаемого fetch() , тогда как другая хотела разделить получение ответа и влияет на реакцию.

Эти требования несовместимы, поэтому одна группа не могла получить то, что хотела. Если это ты, извини! Если вам от этого легче, я тоже был в этой группе. Но тот факт, что AbortSignal соответствует требованиям других API, делает его правильным выбором. Кроме того, сделать возможным прерывание связанных обещаний станет очень сложно, если не невозможно.

Если вы хотите вернуть объект, который предоставляет ответ, но также может прервать его, вы можете создать простую оболочку:

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

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

Ложные старты в TC39

Была предпринята попытка отличить отмененное действие от ошибки. Сюда входило третье состояние обещания, обозначающее «отменено», и некоторый новый синтаксис для обработки отмены как в синхронном, так и в асинхронном коде:

Не

Не настоящий код — предложение было отозвано

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

Чаще всего при отмене действия — ничего не делать. В приведенном выше предложении отмена отделена от ошибок, поэтому вам не нужно специально обрабатывать ошибки прерывания. catch cancel позволит вам узнать об отмененных действиях, но в большинстве случаев вам это не понадобится.

Это дошло до стадии 1 в TC39, но консенсус не был достигнут, и предложение было отозвано .

Наше альтернативное предложение, AbortController , не требовало какого-либо нового синтаксиса, поэтому не имело смысла специфицировать его в TC39. Все, что нам нужно от JavaScript, уже было, поэтому мы определили интерфейсы внутри веб-платформы, в частности стандарт DOM . Как только мы приняли это решение, все остальное сложилось относительно быстро.

Большое изменение спецификации

XMLHttpRequest уже много лет нельзя было прерывать, но спецификация была довольно расплывчатой. Было неясно, в какие моменты базовую сетевую активность можно было избежать или прекратить, а также что произойдет, если возникнет состояние гонки между вызовом abort() и завершением выборки.

На этот раз мы хотели сделать все правильно, но это привело к большим изменениям в спецификации, которые потребовали тщательного рассмотрения (это моя вина, и огромное спасибо Анне ван Кестерен и Доменику Дениколе за то, что они протащили меня через это) и приличному набору тесты .

Но мы сейчас здесь! У нас есть новый веб-примитив для прерывания асинхронных действий, и можно управлять несколькими выборками одновременно! Далее мы рассмотрим возможность изменения приоритетов на протяжении всего процесса выборки, а также API более высокого уровня для наблюдения за ходом выборки .