Одна полоса: как JavaScript ждёт, не останавливаясь
Ты запросил данные у сервера и напечатал результат: undefined. Ничего не сломалось — ты прочитал ответ раньше, чем он приехал, и всё непонятное здесь растёт из этого одного недоразумения.
Этот баг пишут все и однажды. Ты запрашиваешь данные, кладёшь в переменную, печатаешь на следующей строке — и получаешь undefined. А потом, чуть позже, данные тихо приезжают, и положить их уже некуда.
Ничего не упало. Запрос идёт 200 миллисекунд, а твоя следующая строка выполнилась через 0,01 миллисекунды. Ты прочитал ответ до того, как он появился.
Причина, по которой JavaScript устроен так, — факт из первой статьи: у страницы один поток, и он же выполняет твои клики, прокрутку, анимации и раскладку. Если бы ожидание сервера блокировало этот поток, каждый запрос подвешивал бы всю страницу. Поэтому язык никогда не блокирует. Он планирует.
В JavaScript ничто не ждёт. Всё планируется — и вся путаница с асинхронностью вытекает из этой одной фразы.
Цикл событий в одном абзаце, который можно унести
Твой код выполняется на стеке, по одной вещи за раз, до конца. Всё медленное — сетевой запрос, таймер, чтение файла — передаётся браузеру, который делает это в другом месте. Когда работа закончена, результат не выполняется немедленно: колбэк кладётся в очередь. И только когда твой текущий код дошёл до конца, цикл берёт из очереди следующий элемент и выполняет его.
Отсюда три следствия, и они объясняют бо́льшую часть загадок:
Таймер — это минимум, а не обещание. setTimeout(fn, 0) не выполняется сейчас; он выполнится после всего, что уже на стеке. Если какая-то функция занята 300 миллисекунд, твой «немедленный» колбэк подождёт 300 миллисекунд.
Длинная функция подвешивает всё. Не только твою логику: клики, прокрутка и анимация — в той же полосе. Поэтому тяжёлую работу приходится дробить или уносить в воркер.
Порядок не совпадает с порядком строк. Асинхронные результаты приезжают, когда приезжают, и рассуждение о том, что после чего происходит, и есть навык, которому учит эта статья.
От колбэков к await
История короткая и её стоит знать, потому что все три стиля встретятся в настоящем коде.
Колбэки были первыми: передай функцию, которую вызовут по завершении. Терпимо для одной операции и невыносимо для пяти подряд — каждая вложена в предыдущую, обработка ошибок продублирована на каждом уровне. Индустрия назвала это адом колбэков и не шутила.
Промисы превратили ожидаемый результат в объект, который можно держать в руках. Промис находится в одном из трёх состояний — ожидает, выполнен, отклонён — и переходит в конечное ровно один раз, что и делает его компонуемым: его можно передавать, соединять в цепочку и объединять по нескольку.
async/await — синтаксис поверх промисов, и писать ты будешь его. await приостанавливает функцию до разрешения промиса — не блокируя поток, потому что вся остальная страница продолжает работать, пока эта функция подвешена. В результате асинхронный код читается сверху вниз, как обычный.
Два факта снимают бо́льшую часть путаницы: await работает только внутри async-функции, а async-функция всегда возвращает промис, что бы ты из неё ни вернул. Значит, вызов без await отдаёт тебе промис, а не значение, — вторая половина того самого undefined из начала статьи.
Ошибки и ловушка в fetch
С await ошибки обрабатываются обычным try/catch — во многом поэтому он и победил. В цепочке промисов ту же роль играет .catch().
Но есть одно поведение, на котором спотыкаются все, и его стоит выделить: fetch не отклоняется из-за неуспешного HTTP-статуса. С точки зрения fetch 404 или 500 — успешный поход туда и обратно: сервер ответил, и ответ был «нет». Промис отклоняется только тогда, когда провалился сам запрос: нет сети, не разрешилось имя, запрос отменён.
Поэтому каждому запросу нужна явная проверка response.ok до того, как ты полез в тело. Без неё твоя ветка ошибки не выполняется никогда, и ты бодро пытаешься прочитать список товаров из страницы ошибки. Одно это упущение объясняет огромную долю «работает, пока не перестанет» в проде.
Дальше обрабатывай остальное честно. Необработанное отклонение — тихий сбой без единого сообщения пользователю, худшая категория: никто не сообщит, и ничего не всплывёт, пока кто-нибудь не спросит, почему список пустой.
Последовательно или параллельно
Вот ошибка, стоящая настоящих секунд. await внутри цикла заставляет десять запросов идти друг за другом: если каждый занимает 200 миллисекунд, человек ждёт две секунды там, где хватило бы двухсот миллисекунд.
Инструменты, чтобы сделать лучше:
Promise.all— запустить все, дождаться всех. Падает целиком, если упал любой.Promise.allSettled— дождаться всех и получить исход каждого. Уместно, когда частичный успех допустим: пять виджетов на дашборде, один из которых лежит.Promise.race— побеждает первый разрешившийся. Классическое применение — таймаут.Promise.any— первый успешный.
Правило применяется легко: если два запроса не зависят друг от друга, они должны стартовать одновременно. Если второму нужен результат первого, последовательность верна и чинить нечего.
Отмена и устаревшие ответы
Две проблемы появляются, как только в дело вступает живой человек, и обе неочевидны.
Гонка при быстром вводе. Кто-то печатает «стул» в поиске, и ты отправляешь запрос на каждое нажатие. Ответ на «сту» приходит после ответа на «стул» — сервер ничего не обещал про порядок — и затирает правильный результат старым. На экране теперь ответ на вопрос, который человек уже дозадал.
Работа, продолжающаяся, когда она никому не нужна. Человек ушёл на другой экран, а три запроса всё ещё в полёте и всё ещё пишут состояние экрана, которого нет.
У обеих одна починка: AbortController. Создавай его на запрос, передавай сигнал в fetch и отменяй предыдущий при старте нового или при уходе компонента. Один механизм, два бага — и выучить его стоит рано, а не после недели охоты на призраков.
Четыре состояния любого асинхронного экрана
У любого куска интерфейса, который кормится запросом, четыре состояния, и разница между любительской и профессиональной реализацией почти целиком в последних трёх.
Загрузка — и она должна быть честной: скелет по форме будущего содержимого лучше крутилки, а совсем ничего — худший вариант, потому что пустая область неотличима от пустого результата.
Ошибка — скажи, что произошло, и дай выход. «Что-то пошло не так» без кнопки повтора — тупик.
Пусто — запрос удался, а данных нет. Это состояние пропускают почти в каждой первой реализации, и пользователи читают пустой экран как сломанную страницу.
Успех — то единственное, которое делают все.
Пиши все четыре каждый раз. Это правило «спроектируй все пять состояний» из сферы «UX-дизайн», прибывшее в форме кода, и самый ясный признак того, что интерфейсная работа сделана как следует.
Реальность сети
Твоя машина — не мир. Разработка идёт по быстрому соединению с сервером на том же ноутбуке, где каждый запрос занимает четыре миллисекунды и не падает никогда.
Настоящие пользователи едут в электричке. Поэтому: ограничь скорость сети в инструментах разработчика до медленного 3G и попользуйся собственным интерфейсом — огромная доля асинхронных багов становится видна только тогда, когда запрос идёт две секунды. Ставь таймауты, чтобы зависший запрос не превращался в вечную крутилку. Повторяй с нарастающими паузами при временных сбоях и не повторяй то, что повторять нельзя. И реши, что твой интерфейс делает без сети, даже если ответ — просто честное сообщение.
Строить модель, а не заучивать правила
Асинхронность — то место, где модель в голове важнее синтаксиса и где чтение обманчиво как нигде: объяснение цикла событий совершенно понятно, пока ты его читаешь, и не даёт ничего применимого.
Работает упражнение на предсказание. Напиши небольшой файл, где несколько console.log разбросаны между setTimeout, разрешённым промисом и await, и — до запуска — выпиши порядок, в котором строки напечатаются. Потом запусти. Каждое расхождение — точная карта того, где твоя модель неверна, и занимает это две минуты. Здесь одновременно извлечение и мгновенная обратная связь, и это бьёт любое количество перечитываний схемы.
Дальше собери чанк один раз и как следует: одну функцию, которая делает запрос с состоянием загрузки, проверкой статуса, обработкой ошибки и отменой. Сделай её правильно однажды, сохрани и пересобери по памяти через неделю. После этого «загрузить данные на экран» — одно движение, а не двенадцать решений, — ровно то сжатие, которое описывает сфера «Как учиться», и именно поэтому у опытного разработчика это занимает четыре минуты.
На практике
Предсказывай порядок логов до запуска. Раз в неделю, пока учишься.
Проверяй response.ok в каждом запросе, без исключений, пока это не перестанет требовать мысли.
Ищи await внутри циклов в собственном коде и спрашивай, действительно ли эти запросы зависят друг от друга.
Пиши состояния загрузки, ошибки и пустоты раньше успешного пути. Иначе это те три, которые ты не напишешь никогда.
Разрабатывай с ограниченной сетью один день в неделю. Это меняет то, что ты замечаешь.
Добавляй AbortController к любому запросу, привязанному к вводу или к экрану, с которого можно уйти.
Проверь себя
Закрой статью и ответь своими словами:
- Почему JavaScript планирует вместо того, чтобы ждать, и при чём тут единственный поток?
- Что на самом деле происходит при вызове
setTimeoutс задержкой 0? - Каковы три состояния промиса и что всегда возвращает
async-функция? - Почему
fetchне отклоняется на 500 и что из-за этого приходится писать? - Когда
awaitвнутри цикла — это баг и что его заменяет? - Опиши гонку с устаревшим ответом и способ её починки.
- Назови четыре состояния асинхронного экрана и что ломается при отсутствии каждого.
Коротко
- У страницы один поток, поэтому ничто не блокирует: медленная работа передаётся наружу, а её результат ждёт в очереди, пока не закончится текущий код.
- Таймер — это минимальная задержка, длинная функция подвешивает всё, а порядок выполнения не совпадает с порядком строк.
- Колбэки стали промисами, промисы —
async/await;awaitприостанавливает функцию, не блокируя страницу, аasync-функция всегда возвращает промис. fetchотклоняется только при сетевом сбое — проверяйresponse.okсам, иначе ветка ошибки не выполнится никогда.- Независимые запросы должны стартовать вместе через
Promise.all;allSettled,raceиanyзакрывают частичный успех, таймауты и первый успешный. AbortControllerчинит и устаревшие ответы не по порядку, и работу, продолжающуюся после ухода с экрана.- У каждого асинхронного экрана четыре состояния — загрузка, ошибка, пусто, успех, — и последние три отделяют настоящую работу от демо.
- Строй модель предсказанием порядка логов и собери один переиспользуемый чанк «запрос со всеми состояниями», чтобы загрузка данных стала одним движением.