Проект ЦИТадель
JavaScript появился как язык сценариев для веб-страниц, но теперь исполняется также на серверах, в редакторах и в утилитах командной строки. За это время в стандарт вошли блочные объявления, модули, промисы и другой синтаксис, которого не было в языке 2000-х годов. Статья предназначена для читателя, знакомого с программированием, но давно не работавшего с JavaScript: она объясняет современный основной набор языка, асинхронность, место TypeScript и устройство инструментальной экосистемы. Практикум связывает тему с предыдущей статьёй — в нём предстоит написать типизированный клиент к веб-API.
Крупное обновление ECMAScript 2015 стало началом ежегодных выпусков стандарта. Старые конструкции сохранились ради совместимости с существующими страницами, однако новый код обычно опирается на другой набор средств:
let и const принадлежат ближайшему блоку, в отличие от функциональной области var. Их связывания создаются при входе в область, но недоступны до выполнения объявления — это называют временной мёртвой зоной. const запрещает переприсвоить само связывание, но не делает объект неизменяемым.import и export задают связи между файлами на уровне языка. Статические импорты позволяют заранее построить значительную часть графа зависимостей; динамический import() загружает модуль по требованию. Удаление неиспользуемого кода возможно только при корректном анализе побочных эффектов и не гарантируется одним наличием модулей.a?.b?.c и оператор ?? сокращают служебный код. Важно различать ??, реагирующий только на null и undefined, и ||, для которого ложными являются также 0, пустая строка и false.this, поэтому она удобна для коллбэков, которым нужен this внешней области, но не заменяет всякую обычную функцию. Классы дают декларативный синтаксис поверх прототипной модели. Map и Set, итераторы и генераторы позволяют явно выразить словари, множества и последовательности.Совместимость остаётся важным ограничением эволюции языка, но она не означает, что любое прежнее поведение закреплено навсегда: спецификация исправляет ошибки и иногда уточняет семантику. Целевые среды проекта определяют, какие возможности можно доставить непосредственно, а какие требуют преобразования или полифила.
Как показано в статье «Как устроен браузер», выполнение сценария связано с циклом событий, очередями задач и микрозадач. Это не означает, что весь браузер однопоточен: речь идёт о последовательном выполнении кода одним агентом JavaScript. Способы описать продолжение асинхронной операции развивались поэтапно:
Promise представляет будущий результат и позволяет передавать его и соединять операции цепочками then/catch. Promise.all ждёт успешного завершения всех переданных обещаний и отклоняется после первого отказа; allSettled собирает все исходы; race принимает первый завершившийся исход; any — первое успешное значение. Эти комбинаторы не отменяют оставшиеся операции и сами по себе не создают параллельное вычисление: работа начинается там, где созданы соответствующие обещания.async/await. Асинхронная функция всегда возвращает обещание. await приостанавливает только выполнение этой функции до получения результата, после чего её продолжение ставится в очередь; поток при этом не блокируется. try/catch обрабатывает отказ ожидаемого обещания. Ожидание внутри обычного цикла даёт последовательность, а предварительный запуск операций с последующим Promise.all — конкурентное ожидание. Конкурентность следует ограничивать, если сервис, сеть или память не рассчитаны на весь набор сразу.Промис не предоставляет отмену сам по себе. Веб-API могут принять AbortSignal; например, fetch прекращает клиентское ожидание и чтение ответа после отмены, но это ещё не доказывает, что сервер отменил обработку. Тайм-аут, повтор, задержка с джиттером и идемпотентность остаются частью протокола операции. Эти правила разобраны в статье «Надёжность поверх ненадёжной сети».
В небольшом JavaScript-модуле форму значения часто видно из соседнего кода и тестов. С ростом числа модулей и участников становится труднее проследить все допустимые входы функции и безопасно переименовать поле. TypeScript добавляет к JavaScript статическую проверку типов и инструменты для навигации и рефакторинга. Для его применения важны три свойства:
value as Book сообщает что-то компилятору, но ничего не проверяет. Ответ API сначала следует рассматривать как unknown и валидировать по схеме из статьи «JSON и дизайн API».implements. Это соответствует распространённым объектным интерфейсам JavaScript, но система намеренно допускает некоторые непроверяемые конструкции. any, утверждения типов и неточные декларации библиотек могут обойти защиту компилятора.checkJs и JSDoc, затем переводить по файлам и усиливать настройки. Для нового проекта разумной исходной точкой служит strict, объединяющий семейство строгих проверок.Объединения, литеральные типы, дженерики, keyof, условные и отображаемые типы позволяют выводить один контракт из другого. Проверки typeof, in и поля-дискриминаторы сужают объединение внутри ветви. Такая выразительность полезна, пока контракт остаётся понятен читателю и сообщения компилятора помогают исправить код. Сложный вычисляемый тип стоит заменить более явным, если его труднее сопровождать, чем описываемую им программу.
JavaScript не привязан к браузеру. В Node.js обратные вызовы одного цикла событий обычно выполняют JavaScript последовательно, а неблокирующий ввод-вывод и пул рабочих потоков позволяют обслуживать другие операции во время ожидания. Долгое синхронное вычисление задерживает все задачи данного цикла. Для вычислений можно использовать worker_threads, пул отдельных процессов или внешний сервис; рабочие потоки полезны прежде всего для нагрузки на процессор, а не как замена штатному асинхронному вводу-выводу.
Пакеты ускоряют разработку, но каждая прямая и транзитивная зависимость входит в цепочку поставки. Перед добавлением оценивают необходимость, сопровождение, разрешения установочных сценариев и размер дерева зависимостей. Lock-файл сохраняют в репозитории и используют воспроизводимую установку в конвейере; обновления проверяют тестами и просматривают отдельно от прикладных изменений. Аудит известных уязвимостей, подписи реестра и сведения о происхождении пакета дают дополнительные сигналы, но не доказывают безопасность исходного кода.
Инструментальный конвейер решает несколько разных задач: проверяет типы, преобразует синтаксис под целевые среды, объединяет модули, удаляет достижимо неиспользуемый код, минифицирует и строит карты исходников. Проекту нужны только те стадии, для которых есть измеримая причина. Нативные модули позволяют небольшому приложению обойтись без объединения файлов, но производственная доставка всё равно может выиграть от оптимизации числа запросов, размера и разбиения кода. Общие правила такой автоматизации рассмотрены в статье «Инженерный конвейер».
Сквозная задача — клиент к библиотечному API из статьи о JSON и дизайне API. Нужны среда с поддержкой fetch, TypeScript и редактор, показывающий результаты проверки.
Задание 1. Настройка проекта. Включите strict и запретите публикацию результата при ошибке проверки. Опишите типы Book, Reader и Loan по схемам OpenAPI. Сопоставьте строковые идентификаторы, календарные даты, моменты времени, необязательные поля и null с решениями первой статьи.
Задание 2. Асинхронный клиент. Реализуйте функции на fetch и async/await: получение книги, чтение списка с курсорной пагинацией и создание выдачи с ключом идемпотентности. Введите общий тип страницы результатов. Добавьте сигнал отмены и ограничение времени ожидания. Повторяйте только те запросы и ответы, для которых контракт допускает повтор: учитывайте метод, ключ идемпотентности, Retry-After, число попыток и случайную составляющую задержки.
Задание 3. Граница с внешним миром. Пусть заглушка вернёт неверный тип одного поля. Сначала скройте проблему утверждением as Book и проследите, где проявится ошибка. Затем примите результат как unknown, проверьте его по схеме и сформируйте сообщение с путём к неверному полю. Типизированным объект становится только результат успешной проверки.
Задание 4. Последовательно и конкурентно. Загрузите двадцать книг тремя способами: с await в цикле, одним Promise.all и пулом с небольшим пределом одновременных запросов. Замерьте время и число незавершённых запросов. Добавьте один отказ и проверьте, что происходит с остальными операциями после отклонения Promise.all.
Задание 5*. Сужение типов. Опишите прикладной результат как объединение «успех или ожидаемая ошибка» с полем-дискриминатором. Реализуйте полный разбор вариантов и используйте never, чтобы компилятор обнаруживал забытый вариант. Сравните это представление с исключением для транспортного сбоя или нарушения инварианта.
async/await дают последовательную запись продолжений. Promise.all объединяет уже начатые операции, не запускает и не отменяет их.