Перейти к основному содержимому
Блокчейн45ГЛАВА 45Данные из сети: загрузка, отказ, гонка ответовloadingokerrorопоздавший ответcancelled = true

Данные из сети: загрузка, отказ, гонка ответов

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Интерфейс к контракту почти целиком состоит из ожидания. Баланс приходит из сети. Список операций приходит из сети. Отправленная транзакция подтверждается секунды.

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

Эта глава — про три вещи, которые отличают рабочий интерфейс от учебного: честные состояния, проверку ответа и защиту от опоздавших данных.

Не «данные», а состояние​

Первое, что делают неправильно: заводят одно состояние под данные и полагаются на то, что до ответа там пусто.

Так не работает. «Пусто» означает сразу три разные вещи: ещё не спрашивали, спросили и ждём, спросили и получили отказ. Показать пользователю нужно три разных экрана.

const [state, setState] = useState({ status: "idle" });
// { status: "loading" }
// { status: "ok", value: "1000.0 ETH" }
// { status: "error", message: "-32000 unknown account" }

Прогон гоняет один компонент через все состояния:

сразу после появления : загружаем…
ответ пришёл : 1000.0 ETH
переключили на адрес с отказом: загружаем…
отказ пришёл : ошибка: узел ответил отказом: -32000 unknown account
У ДАННЫХ ИЗ СЕТИ НЕ ОДНО СОСТОЯНИЕ, А ЧЕТЫРЕпокойещё не спрашивализагружаемзапрос в путиготовозначение пришлоотказпричина известнапрогон одного компонента:загружаем… → 1000.0 ETH → загружаем… → ошибка: -32000 unknown accountинтерфейс, у которого есть только «данные», при отказе показывает пустоту и молчитпоэтому состояние хранят одним объектом: тогда «загружаем и одновременно ошибка» невозможно
Четыре положения вместо одного; хранить их одним объектом — значит запретить невозможные сочетания

Почему одним объектом, а не тремя отдельными состояниями. Три состояния — «загружаем», «данные», «ошибка» — допускают сочетания, которых не бывает: загружаем и ошибка одновременно, данные вместе с ошибкой. Каждое такое сочетание однажды окажется на экране. Один объект с полем-меткой делает их невозможными — это ровно тот приём из главы про свои типы, только в интерфейсе.

Запрос «успешен», даже когда страницы нет​

── fetch и коды ответа ──
существующая страница : status 200, ok true
несуществующая : status 404, ok false
бросил ли fetch исключение на 404: false
ЗАПРОС «УСПЕШЕН», ДАЖЕ КОГДА СТРАНИЦЫ НЕТстраница естьstatus 200, ok trueразбираем ответ — всё верностраницы нетstatus 404, ok falseисключения не было: falseперехват не сработает — проверять рукамизапрос считается успешным, если ответ вообще пришёл: каким он был — отдельный вопросто же самое мы видели у узла: там отказ лежал внутри тела при коде 200правило одно: после каждого запроса проверяют признак успеха, и только потом разбирают
Отказ сервера — не исключение: перехват не сработает, признак успеха проверяют руками

Запрос считается успешным, если ответ вообще пришёл. Каким он был — вопрос отдельный. Исключение бывает только тогда, когда ответа нет вовсе: сеть недоступна, адрес не резолвится, запрос отменён.

Это ровно то же, что мы видели у узла: опечатка в имени метода давала код 200 и отказ внутри тела.

const res = await fetch(url);
if (!res.ok) throw new Error(`узел ответил ${res.status}`);
const data = await res.json();
Нажми на часть выражения
Отвечено вопросов: 0 из 2
Сервер ответил кодом 404. Попадём ли мы в перехват ошибки?
Почему состояние запроса хранят одним объектом, а не тремя переменными?
Отвечено верно: 0 из 2

Запрос живёт в эффекте​

useEffect(() => {
let cancelled = false;
setState({ status: "loading" });

getBalance(address)
.then((value) => { if (!cancelled) setState({ status: "ok", value }); })
.catch((e) => { if (!cancelled) setState({ status: "error", message: e.message }); });

return () => { cancelled = true; };
}, [address]);

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

