Fetch API 2. Использование Fetch

эта Web-страница является продолжением страницы Fetch API.

Fetch: ход загрузки

Метод fetch позволяет отслеживать процесс получения данных.

Заметим, на данный момент в fetch нет способа отслеживать процесс отправки. Для этого используйте XMLHttpRequest, позже мы его рассмотрим.

Чтобы отслеживать ход загрузки данных с сервера, можно использовать свойство response.body. Это ReadableStream («поток для чтения») – особый объект, который предоставляет тело ответа по частям, по мере поступления. Потоки для чтения описаны в спецификации Streams API.

В отличие от response.text(), response.json() и других методов, response.body даёт полный контроль над процессом чтения, и мы можем подсчитать, сколько данных получено на каждый момент.

Вот примерный код, который читает ответ из response.body:

// вместо response.json() и других методов
const reader = response.body.getReader();

// бесконечный цикл, пока идёт загрузка
while(true) {
  // done становится true в последнем фрагменте
  // value - Uint8Array из байтов каждого фрагмента
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  console.log(`Получено ${value.length} байт`)
}

Результат вызова await reader.read() – это объект с двумя свойствами:

Streams API также описывает асинхронный перебор по ReadableStream, при помощи цикла for await..of, но он пока слабо поддерживается (см. задачи для браузеров), поэтому используем цикл while.

Мы получаем новые фрагменты данных в цикле, пока загрузка не завершится, то есть пока done не станет true.

Чтобы отслеживать процесс загрузки, нам нужно при получении очередного фрагмента прибавлять его длину value к счётчику.

Вот полный рабочий пример, который получает ответ сервера и в процессе получения выводит в консоли длину полученных данных:

(async () => {
// Шаг 1: начинаем загрузку fetch, получаем поток для чтения
let response = await fetch('xhr/commits.json');

const reader = response.body.getReader();

// Шаг 2: получаем длину содержимого ответа
const contentLength = +response.headers.get('Content-Length');

// Шаг 3: считываем данные:
let receivedLength = 0; // количество байт, полученных на данный момент
let chunks = []; // массив полученных двоичных фрагментов (составляющих тело ответа)
while(true) {
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  chunks.push(value);
  receivedLength += value.length;

  console.log(`Получено ${receivedLength} из ${contentLength}`)
}

// Шаг 4: соединим фрагменты в общий типизированный массив Uint8Array
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for(let chunk of chunks) {
  chunksAll.set(chunk, position); // (4.2)
  position += chunk.length;
}

// Шаг 5: декодируем Uint8Array обратно в строку
let result = new TextDecoder("utf-8").decode(chunksAll);

// Готово!
let commits = JSON.parse(result);
alert(commits[0].author.login);
})()

Разберёмся, что здесь произошло:

  1. Мы обращаемся к fetch как обычно, но вместо вызова response.json() мы получаем доступ к потоку чтения response.body.getReader().

    Обратите внимание, что мы не можем использовать одновременно оба эти метода для чтения одного и того же ответа: либо обычный метод response.json(), либо чтение потока response.body.

  2. Ещё до чтения потока мы можем вычислить полную длину ответа из заголовка Content-Length.

    Он может быть нечитаемым при запросах на другой источник ( подробнее в разделе Fetch: запросы на другие сайты ) и, в общем-то, серверу необязательно его устанавливать. Тем не менее, обычно длина указана.

  3. Вызываем await reader.read() до окончания загрузки.

    Всё, что получили, мы складываем по «кусочкам» в массив chunks. Это важно, потому что после того, как ответ получен, мы уже не сможем «перечитать» его, используя response.json() или любой другой способ (попробуйте – будет ошибка).

  4. В самом конце у нас типизированный массив – Uint8Array. В нём находятся фрагменты данных. Нам нужно их склеить, чтобы получить строку. К сожалению, для этого нет специального метода, но можно сделать, например, так:

    1. Создаём chunksAll = new Uint8Array(receivedLength) – массив того же типа заданной длины.
    2. Используем .set(chunk, position) для копирования каждого фрагмента друг за другом в него.
  5. Наш результат теперь хранится в chunksAll. Это не строка, а байтовый массив.

    Чтобы получить именно строку, надо декодировать байты. Встроенный объект TextDecoder как раз этим и занимается. Потом мы можем, если необходимо, преобразовать строку в данные с помощью JSON.parse.

    Что если результат нам нужен в бинарном виде вместо строки? Это ещё проще. Замените шаги 4 и 5 на создание единого Blob из всех фрагментов:

    let blob = new Blob(chunks);

В итоге у нас есть результат (строки или Blob, смотря что удобно) и отслеживание прогресса получения.

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

Fetch: прерывание запроса

