- Операторы
- Управляющие инструкции
- JS Объекты
- браузер BOM
- HTML DOM
- События
- HTML Объекты
- Промисы, async/await
- Сетевые запросы
- XMLHttpRequest
- Объекты URL
- Объект formData
- Fetch API
- Fetch API 2
- WebSocket
- Server Sent Events
- Бинарные данные и файлы
- Модули
- Классы
- Разное
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; } Alert(`Получено ${value.length} байт`) }
Результат вызова await reader.read()
– это объект с двумя свойствами:
done
–true
, когда чтение закончено, иначеfalse
.value
– типизированный массив данных ответаUint8Array
.
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; Alert(`Получено ${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); })()
Разберёмся, что здесь произошло:
-
Мы обращаемся к
fetch
как обычно, но вместо вызоваresponse.json()
мы получаем доступ к потоку чтенияresponse.body.getReader()
.Обратите внимание, что мы не можем использовать одновременно оба эти метода для чтения одного и того же ответа: либо обычный метод
response.json()
, либо чтение потокаresponse.body
. -
Ещё до чтения потока мы можем вычислить полную длину ответа из заголовка
Content-Length
.Он может быть нечитаемым при запросах на другой источник ( подробнее в разделе Fetch: запросы на другие сайты ) и, в общем-то, серверу необязательно его устанавливать. Тем не менее, обычно длина указана.
-
Вызываем
await reader.read()
до окончания загрузки.Всё, что получили, мы складываем по «кусочкам» в массив
chunks
. Это важно, потому что после того, как ответ получен, мы уже не сможем «перечитать» его, используяresponse.json()
или любой другой способ (попробуйте – будет ошибка). -
В самом конце у нас типизированный массив –
Uint8Array
. В нём находятся фрагменты данных. Нам нужно их склеить, чтобы получить строку. К сожалению, для этого нет специального метода, но можно сделать, например, так:- Создаём
chunksAll = new Uint8Array(receivedLength)
– массив того же типа заданной длины. - Используем
.set(chunk, position)
для копирования каждого фрагмента друг за другом в него.
- Создаём
-
Наш результат теперь хранится в
chunksAll
. Это не строка, а байтовый массив.Чтобы получить именно строку, надо декодировать байты. Встроенный объект TextDecoder как раз этим и занимается. Потом мы можем, если необходимо, преобразовать строку в данные с помощью
JSON.parse
.Что если результат нам нужен в бинарном виде вместо строки? Это ещё проще. Замените шаги 4 и 5 на создание единого
Blob
из всех фрагментов:let blob = new Blob(chunks);
В итоге у нас есть результат (строки или Blob
, смотря что удобно) и отслеживание прогресса получения.
На всякий случай повторимся, что здесь мы рассмотрели, как отслеживать процесс получения данных с сервера, а не их отправки на сервер. Для отслеживания отправки у fetch
пока нет способа.
Fetch: прерывание запроса
Метод fetch
возвращает промис. А в JavaScript в целом нет понятия «отмены» промиса. Как же прервать запрос fetch
?
Для таких целей существует специальный встроенный объект: AbortController
, который можно использовать для отмены не только fetch
, но и других асинхронных задач.
Использовать его достаточно просто:
-
Шаг 1: создаём контроллер:
let controller = new AbortController();
Контроллер
controller
– чрезвычайно простой объект.- Он имеет единственный метод
abort()
и единственное свойствоsignal
. - При вызове
abort()
:- генерируется событие с именем
abort
на объектеcontroller.signal
- свойство
controller.signal.aborted
становится равнымtrue
.
- генерируется событие с именем
Все, кто хочет узнать о вызове
abort()
, ставят обработчики наcontroller.signal
, чтобы отслеживать его.Вот так (пока без
fetch
):(async () => {let controller = new AbortController(); let signal = controller.signal; // срабатывает при вызове controller.abort() signal.addEventListener('abort', () => alert("отмена!")); controller.abort(); // отмена! Alert(signal.aborted); // true })();
- Он имеет единственный метод
-
Шаг 2: передайте свойство
signal
опцией в методfetch
:let controller = new AbortController(); fetch(url, { signal: controller.signal });
Метод
fetch
умеет работать сAbortController
, он слушает событиеabort
наsignal
. -
Шаг 3: чтобы прервать выполнение
fetch
, вызовитеcontroller.abort()
:controller.abort();
Вот и всё:
fetch
получает событие изsignal
и прерывает запрос.
Когда 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
, скажем, погода:
-
Сначала, заранее, объявляем глобальную функцию для обработки данных, например
gotWeather
.// 1. Объявить функцию для обработки погодных данных function gotWeather({ temperature, humidity }) { Alert(`температура: ${temperature}, влажность: ${humidity}`); }
-
Затем создаём тег
<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);
-
Удалённый сервер с
another.com
должен в ответ сгенерировать скрипт, который вызываетgotWeather(...)
с данными, которые хочет передать.});// Ожидаемый ответ от сервера выглядит так: gotWeather({ temperature: 25, humidity: 78
-
Когда этот скрипт загрузится и выполнится, наша функция
gotWeather
получает данные.
Это работает и не нарушает безопасность, потому что обе стороны согласились передавать данные таким образом. А когда обе стороны согласны, то это определённо не хак. Всё ещё существуют сервисы, которые предоставляют такой доступ, так как это работает даже для очень старых браузеров.
Спустя некоторое время в браузерном JavaScript появились методы для сетевых запросов.
Вначале запросы на другой источник были запрещены. Но в результате долгих дискуссий было решено разрешить их делать, но для использования новых возможностей требовать разрешение сервера, выраженное в специальных заголовках.
Простые запросы
Есть два вида запросов на другой источник:
- Простые.
- Все остальные.
Простые запросы будут попроще, поэтому давайте начнём с них.
Простой запрос – это запрос, удовлетворяющий следующим условиям:
- Простой метод: GET, POST или HEAD
- Простые заголовки – разрешены только:
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
) или звёздочку *
. Тогда ответ успешен, в противном случае возникает ошибка.
Здесь браузер играет роль доверенного посредника:
- Он гарантирует, что к запросу на другой источник добавляется правильный заголовок
Origin
. - Он проверяет наличие разрешающего заголовка
Access-Control-Allow-Origin
в ответе и, если всё хорошо, то JavaScript получает доступ к ответу сервера, в противном случае – доступ запрещается с ошибкой.
Вот пример ответа сервера, который разрешает доступ:
200 OK Content-Type:text/html; charset=UTF-8 Access-Control-Allow-Origin: http://vpogiba.info
Заголовки ответа
По умолчанию при запросе к другому источнику JavaScript может получить доступ только к так называемым «простым» заголовкам ответа:
Cache-Control
Content-Language
Content-Type
Expires
Last-Modified
Pragma
При доступе к любому другому заголовку ответа будет ошибка.
Пожалуйста, обратите внимание: в списке нет заголовка 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
, у него нет тела, но есть два заголовка:
Access-Control-Request-Method
содержит HTTP-метод «непростого» запроса.Access-Control-Request-Headers
предоставляет разделённый запятыми список его «непростых» HTTP-заголовков.
Если сервер согласен принимать такие запросы, то он должен ответить без тела, со статусом 200 и с заголовками:
Access-Control-Allow-Methods
должен содержать разрешённые методы.Access-Control-Allow-Headers
должен содержать список разрешённых заголовков.- Кроме того, заголовок
Access-Control-Max-Age
может указывать количество секунд, на которое нужно кешировать разрешения. Так что браузеру не придётся посылать предзапрос для последующих запросов, удовлетворяющих данным разрешениям.
Давайте пошагово посмотрим, как это работает, на примере PATCH
запроса (этот метод часто используется для обновления данных) на другой источник:
let response = await fetch('https://site.com/service.json', { method: 'PATCH', headers: { 'Content-Type': 'application/json' 'API-Key': 'secret' } });
Этот запрос не является простым по трём причинам (достаточно одной):
- Метод
PATCH
Content-Type
не один из:application/x-www-form-urlencoded
,multipart/form-data
,text/plain
.- Содержит «непростой» заголовок
API-Key
.
Шаг 1 (предзапрос)
Перед тем, как послать такой запрос, браузер самостоятельно генерирует и посылает предзапрос, который выглядит следующим образом:
OPTIONS /service.json Host: site.com Origin: http://vpogiba.info Access-Control-Request-Method: PATCH Access-Control-Request-Headers: Content-Type,API-Key
- Метод:
OPTIONS
. - Путь – точно такой же, как в основном запросе:
/service.json
. - Особые заголовки:
Origin
– источник.Access-Control-Request-Method
– запрашиваемый метод.Access-Control-Request-Headers
– разделённый запятыми список «непростых» заголовков запроса.
Шаг 2 (ответ сервера на предзапрос)
Сервер должен ответить со статусом 200 и заголовками:
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type,API-Key
.
Это разрешит будущую коммуникацию, в противном случае возникает ошибка.
Если сервер ожидает в будущем другие методы и заголовки, то он может в ответе перечислить их все сразу, разрешить заранее, например:
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
запрещено использовать звёздочку *
для запросов с авторизационными данными. Там должен быть именно источник, как показано выше. Это дополнительная мера безопасности, чтобы гарантировать, что сервер действительно знает, кому он доверяет делать такие запросы.
Итого
С точки зрения браузера запросы к другому источнику бывают двух видов: «простые» и все остальные.
Простые запросы должны удовлетворять следующим условиям:
- Метод: GET, POST или HEAD.
- Заголовки – мы можем установить только:
Accept
Accept-Language
Content-Language
Content-Type
со значениемapplication/x-www-form-urlencoded
,multipart/form-data
илиtext/plain
.
Основное их отличие заключается в том, что простые запросы с давних времён выполнялись с использованием тегов <form>
или <script>
, в то время как непростые долгое время были невозможны для браузеров.
Практическая разница состоит в том, что простые запросы отправляются сразу с заголовком Origin
, а для других браузер делает предварительный запрос, спрашивая разрешения.
Для простых запросов:
- → Браузер посылает заголовок
Origin
с источником. - ← Для запросов без авторизационных данных (не отправляются по умолчанию) сервер должен установить:
Access-Control-Allow-Origin
в*
или то же значение, что иOrigin
- ← Для запросов с авторизационными данными сервер должен установить:
Access-Control-Allow-Origin
в то же значение, что иOrigin
Access-Control-Allow-Credentials
вtrue
Дополнительно, чтобы разрешить JavaScript доступ к любым заголовкам ответа, кроме Cache-Control
, Content-Language
, Content-Type
, Expires
, Last-Modified
или Pragma
, сервер должен перечислить разрешённые в заголовке Access-Control-Expose-Headers
.
Для непростых запросов перед основным запросом отправляется предзапрос:
- → Браузер посылает запрос
OPTIONS
на тот же адрес с заголовками:Access-Control-Request-Method
– содержит запрашиваемый метод,Access-Control-Request-Headers
– перечисляет непростые запрашиваемые заголовки.
- ← Сервер должен ответить со статусом 200 и заголовками:
Access-Control-Allow-Methods
со списком разрешённых методов,Access-Control-Allow-Headers
со списком разрешённых заголовков,Access-Control-Max-Age
с количеством секунд для кеширования разрешений
- → Затем отправляется основной запрос, применяется предыдущая «простая» схема.