Обратите внимание на порядок внутри: сначала метка «загружаем», потом запрос. Иначе между сменой адреса и приходом ответа на экране висит старое значение — и пользователь видит баланс чужого кошелька как свой.

Гонка: опоздавший ответ затирает новый​

Самая коварная ошибка этой темы. Прогон задаёт порядок ответов вручную — так воспроизводится то, что в сети случается само:

── без защиты от опоздавшего ответа ──
1) спросили баланс адреса A → загружаем…
2) переключились на B, ответа по A нет → загружаем…
3) пришёл ответ по B → 500.0 ETH — адрес B
4) пришёл опоздавший ответ по A → 1000.0 ETH — адрес A

── с защитой (флаг в уборке эффекта) ──
4) пришёл опоздавший ответ по A → 500.0 ETH — адрес B
ПОЛЬЗОВАТЕЛЬ ПЕРЕКЛЮЧИЛ АДРЕС, ПОКА ОТВЕТ БЫЛ В ПУТИ1спросили баланс Aзагружаем…2переключились на Bзагружаем…3пришёл ответ по B500.0 ETH — адрес B4пришёл опоздавший ответ по A1000.0 ETH — адрес Aс флагом отмены в уборке эффекта четвёртая строка остаётся «500.0 ETH — адрес B»ответы приходят не в том порядке, в каком спрашивали: это не редкость, а обычное дело
Четвёртый шаг решает всё: без флага на экране оказывается баланс того адреса, от которого пользователь уже ушёл

Разбор по шагам. Пользователь смотрит адрес A, ответ идёт медленно. Он переключается на B — ответ по B приходит быстро и появляется на экране. А потом приходит ответ по A и записывает себя поверх.

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

Флаг отмены в уборке решает это в две строки. Уборка выполняется перед новым запуском эффекта — значит, к моменту прихода опоздавшего ответа его флаг уже поднят, и результат отбрасывается.

Важно

Ответы приходят не в том порядке, в каком их запрашивали, — это не редкость, а обычное дело. Разные узлы, разная нагрузка, повторы на уровне сети. Любой запрос, зависящий от того, что пользователь может переключить, обязан иметь такую защиту.

Отмена запроса​

Флаг отбрасывает результат, но сам запрос продолжает идти. Когда запрос тяжёлый или их много, его лучше действительно оборвать:

useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
.then((res) => res.json())
.then(setData)
.catch((e) => { if (e.name !== "AbortError") setError(e.message); });
return () => ctrl.abort();
}, [url]);
── отмена запроса ──
запрос отменён: AbortError — signal is aborted without reason
Нажми на часть выражения

Браузер не пустит вас к узлу просто так​

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

страница открыта по: https:
запрос к http://127.0.0.1:8545 со страницы https не прошёл:
TypeError: Failed to fetch

Сообщение не говорит ничего — и это типично. За таким отказом стоят два разных правила браузера:

Смешанное содержимое. Страница по защищённому протоколу не может обращаться по незащищённому. Узел на своей машине отвечает по обычному — значит, страницу тоже открывают по обычному, локально.

Разрешение чужого источника. Даже по одинаковому протоколу браузер спросит у узла разрешение обращаться с другого адреса. У geth оно включается отдельными ключами запуска:

geth --http --http.corsdomain "http://localhost:5173" --http.vhosts "*"
Совет

TypeError: Failed to fetch — самое бессодержательное сообщение во фронтенде. Оно означает «запроса не было вовсе» и покрывает всё: сеть недоступна, узел не запущен, смешанное содержимое, запрет чужого источника, отменённый запрос. Смотреть надо во вкладку запросов в панели разработчика: там видно, какое именно правило сработало.

Отвечено вопросов: 0 из 2
Пользователь переключил адрес, и на экране оказался баланс прежнего. Что произошло?
Чем отмена запроса отличается от флага?
Отвечено верно: 0 из 2

Что показывать, пока ждём​

Три правила, которые делают ожидание переносимым.

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