Метод fetch возвращает промис. А в JavaScript в целом нет понятия «отмены» промиса. Как же прервать запрос fetch?

Для таких целей существует специальный встроенный объект: AbortController, который можно использовать для отмены не только fetch, но и других асинхронных задач.

Использовать его достаточно просто:

Когда fetch отменяется, его промис завершается с ошибкой AbortError, поэтому мы должны обработать её, например, в try..catch:

(async () => {
// прервать через 1 секунду
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('xhr/f1.php?n=0', {
    signal: controller.signal
  });
} catch(err) {
  if (err.name == 'AbortError') { // обработать ошибку от вызова abort()
    alert("Прервано!");
  } else { throw err; }
}
})();

AbortController – масштабируемый, он позволяет отменить несколько вызовов fetch одновременно.

Например, здесь мы запрашиваем много URL параллельно, и контроллер прерывает их все:

let urls = [...]; // список URL для параллельных fetch

let controller = new AbortController();

let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal
}));

let results = await Promise.all(fetchJobs);

// если откуда-то вызвать controller.abort(),
// то это прервёт все вызовы fetch

Если у нас есть собственные асинхронные задачи, отличные от fetch, мы можем использовать один AbortController для их остановки вместе с fetch.

Нужно лишь слушать его событие abort:

let urls = [...];
let controller = new AbortController();

let ourJob = new Promise((resolve, reject) => { // наша задача
  ...
  controller.signal.addEventListener('abort', reject);
});

let fetchJobs = urls.map(url => fetch(url, { // запросы fetch
  signal: controller.signal
}));

// ожидать выполнения нашей задачи и всех запросов
let results = await Promise.all([...fetchJobs, ourJob]);

// вызов откуда-нибудь ещё:
// controller.abort() прервёт все вызовы fetch и наши задачи

Так что AbortController существует не только для fetch, это универсальный объект для отмены асинхронных задач, в fetch встроена интеграция с ним.

Fetch: запросы на другие сайты

Если мы сделаем запрос fetch на другой веб-сайт, он, вероятно, завершится неудачей.

Например, давайте попробуем запросить http://example.com:

(async () => {
try {
  await fetch('http://example.com');
} catch(err) {
  alert(err); // Failed to fetch
}
})()

Вызов fetch не удался, как и ожидалось.

Ключевым понятием здесь является источник (origin) – комбинация домен/порт/протокол.

Запросы на другой источник – отправленные на другой домен (или даже поддомен), или протокол, или порт – требуют специальных заголовков от удалённой стороны.

Эта политика называется «CORS»: Cross-Origin Resource Sharing («совместное использование ресурсов между разными источниками»).

Зачем нужен CORS?

CORS существует для защиты интернета от злых хакеров.

Серьёзно. Давайте сделаем краткое историческое отступление.

Многие годы скрипт с одного сайта не мог получить доступ к содержимому другого сайта.

Это простое, но могучее правило было основой интернет-безопасности. Например, хакерский скрипт с сайта hacker.com не мог получить доступ к почтовому ящику пользователя на сайте gmail.com. И люди чувствовали себя спокойно.

В то время в JavaScript не было методов для сетевых запросов. Это был «игрушечный» язык для украшения веб-страниц.

Но веб-разработчики жаждали большей власти. Чтобы обойти этот запрет и всё же получать данные с других сайтов, были придуманы разные хитрости.

Использование форм

Одним из способов общения с другим сервером была отправка туда формы <form>. Люди отправляли её в <iframe>, чтобы оставаться на текущей странице, вот так:

<!-- цель формы -->
<iframe name="iframe"></iframe>

<!-- форма могла быть динамически сгенерирована и отправлена с помощью JavaScript -->
<form target="iframe" method="POST" action="http://another.com/…">
  ...
</form>

Таким способом было возможно сделать GET/POST запрос к другому сайту даже без сетевых методов, так как формы можно отправлять куда угодно. Но так как запрещено получать доступ к содержимому <iframe> с другого сайта, прочитать ответ было невозможно.

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

Использование скриптов

Ещё один трюк заключался в использовании тега script. У него может быть любой src, с любым доменом, например <script src="http://another.com/…">. Это даёт возможность загрузить и выполнить скрипт откуда угодно.

Если сайт, например another.com, хотел предоставить данные для такого доступа, он предоставлял так называемый «протокол JSONP» (JSON with Padding)".

Вот как он работал.

