23 декабря 2022 г.

Fetch

JavaScript может отправлять сетевые запросы на сервер и подгружать новую информацию по мере необходимости.

Например, мы можем использовать сетевой запрос, чтобы:

  • Отправить заказ,
  • Загрузить информацию о пользователе,
  • Запросить последние обновления с сервера,
  • …и т.п.

Для сетевых запросов из JavaScript есть широко известный термин «AJAX» (аббревиатура от Asynchronous JavaScript And XML). XML мы использовать не обязаны, просто термин старый, поэтому в нём есть это слово. Возможно, вы его уже где-то слышали.

Есть несколько способов делать сетевые запросы и получать информацию с сервера.

Метод fetch() — современный и очень мощный, поэтому начнём с него. Он не поддерживается старыми (можно использовать полифил), но поддерживается всеми современными браузерами.

Базовый синтаксис:

let promise = fetch(url, [options])
  • url – URL для отправки запроса.
  • options – дополнительные параметры: метод, заголовки и так далее.

Без options это простой GET-запрос, скачивающий содержимое по адресу url.

Браузер сразу же начинает запрос и возвращает промис, который внешний код использует для получения результата.

Процесс получения ответа обычно происходит в два этапа.

Во-первых, promise выполняется с объектом встроенного класса Response в качестве результата, как только сервер пришлёт заголовки ответа.

На этом этапе мы можем проверить статус HTTP-запроса и определить, выполнился ли он успешно, а также посмотреть заголовки, но пока без тела ответа.

Промис завершается с ошибкой, если fetch не смог выполнить HTTP-запрос, например при ошибке сети или если нет такого сайта. HTTP-статусы 404 и 500 не являются ошибкой.

Мы можем увидеть HTTP-статус в свойствах ответа:

  • status – код статуса HTTP-запроса, например 200.
  • ok – логическое значение: будет true, если код HTTP-статуса в диапазоне 200-299.

Например:

let response = await fetch(url);

if (response.ok) { // если HTTP-статус в диапазоне 200-299
  // получаем тело ответа (см. про этот метод ниже)
  let json = await response.json();
} else {
  alert("Ошибка HTTP: " + response.status);
}

Во-вторых, для получения тела ответа нам нужно использовать дополнительный вызов метода.

Response предоставляет несколько методов, основанных на промисах, для доступа к телу ответа в различных форматах:

  • response.text() – читает ответ и возвращает как обычный текст,
  • response.json() – декодирует ответ в формате JSON,
  • response.formData() – возвращает ответ как объект FormData (разберём его в следующей главе),
  • response.blob() – возвращает объект как Blob (бинарные данные с типом),
  • response.arrayBuffer() – возвращает ответ как ArrayBuffer (низкоуровневое представление бинарных данных),
  • помимо этого, response.body – это объект ReadableStream, с помощью которого можно считывать тело запроса по частям. Мы рассмотрим и такой пример несколько позже.

Например, получим JSON-объект с последними коммитами из репозитория на GitHub:

let url = 'https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits';
let response = await fetch(url);

let commits = await response.json(); // читаем ответ в формате JSON

alert(commits[0].author.login);

То же самое без await, с использованием промисов:

fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits')
  .then(response => response.json())
  .then(commits => alert(commits[0].author.login));

Для получения ответа в виде текста используем await response.text() вместо .json():

let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

let text = await response.text(); // прочитать тело ответа как текст

alert(text.slice(0, 80) + '...');

В качестве примера работы с бинарными данными, давайте запросим и выведем на экран логотип спецификации «fetch» (см. главу Blob, чтобы узнать про операции с Blob):

let response = await fetch('/article/fetch/logo-fetch.svg');

let blob = await response.blob(); // скачиваем как Blob-объект

// создаём <img>
let img = document.createElement('img');
img.style = 'position:fixed;top:10px;left:10px;width:100px';
document.body.append(img);

// выводим на экран
img.src = URL.createObjectURL(blob);

setTimeout(() => { // прячем через три секунды
  img.remove();
  URL.revokeObjectURL(img.src);
}, 3000);
Важно:

Мы можем выбрать только один метод чтения ответа.

Если мы уже получили ответ с response.text(), тогда response.json() не сработает, так как данные уже были обработаны.

let text = await response.text(); // тело ответа обработано
let parsed = await response.json(); // ошибка (данные уже были обработаны)

Заголовки ответа

Заголовки ответа хранятся в похожем на Map объекте response.headers.

Это не совсем Map, но мы можем использовать такие же методы, как с Map, чтобы получить заголовок по его имени или перебрать заголовки в цикле:

let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

// получить один заголовок
alert(response.headers.get('Content-Type')); // application/json; charset=utf-8

// перебрать все заголовки
for (let [key, value] of response.headers) {
  alert(`${key} = ${value}`);
}

Заголовки запроса

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

let response = fetch(protectedUrl, {
  headers: {
    Authentication: 'secret'
  }
});

Есть список запрещённых HTTP-заголовков, которые мы не можем установить:

  • Accept-Charset, Accept-Encoding
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Connection
  • Content-Length
  • Cookie, Cookie2
  • Date
  • DNT
  • Expect
  • Host
  • Keep-Alive
  • Origin
  • Referer
  • TE
  • Trailer
  • Transfer-Encoding
  • Upgrade
  • Via
  • Proxy-*
  • Sec-*

Эти заголовки обеспечивают достоверность данных и корректную работу протокола HTTP, поэтому они контролируются исключительно браузером.

POST-запросы