Не скрывать интерфейс целиком. Обновляется баланс — блокируется баланс, а не вся страница.

Отказ должен быть действием, а не текстом. «Не удалось получить баланс» плюс кнопка «повторить» — это интерфейс. Одна и та же фраза без кнопки — тупик.

Для транзакции состояний больше, и каждое стоит показать: подписываем в кошельке, отправлено, ждём подтверждения, готово. Пользователь должен понимать, на каком шаге всё встало, — иначе он нажмёт ещё раз.

Типичные ошибки​

Разбирают ответ, не проверив признак успеха. Страница ошибки разбирается как данные и падает далеко от причины.

Одно состояние вместо четырёх. Отказ выглядит как пустота.

Метка «загружаем» ставится после запроса. На экране висит старое значение — чужой баланс.

Нет защиты от опоздавших ответов. Разобрано выше.

Отменённый запрос показывается как ошибка сети. Каждое переключение вкладки — красная плашка.

Один пульт отмены на несколько запросов. Уже отменённый обрывает и новые.

Запрос в теле компонента. Уходит при каждом пересчёте.

Failed to fetch считают ошибкой кода. Чаще это правила браузера: смешанное содержимое или запрет чужого источника.

Отвечено вопросов: 0 из 2
Что показывать, пока данные идут?
Узел на демонстрации ответил отказом. Что должен показать интерфейс?
Отвечено верно: 0 из 2

Слова главы​

  • Состояние запроса — покой, загрузка, готово, отказ; хранится одним объектом с меткой.
  • Признак успеха ответа — истинен для кодов 200–299; проверяется вручную.
  • Отказ сервера — не исключение — исключение бывает, только если ответа нет вовсе.
  • Гонка ответов — опоздавший ответ по старому запросу затирает новый.
  • Флаг отмены — переменная в эффекте, поднимаемая уборкой; отбрасывает результат.
  • Пульт отмены — обрывает сам запрос; его ошибку отличают по имени.
  • Смешанное содержимое — защищённая страница не ходит по незащищённому адресу.
  • Разрешение чужого источника — узел должен разрешить обращения со страницы.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 95 зн/мин, точность ≥ 88%

if (!res.ok) throw new Error(`узел ответил ${res.status}`);

На чемпионате​

Проверьте связку браузер — узел в первый час. Смешанное содержимое и запрет чужого источника съедают время именно потому, что сообщение ничего не объясняет. Один запрос из страницы к узлу — и вы знаете, работает ли связка вообще.

Все четыре состояния — на экране. Это видно проверяющему сразу и стоит очень дёшево.

Флаг отмены — в каждом запросе, зависящем от выбора пользователя. Адрес, сеть, вкладка.

Кнопка «повторить» вместо тупика. Узел на демонстрации может ответить не с первого раза — и интерфейс, который умеет это пережить, выглядит совсем иначе.

И про порядок: сначала добейтесь, чтобы данные приходили и показывались, и только потом занимайтесь красотой. Красивый интерфейс, который показывает чужой баланс, хуже некрасивого, который показывает верный.

Куда углубиться​

  • Библиотеки запросов — берут на себя состояния, повторы, запас ответов и отмену.
  • Подписка на события контракта — не спрашивать, а получать уведомления об изменениях.
  • Повтор с нарастающей паузой — как не добить узел, который уже не справляется.
  • Ожидание подтверждения транзакции — сколько блоков считать достаточным.
  • Панель запросов в браузере — как по ней отличить запрет источника от отсутствия узла.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоРабота с API в React. Подключение к json-server и fetch-запросы: GET, POST, DELETE, PATCHАлександр Ламков — Friendly Frontend
ВидеоReact #40 | Загрузка данных из API в React: fetch и useEffect.DevHotSpot
Видео🔥 Ошибка CORS в браузере — как починить за 5 минут (и почему она возникает)Makhabat Abdisattarova
ВидеоLearn CORS In 6 MinutesWeb Dev Simplified
ВидеоReact Урок 8: Как делать запросы к API? useEffect + Fetch шаг за шагом!DevHotSpot

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.