Например, нам на нашем сайте нужны данные с сайта http://another.com, скажем, погода:

  1. Сначала, заранее, объявляем глобальную функцию для обработки данных, например gotWeather.

    // 1. Объявить функцию для обработки погодных данных
    function gotWeather({ temperature, humidity }) {
      alert(`температура: ${temperature}, влажность: ${humidity}`);
    }
  2. Затем создаём тег <script> с src="http://another.com/weather.json?callback=gotWeather", при этом имя нашей функции – в URL-параметре callback.

    let script = document.createElement('script');
    script.src = `http://another.com/weather.json?callback=gotWeather`;
    document.body.append(script);
  3. Удалённый сервер с another.com должен в ответ сгенерировать скрипт, который вызывает gotWeather(...) с данными, которые хочет передать.

    // Ожидаемый ответ от сервера выглядит так:
    gotWeather({
      temperature: 25,
      humidity: 78
    });
  4. Когда этот скрипт загрузится и выполнится, наша функция gotWeather получает данные.

Это работает и не нарушает безопасность, потому что обе стороны согласились передавать данные таким образом. А когда обе стороны согласны, то это определённо не хак. Всё ещё существуют сервисы, которые предоставляют такой доступ, так как это работает даже для очень старых браузеров.

Спустя некоторое время в браузерном JavaScript появились методы для сетевых запросов.

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

Простые запросы

Есть два вида запросов на другой источник:

  1. Простые.
  2. Все остальные.

Простые запросы будут попроще, поэтому давайте начнём с них.

Простой запрос – это запрос, удовлетворяющий следующим условиям:

  1. Простой метод: GET, POST или HEAD
  2. Простые заголовки – разрешены только:
    • Accept,
    • Accept-Language,
    • Content-Language,
    • Content-Type со значением application/x-www-form-urlencoded, multipart/form-data или text/plain.

Любой другой запрос считается «непростым». Например, запрос с методом PUT или с HTTP-заголовком API-Key не соответствует условиям.

Принципиальное отличие между ними состоит в том, что «простой запрос» может быть сделан через <form> или <script>, без каких-то специальных методов.

Таким образом, даже очень старый сервер должен быть способен принять простой запрос.

В противоположность этому, запросы с нестандартными заголовками или, например, методом DELETE нельзя создать таким способом. Долгое время JavaScript не мог делать такие запросы. Поэтому старый сервер может предположить, что такие запросы поступают от привилегированного источника, «просто потому, что веб-страница неспособна их посылать».

Когда мы пытаемся сделать непростой запрос, браузер посылает специальный предварительный запрос («предзапрос», по англ. «preflight»), который спрашивает у сервера – согласен ли он принять такой непростой запрос или нет?

И, если сервер явно не даёт согласие в заголовках, непростой запрос не посылается.

Далее мы разберём конкретные детали.

CORS для простых запросов

При запросе на другой источник браузер всегда ставит «от себя» заголовок Origin.

Например, если мы запрашиваем https://anywhere.com/request со страницы http://vpogiba.info/page, заголовки будут такими:

GET /request
Host: anywhere.com
Origin: http://vpogiba.info
..

Как вы можете видеть, заголовок Origin содержит именно источник (домен/протокол/порт), без пути.

Сервер может проверить Origin и, если он согласен принять такой запрос, добавить особый заголовок Access-Control-Allow-Origin к ответу. Этот заголовок должен содержать разрешённый источник (в нашем случае http://vpogiba.info) или звёздочку *. Тогда ответ успешен, в противном случае возникает ошибка.

Здесь браузер играет роль доверенного посредника:

  1. Он гарантирует, что к запросу на другой источник добавляется правильный заголовок Origin.
  2. Он проверяет наличие разрешающего заголовка Access-Control-Allow-Origin в ответе и, если всё хорошо, то JavaScript получает доступ к ответу сервера, в противном случае – доступ запрещается с ошибкой.

Вот пример ответа сервера, который разрешает доступ:

200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: http://vpogiba.info
Заголовки ответа

По умолчанию при запросе к другому источнику JavaScript может получить доступ только к так называемым «простым» заголовкам ответа:

При доступе к любому другому заголовку ответа будет ошибка.

Пожалуйста, обратите внимание: в списке нет заголовка Content-Length!

Этот заголовок содержит полную длину ответа. Поэтому если мы загружаем что-то и хотели бы отслеживать прогресс в процентах, то требуется дополнительное разрешение для доступа к этому заголовку (читайте ниже).

Чтобы разрешить JavaScript доступ к любому другому заголовку ответа, сервер должен указать заголовок Access-Control-Expose-Headers. Он содержит список, через запятую, заголовков, которые не являются простыми, но доступ к которым разрешён.

Например:

200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: http://vpogiba.info
Access-Control-Expose-Headers: Content-Length,API-Key

При таком заголовке Access-Control-Expose-Headers, скрипту разрешено получить заголовки Content-Length и API-Key ответа.

«Непростые» запросы

Мы можем использовать любой HTTP-метод: не только GET/POST, но и PATCH, DELETE и другие.

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

Поэтому, чтобы избежать недопониманий, браузер не делает «непростые» запросы (которые нельзя было сделать в прошлом) сразу. Перед этим он посылает предварительный запрос, спрашивая разрешения.

Предварительный запрос использует метод OPTIONS, у него нет тела, но есть два заголовка:

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

Давайте пошагово посмотрим, как это работает, на примере PATCH запроса (этот метод часто используется для обновления данных) на другой источник:

let response = await fetch('https://site.com/service.json', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json'
    'API-Key': 'secret'
  }
});