Для отправки POST-запроса или запроса с другим методом, нам необходимо использовать fetch параметры:

  • method – HTTP метод, например POST,
  • body – тело запроса, одно из списка:
    • строка (например, в формате JSON),
    • объект FormData для отправки данных как form/multipart,
    • Blob/BufferSource для отправки бинарных данных,
    • URLSearchParams для отправки данных в кодировке x-www-form-urlencoded, используется редко.

Чаще всего используется JSON.

Например, этот код отправляет объект user как JSON:

let user = {
  name: 'John',
  surname: 'Smith'
};

let response = await fetch('/article/fetch/post/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  body: JSON.stringify(user)
});

let result = await response.json();
alert(result.message);

Заметим, что так как тело запроса body – строка, то заголовок Content-Type по умолчанию будет text/plain;charset=UTF-8.

Но, так как мы посылаем JSON, то используем параметр headers для отправки вместо этого application/json, правильный Content-Type для JSON.

Отправка изображения

Мы можем отправить бинарные данные при помощи fetch, используя объекты Blob или BufferSource.

В этом примере есть элемент <canvas>, на котором мы можем рисовать движением мыши. При нажатии на кнопку «Отправить» изображение отправляется на сервер:

<body style="margin:0">
  <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>

  <input type="button" value="Отправить" onclick="submit()">

  <script>
    canvasElem.onmousemove = function(e) {
      let ctx = canvasElem.getContext('2d');
      ctx.lineTo(e.clientX, e.clientY);
      ctx.stroke();
    };

    async function submit() {
      let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
      let response = await fetch('/article/fetch/post/image', {
        method: 'POST',
        body: blob
      });

      // сервер ответит подтверждением и размером изображения
      let result = await response.json();
      alert(result.message);
    }

  </script>
</body>

Заметим, что здесь нам не нужно вручную устанавливать заголовок Content-Type, потому что объект Blob имеет встроенный тип (image/png, заданный в toBlob). При отправке объектов Blob он автоматически становится значением Content-Type.

Функция submit() может быть переписана без async/await, например, так:

function submit() {
  canvasElem.toBlob(function(blob) {
    fetch('/article/fetch/post/image', {
      method: 'POST',
      body: blob
    })
      .then(response => response.json())
      .then(result => alert(JSON.stringify(result, null, 2)))
  }, 'image/png');
}

Итого

Типичный запрос с помощью fetch состоит из двух операторов await:

let response = await fetch(url, options); // завершается с заголовками ответа
let result = await response.json(); // читать тело ответа в формате JSON

Или, без await:

fetch(url, options)
  .then(response => response.json())
  .then(result => /* обрабатываем результат */)

Параметры ответа:

  • response.status – HTTP-код ответа,
  • response.oktrue, если статус ответа в диапазоне 200-299.
  • response.headers – похожий на Map объект с HTTP-заголовками.

Методы для получения тела ответа:

  • response.text() – возвращает ответ как обычный текст,
  • response.json() – декодирует ответ в формате JSON,
  • response.formData() – возвращает ответ как объект FormData (кодировка form/multipart, см. следующую главу),
  • response.blob() – возвращает объект как Blob (бинарные данные с типом),
  • response.arrayBuffer() – возвращает ответ как ArrayBuffer (низкоуровневые бинарные данные),

Опции fetch, которые мы изучили на данный момент:

  • method – HTTP-метод,
  • headers – объект с запрашиваемыми заголовками (не все заголовки разрешены),
  • body – данные для отправки (тело запроса) в виде текста, FormData, BufferSource, Blob или UrlSearchParams.

В следующих главах мы рассмотрим больше параметров и вариантов использования fetch.

Задачи

Создайте асинхронную функцию getUsers(names), которая получает на вход массив логинов пользователей GitHub, запрашивает у GitHub информацию о них и возвращает массив объектов-пользователей.

Информация о пользователе GitHub с логином USERNAME доступна по ссылке: https://api.github.com/users/USERNAME.

В песочнице есть тестовый пример.

Важные детали:

  1. На каждого пользователя должен приходиться один запрос fetch.
  2. Запросы не должны ожидать завершения друг друга. Надо, чтобы данные приходили как можно быстрее.
  3. Если какой-то запрос завершается ошибкой или оказалось, что данных о запрашиваемом пользователе нет, то функция должна возвращать null в массиве результатов.

Открыть песочницу с тестами для задачи.

Чтобы получить сведения о пользователе, нам нужно вызвать fetch('https://api.github.com/users/USERNAME').

Если ответ приходит cо статусом 200, то вызываем метод .json(), чтобы прочитать JS-объект.

А если запрос завершается ошибкой или код статуса в ответе отличен от 200, то мы просто возвращаем null в массиве результатов.

Вот код:

async function getUsers(names) {
  let jobs = [];

  for(let name of names) {
    let job = fetch(`https://api.github.com/users/${name}`).then(
      successResponse => {
        if (successResponse.status != 200) {
          return null;
        } else {
          return successResponse.json();
        }
      },
      failResponse => {
        return null;
      }
    );
    jobs.push(job);
  }

  let results = await Promise.all(jobs);

  return results;
}

Пожалуйста, обратите внимание: вызов .then прикреплён к fetch, чтобы, когда ответ получен, сразу начинать считывание данных с помощью .json(), не дожидаясь завершения других запросов.

Если бы мы использовали await Promise.all(names.map(name => fetch(...))) и вызывали бы .json() на результатах запросов, то пришлось бы ждать, пока завершатся все из них. Вызывая .json() сразу после каждого fetch, мы добились того, что считывание присланных по каждому запросу данных происходит независимо от других запросов.

Это пример того, как относительно низкоуровневое Promise API может быть полезным, даже если мы в основном используем async/await в коде.

Открыть решение с тестами в песочнице.

Карта учебника