EverProduct
Фронтенд

Этап 03 · Язык

Одна полоса: как 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 к любому запросу, привязанному к вводу или к экрану, с которого можно уйти.

Проверь себя

Закрой статью и ответь своими словами:

  1. Почему JavaScript планирует вместо того, чтобы ждать, и при чём тут единственный поток?
  2. Что на самом деле происходит при вызове setTimeout с задержкой 0?
  3. Каковы три состояния промиса и что всегда возвращает async-функция?
  4. Почему fetch не отклоняется на 500 и что из-за этого приходится писать?
  5. Когда await внутри цикла — это баг и что его заменяет?
  6. Опиши гонку с устаревшим ответом и способ её починки.
  7. Назови четыре состояния асинхронного экрана и что ломается при отсутствии каждого.

Коротко

  • У страницы один поток, поэтому ничто не блокирует: медленная работа передаётся наружу, а её результат ждёт в очереди, пока не закончится текущий код.
  • Таймер — это минимальная задержка, длинная функция подвешивает всё, а порядок выполнения не совпадает с порядком строк.
  • Колбэки стали промисами, промисы — async/await; await приостанавливает функцию, не блокируя страницу, а async-функция всегда возвращает промис.
  • fetch отклоняется только при сетевом сбое — проверяй response.ok сам, иначе ветка ошибки не выполнится никогда.
  • Независимые запросы должны стартовать вместе через Promise.all; allSettled, race и any закрывают частичный успех, таймауты и первый успешный.
  • AbortController чинит и устаревшие ответы не по порядку, и работу, продолжающуюся после ухода с экрана.
  • У каждого асинхронного экрана четыре состояния — загрузка, ошибка, пусто, успех, — и последние три отделяют настоящую работу от демо.
  • Строй модель предсказанием порядка логов и собери один переиспользуемый чанк «запрос со всеми состояниями», чтобы загрузка данных стала одним движением.