Этот запрос не является простым по трём причинам (достаточно одной):

Шаг 1 (предзапрос)

Перед тем, как послать такой запрос, браузер самостоятельно генерирует и посылает предзапрос, который выглядит следующим образом:

OPTIONS /service.json
Host: site.com
Origin: http://vpogiba.info
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,API-Key
Шаг 2 (ответ сервера на предзапрос)

Сервер должен ответить со статусом 200 и заголовками:

Это разрешит будущую коммуникацию, в противном случае возникает ошибка.

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

200 OK
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400

Теперь, когда браузер видит, что PATCH есть в Access-Control-Allow-Methods, а Content-Type,API-Key – в списке Access-Control-Allow-Headers, он посылает наш основной запрос.

Кроме того, ответ на предзапрос кешируется на время, указанное в заголовке Access-Control-Max-Age (86400 секунд, один день), так что последующие запросы не вызовут предзапрос. Они будут отосланы сразу при условии, что соответствуют закешированным разрешениям.

Шаг 3 (основной запрос)

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

Основной запрос имеет заголовок Origin (потому что он идёт на другой источник):

PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: http://vpogiba.info
Шаг 4 (основной ответ)

Сервер не должен забывать о добавлении Access-Control-Allow-Origin к ответу на основной запрос. Успешный предзапрос не освобождает от этого:

Access-Control-Allow-Origin: http://vpogiba.info

После этого JavaScript может прочитать ответ сервера.

Предзапрос осуществляется «за кулисами», невидимо для JavaScript.

JavaScript получает только ответ на основной запрос или ошибку, если со стороны сервера нет разрешения.

Авторизационные данные

Запрос на другой источник по умолчанию не содержит авторизационных данных (credentials), под которыми здесь понимаются куки и заголовки HTTP-аутентификации.

Это нетипично для HTTP-запросов. Обычно запрос к http://site.com сопровождается всеми куки с этого домена. Но запросы на другой источник, сделанные методами JavaScript – исключение.

Например, fetch('http://another.com') не посылает никаких куки, даже тех (!), которые принадлежат домену another.com.

Почему?

Потому что запрос с авторизационными данными даёт намного больше возможностей, чем без них. Если он разрешён, то это позволяет JavaScript действовать от имени пользователя и получать информацию, используя его авторизационные данные.

Действительно ли сервер настолько доверяет скрипту? Тогда он должен явно разрешить такие запросы при помощи дополнительного заголовка.

Чтобы включить отправку авторизационных данных в fetch, нам нужно добавить опцию credentials: "include", вот так:

fetch('http://another.com', {
  credentials: "include"
});

Теперь fetch пошлёт куки с домена another.com вместе с нашим запросом на этот сайт.

Если сервер согласен принять запрос с авторизационными данными, он должен добавить заголовок Access-Control-Allow-Credentials: true к ответу, в дополнение к Access-Control-Allow-Origin.

Например:

200 OK
Access-Control-Allow-Origin: http://vpogiba.info
Access-Control-Allow-Credentials: true

Пожалуйста, обратите внимание: в Access-Control-Allow-Origin запрещено использовать звёздочку * для запросов с авторизационными данными. Там должен быть именно источник, как показано выше. Это дополнительная мера безопасности, чтобы гарантировать, что сервер действительно знает, кому он доверяет делать такие запросы.

Итого

С точки зрения браузера запросы к другому источнику бывают двух видов: «простые» и все остальные.

Простые запросы должны удовлетворять следующим условиям:

Основное их отличие заключается в том, что простые запросы с давних времён выполнялись с использованием тегов <form> или <script>, в то время как непростые долгое время были невозможны для браузеров.

Практическая разница состоит в том, что простые запросы отправляются сразу с заголовком Origin, а для других браузер делает предварительный запрос, спрашивая разрешения.

Для простых запросов:

Дополнительно, чтобы разрешить JavaScript доступ к любым заголовкам ответа, кроме Cache-Control, Content-Language, Content-Type, Expires, Last-Modified или Pragma, сервер должен перечислить разрешённые в заголовке Access-Control-Expose-Headers.

Для непростых запросов перед основным запросом отправляется предзапрос: