Данные из сети: загрузка, отказ, гонка ответов
- Зачем это нужно
- Не «данные», а состояние
- Запрос «успешен», даже когда страницы нет
- Запрос живёт в эффекте
- Гонка: опоздавший ответ затирает новый
- Отмена запроса
- Браузер не пустит вас к узлу просто так
- Что показывать, пока ждём
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Интерфейс к контракту почти целиком состоит из ожидания. Баланс приходит из сети. Список операций приходит из сети. Отправленная транзакция подтверждается секунды.
В главе про запросы к узлу мы уже видели, чем это грозит: отказ, спрятанный в теле успешного ответа, потерянный вей, повисший навсегда запрос. Здесь то же самое, но у происходящего есть зритель — и он видит либо понятное «идёт подтверждение», либо пустой экран.
Эта глава — про три вещи, которые отличают рабочий интерфейс от учебного: честные состояния, проверку ответа и защиту от опоздавших данных.
Не «данные», а состояние
Первое, что делают неправильно: заводят одно состояние под данные и полагаются на то, что до ответа там пусто.
Так не работает. «Пусто» означает сразу три разные вещи: ещё не спрашивали, спросили и ждём, спросили и получили отказ. Показать пользователю нужно три разных экрана.
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
Почему одним объектом, а не тремя отдельными состояниями. Три состояния — «загружаем», «данные», «ошибка» — допускают сочетания, которых не бывает: загружаем и ошибка одновременно, данные вместе с ошибкой. Каждое такое сочетание однажды окажется на экране. Один объект с полем-меткой делает их невозможными — это ровно тот приём из главы про свои типы, только в интерфейсе.
Запрос «успешен», даже когда страницы нет
── fetch и коды ответа ──
существующая страница : status 200, ok true
несуществующая : status 404, ok false
бросил ли fetch исключение на 404: false
Запрос считается успешным, если ответ вообще пришёл. Каким он был — вопрос отдельный. Исключение бывает только тогда, когда ответа нет вовсе: сеть недоступна, адрес не резолвится, запрос отменён.
Это ровно то же, что мы видели у узла: опечатка в имени метода давала код 200 и отказ внутри тела.
const res = await fetch(url);
if (!res.ok) throw new Error(`узел ответил ${res.status}`);
const data = await res.json();
Запрос живёт в эффекте
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
Разбор по шагам. Пользователь смотрит адрес 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 — самое бессодержательное сообщение во фронтенде. Оно означает «запроса не было вовсе» и покрывает всё: сеть недоступна, узел не запущен, смешанное содержимое, запрет чужого источника, отменённый запрос. Смотреть надо во вкладку запросов в панели разработчика: там видно, какое именно правило сработало.
Что показывать, пока ждём
Три правила, которые делают ожидание переносимым.
Не пустой экран. Заготовка на месте будущих данных читается лучше, чем крутящийся кружок, и не прыгает, когда данные приходят.
Не скрывать интерфейс целиком. Обновляется баланс — блокируется баланс, а не вся страница.
Отказ должен быть действием, а не текстом. «Не удалось получить баланс» плюс кнопка «повторить» — это интерфейс. Одна и та же фраза без кнопки — тупик.
Для транзакции состояний больше, и каждое стоит показать: подписываем в кошельке, отправлено, ждём подтверждения, готово. Пользователь должен понимать, на каком шаге всё встало, — иначе он нажмёт ещё раз.
Типичные ошибки
Разбирают ответ, не проверив признак успеха. Страница ошибки разбирается как данные и падает далеко от причины.
Одно состояние вместо четырёх. Отказ выглядит как пустота.
Метка «загружаем» ставится после запроса. На экране висит старое значение — чужой баланс.
Нет защиты от опоздавших ответов. Разобрано выше.
Отменённый запрос показывается как ошибка сети. Каждое переключение вкладки — красная плашка.
Один пульт отмены на несколько запросов. Уже отменённый обрывает и новые.
Запрос в теле компонента. Уходит при каждом пересчёте.
Failed to fetch считают ошибкой кода. Чаще это правила браузера: смешанное содержимое или запрет чужого источника.
Слова главы
- Состояние запроса — покой, загрузка, готово, отказ; хранится одним объектом с меткой.
- Признак успеха ответа — истинен для кодов 200–299; проверяется вручную.
- Отказ сервера — не исключение — исключение бывает, только если ответа нет вовсе.
- Гонка ответов — опоздавший ответ по старому запросу затирает новый.
- Флаг отмены — переменная в эффекте, поднимаемая уборкой; отбрасывает результат.
- Пульт отмены — обрывает сам запрос; его ошибку отличают по имени.
- Смешанное содержимое — защищённая страница не ходит по незащищённому адресу.
- Разрешение чужого источника — узел должен разрешить обращения со страницы.
Тренажёр: набрать вслепую
Цель: скорость ≥ 95 зн/мин, точность ≥ 88%
if (!res.ok) throw new Error(`узел ответил ${res.status}`);
На чемпионате
Проверьте связку браузер — узел в первый час. Смешанное содержимое и запрет чужого источника съедают время именно потому, что сообщение ничего не объясняет. Один запрос из страницы к узлу — и вы знаете, работает ли связка вообще.
Все четыре состояния — на экране. Это видно проверяющему сразу и стоит очень дёшево.
Флаг отмены — в каждом запросе, зависящем от выбора пользователя. Адрес, сеть, вкладка.
Кнопка «повторить» вместо тупика. Узел на демонстрации может ответить не с первого раза — и интерфейс, который умеет это пережить, выглядит совсем иначе.
И про порядок: сначала добейтесь, чтобы данные приходили и показывались, и только потом занимайтесь красотой. Красивый интерфейс, который показывает чужой баланс, хуже некрасивого, который показывает верный.
Куда углубиться
- Библиотеки запросов — берут на себя состояния, повторы, запас ответов и отмену.
- Подписка на события контракта — не спрашивать, а получать уведомления об изменениях.
- Повтор с нарастающей паузой — как не добить узел, который уже не справляется.
- Ожидание подтверждения транзакции — сколько блоков считать достаточным.
- Панель запросов в браузере — как по ней отличить запрет источника от отсутствия узла.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- MDN: использование fetch — запрос, ответ, признак успеха.
- MDN: AbortController — отмена запросов.
- MDN: CORS — почему браузер спрашивает разрешение у чужого сервера.
- MDN: смешанное содержимое — правило про защищённый и незащищённый протоколы.
- React: получение данных в эффекте — флаг отмены и почему это не окончательное решение.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.