JavaScript: Глубокое погружение в архитектуру и возможности языка

Продвинутый курс для разработчиков, желающих понять внутреннее устройство JavaScript. Программа охватывает работу движка, управление памятью, асинхронность, прототипное наследование и современные стандарты ECMAScript.

1. Под капотом: как работает движок JS, стек вызовов и управление памятью

Под капотом: как работает движок JS, стек вызовов и управление памятью

Добро пожаловать в курс «JavaScript: Глубокое погружение в архитектуру и возможности языка». Мы начинаем наше путешествие не с синтаксиса переменных или циклов, а с фундамента, на котором строится всё остальное. Чтобы стать настоящим инженером, а не просто пользователем языка, необходимо понимать, что происходит «под капотом», когда вы запускаете свой код.

Многие разработчики пишут код годами, не задумываясь о том, как именно браузер превращает текстовые файлы .js в интерактивные интерфейсы. Сегодня мы разберем анатомию JavaScript-движка, узнаем, где хранятся данные, и почему бесконечная рекурсия «взрывает» стек.

Что такое движок JavaScript?

JavaScript — это язык высокого уровня. Это означает, что он понятен человеку, но совершенно непонятен процессору компьютера. Процессор понимает только машинный код (нули и единицы). Роль переводчика берет на себя JavaScript Engine (движок).

Самый популярный движок на сегодняшний день — V8, разработанный Google. Он используется в браузере Chrome и в среде Node.js. Существуют и другие: SpiderMonkey (Firefox) или JavaScriptCore (Safari), но принципы их работы во многом схожи.

Как работает V8?

Работа движка — это сложный конвейер. Давайте упростим его до основных этапов:

  • Парсинг (Parsing): Движок читает ваш код и разбивает его на лексемы (ключевые слова, переменные, операторы).
  • AST (Abstract Syntax Tree): Лексемы собираются в древовидную структуру, которая отражает синтаксическую связь между элементами кода.
  • Компиляция и выполнение: Интерпретатор начинает выполнять код, а JIT-компилятор (Just-In-Time) параллельно оптимизирует часто используемые участки, превращая их в эффективный машинный код.
  • !Упрощенная схема конвейера обработки JavaScript-кода внутри движка V8.

    Главная особенность современных движков — это JIT-компиляция. Она объединяет скорость компиляции (как в C++) и гибкость интерпретации (как в Python), позволяя JavaScript работать невероятно быстро.

    Модель памяти: Стек и Куча

    Когда движок выполняет код, ему нужно где-то хранить данные: переменные, объекты, функции. Для этого выделяются две области памяти:

  • Стек вызовов (Call Stack)
  • Куча памяти (Memory Heap)
  • Понимание разницы между ними критически важно для написания оптимизированного кода.

    Куча памяти (Memory Heap)

    Это большая, неструктурированная область памяти. Сюда попадают все сложные объекты: массивы, функции и объекты, созданные через new или литералы {}.

    Представьте кучу как огромный склад, где коробки (объекты) разбросаны в произвольном порядке. Чтобы найти коробку, нам нужна ссылка на нее. Выделение памяти здесь происходит динамически.

    Стек вызовов (Call Stack)

    Это структурированная область памяти, работающая по принципу LIFO (Last In, First Out — «последним пришел, первым ушел»). Стек хранит:

    * Примитивные типы данных (числа, строки, булевы значения), если они являются локальными переменными. * Ссылки на объекты в куче. * Контексты выполнения функций (Stack Frames).

    !Различие между структурированным Стеком вызовов и хаотичной Кучей памяти.

    Глубокое погружение в Call Stack

    JavaScript — однопоточный язык. Это означает, что у него есть только один стек вызовов. Он может делать только одну вещь в конкретный момент времени.

    Давайте проследим за выполнением кода:

    Вот что происходит в стеке пошагово:

  • Global Execution Context: В стек помещается глобальный контекст (запуск скрипта).
  • Вызов printSquare(5): Создается кадр (frame) для функции printSquare и кладется поверх глобального контекста.
  • Вызов multiply(n, n): Внутри printSquare вызывается multiply. Её кадр кладется на самый верх стека.
  • Возврат из multiply: Функция завершается, возвращает значение. Её кадр удаляется из стека (pop).
  • Вызов console.log: Новый кадр для console.log кладется на верх стека.
  • Завершение printSquare: Функция завершается, её кадр удаляется.
  • Стек пуст: Программа завершена.
  • Переполнение стека (Stack Overflow)

    Размер стека ограничен. Если вы создадите функцию, которая вызывает сама себя без условия выхода (бесконечная рекурсия), стек заполнится кадрами функций до предела, и браузер «упадет» с ошибкой Maximum call stack size exceeded.

    Это классический пример переполнения стека. Движок защищает систему, принудительно останавливая скрипт.

    Управление памятью и сборка мусора

    В языках низкого уровня, таких как C, разработчик должен вручную выделять и освобождать память (malloc, free). В JavaScript управление памятью происходит автоматически. Это удобно, но создает иллюзию, что об утечках памяти можно не думать. Это ошибка.

    Жизненный цикл памяти

  • Выделение: Память выделяется автоматически при объявлении переменных.
  • Использование: Чтение и запись в памяти.
  • Освобождение: Очистка памяти, которая больше не нужна.
  • За третий этап отвечает Garbage Collector (GC) — сборщик мусора.

    Алгоритм Mark-and-Sweep (Пометь и вымети)

    Как движок понимает, что память можно освободить? Основная концепция — достижимость (reachability).

  • Существует набор базовых объектов, называемых корнями (roots). В браузере это глобальный объект window, в Node.js — global.
  • Сборщик мусора периодически запускается и «обходит» корни, помечая все объекты, на которые есть ссылки, как «живые».
  • Затем он идет по ссылкам этих объектов к следующим и так далее.
  • Все объекты, до которых сборщик не смог добраться от корня, считаются мусором.
  • Память, занятая «мусором», освобождается.
  • !Визуализация принципа достижимости: объекты без связей с корнем подлежат удалению.

    Утечки памяти

    Утечка памяти происходит, когда объект вам больше не нужен, но ссылка на него всё ещё существует, мешая GC удалить его. Частые причины:

    * Глобальные переменные: Случайно созданные переменные без let, const или var попадают в глобальный объект и живут вечно. * Забытые таймеры: setInterval, который не был очищен через clearInterval, будет держать ссылки на колбэк и все переменные внутри него. * Замыкания: Неправильное использование замыканий может удерживать в памяти большие объемы данных.

    Почему JavaScript однопоточный?

    Вы можете спросить: «Почему не сделать JS многопоточным, чтобы выполнять много задач сразу?»

    Изначально JavaScript создавался для простых скриптов в браузере. Многопоточность создает огромные сложности с синхронизацией доступа к DOM (представьте, что два потока пытаются одновременно изменить цвет одной и той же кнопки). Однопоточная модель упрощает разработку, избавляя от проблем «состояний гонки» (race conditions).

    Однако, однопоточность имеет минус: если какая-то функция выполняется слишком долго (например, сложная математика или обработка изображения), она блокирует стек. Браузер перестает реагировать на клики и зависает.

    Как JavaScript решает эту проблему и выполняет асинхронные задачи (запросы к серверу, таймеры), не блокируя интерфейс? Ответ кроется в Event Loop (цикле событий), о котором мы поговорим в следующей статье курса.

    Заключение

    Понимание работы движка, стека и памяти — это то, что отличает профессионала. Вы теперь знаете:

    * JS-движок компилирует и выполняет код, используя JIT. * Примитивы хранятся в стеке, объекты — в куче. * Стек вызовов работает по принципу LIFO и имеет лимит. * Сборщик мусора удаляет недостижимые объекты.

    В следующем уроке мы разберем магию асинхронности и узнаем, как Event Loop позволяет однопоточному JavaScript делать несколько дел «одновременно».

    > «Память — это не то, что вы имеете, а то, что вы используете. Неиспользуемая память — это потраченный ресурс, но неуправляемая память — это бомба замедленного действия.»

    2. Продвинутая работа с функциями: лексическое окружение, замыкания и контекст this

    Продвинутая работа с функциями: лексическое окружение, замыкания и контекст this

    В предыдущей статье мы разобрали физику движка JavaScript: как работает стек вызовов и как память распределяется между стеком и кучей. Теперь, когда мы понимаем, где живет код, пришло время разобраться, как функции получают доступ к данным.

    Многие разработчики используют замыкания интуитивно, не понимая механики. Другие годами путаются в ключевом слове this, расставляя bind наугад. В этой статье мы перейдем от магии к инженерному пониманию архитектуры функций в JavaScript.

    Лексическое окружение (Lexical Environment)

    Чтобы понять замыкания, нужно сначала понять, как JavaScript ищет переменные. В отличие от многих языков, где область видимости определяется блоками кода на этапе компиляции, в JS всё работает через специальную внутреннюю структуру.

    Каждый раз, когда запускается функция (или скрипт целиком), движок создает специальный скрытый объект — Lexical Environment (Лексическое окружение). Этот объект существует «в воздухе» (в памяти), вы не можете получить к нему прямой доступ через код, но именно он управляет всеми вашими переменными.

    Лексическое окружение состоит из двух частей:

  • Environment Record (Запись окружения): Это объект, в котором как свойства хранятся все локальные переменные, аргументы функции и ключевое слово this. Если вы объявляете let x = 10, то в записи окружения появляется свойство x: 10.
  • Reference to the outer environment (Ссылка на внешнее окружение): Это ссылка на лексическое окружение, внутри которого был создан текущий код.
  • !Визуализация цепочки лексических окружений: от локального к глобальному.

    Как происходит поиск переменной?

    Когда код обращается к переменной, движок выполняет следующие шаги:

  • Ищет переменную в текущем Environment Record.
  • Если не находит, переходит по ссылке во внешнее окружение и ищет там.
  • Процесс повторяется, пока не будет достигнуто глобальное окружение.
  • Если переменная не найдена и в глобальном объекте, выбрасывается ошибка ReferenceError (в строгом режиме).
  • Именно поэтому внутренняя функция видит переменные внешней, но внешняя не видит переменные внутренней. Ссылки работают только в одну сторону — наружу.

    Замыкания (Closures)

    Теперь, зная о лексическом окружении, мы можем дать точное техническое определение замыканию.

    > Замыкание — это комбинация функции и лексического окружения, в котором эта функция была объявлена.

    В JavaScript все функции являются замыканиями (за редким исключением, вроде функций, созданных через new Function). Почему? Потому что при создании функции она автоматически запоминает окружение, в котором была создана, сохраняя ссылку на него в скрытом свойстве [[Environment]].

    Рассмотрим классический пример:

    Что происходит под капотом?

  • Вызывается createCounter. Создается её лексическое окружение, где count = 0.
  • Функция возвращает анонимную функцию. У этой анонимной функции в скрытом свойстве [[Environment]] сохраняется ссылка на окружение createCounter.
  • Работа createCounter завершается, она уходит из стека вызовов.
  • Внимание, важный момент: Обычно, когда функция завершается, её лексическое окружение удаляется из памяти сборщиком мусора (Garbage Collector), так как на него больше никто не ссылается. Но в данном случае переменная counter (в глобальной области) ссылается на анонимную функцию, а та, через [[Environment]], ссылается на окружение createCounter.

    По правилу достижимости (о котором мы говорили в прошлой статье), сборщик мусора не может удалить окружение createCounter, пока жива внутренняя функция. Переменная count продолжает жить в куче (Heap), хотя функция, создавшая её, давно завершилась.

    Практическое применение

    Замыкания используются повсеместно:

    * Инкапсуляция данных: Скрытие переменных, чтобы к ним нельзя было обратиться напрямую извне (как private свойства в ООП). * Функциональное программирование: Каррирование, частичное применение функций. * Обработчики событий и таймеры: Сохранение состояния между вызовами.

    Контекст выполнения: this

    Если лексическое окружение отвечает на вопрос «Где лежат мои переменные?», то this отвечает на вопрос «Кто меня вызвал?» или «В контексте какого объекта я работаю?».

    Главная проблема с this в JavaScript заключается в том, что он определяется не в момент создания функции, а в момент её вызова. Это динамическая привязка.

    Существует 4 основных правила определения this:

    1. Вызов метода объекта (Implicit Binding)

    Если функция вызывается как метод объекта (через точку), то this ссылается на этот объект.

    2. Простой вызов функции (Default Binding)

    Если функция вызывается сама по себе, без точки, this теряется.

    * В строгом режиме ('use strict'): this будет undefined. * В нестрогом режиме: this будет указывать на глобальный объект (window в браузере или global в Node.js).

    Это частая ошибка при передаче методов в качестве колбэков (например, в setTimeout).

    3. Явная привязка (Explicit Binding)

    Мы можем принудительно указать функции, что считать за this, используя методы call, apply или bind.

    * func.call(context, arg1, arg2) — вызывает функцию сразу с указанным контекстом. * func.apply(context, [args]) — то же самое, но аргументы передаются массивом. * func.bind(context)не вызывает функцию, а возвращает новую обертку, в которой this жестко привязан навсегда.

    4. Вызов с new (Constructor Call)

    Когда функция вызывается с оператором new, создается новый пустой объект, и this внутри функции указывает на этот новый объект.

    Стрелочные функции и их особенность

    С появлением ES6 (ES2015) ситуация изменилась. Стрелочные функции () => {} не имеют своего собственного this. Вообще.

    Они берут this из внешнего лексического окружения. Это означает, что this в стрелочной функции определяется тем, где она была написана, а не тем, как вызвана.

    !Различие в поведении this: динамический контекст против лексического.

    Если бы мы использовали обычную функцию внутри forEach, this был бы undefined (или window), и код сломался бы.

    Потеря контекста: классический пример

    Рассмотрим ситуацию, с которой сталкивается каждый Junior-разработчик:

    Почему undefined? Потому что setTimeout получает ссылку на функцию btn.click, но вызывает её позже как простую функцию, без привязки к объекту btn. Связь разрывается.

    Решения:

  • Использовать обертку: setTimeout(() => btn.click(), 1000)
  • Использовать bind в конструкторе: this.click = this.click.bind(this)
  • Использовать поля класса со стрелочной функцией (современный стандарт):
  • Заключение

    Понимание лексического окружения и контекста this — это водораздел между новичком и опытным разработчиком.

    * Замыкания позволяют функциям «помнить» данные, удерживая их в памяти кучи. * Лексическое окружение — это механизм поиска переменных «снизу вверх». * This — это контекст вызова, который может меняться, если не зафиксирован стрелочной функцией или bind.

    В следующей части курса мы разберем прототипное наследование — еще одну фундаментальную концепцию, на которой строится вся объектная модель JavaScript.

    3. Объектно-ориентированный JavaScript: прототипы, наследование и классы

    Объектно-ориентированный JavaScript: прототипы, наследование и классы

    В предыдущих частях курса мы разобрали, как движок управляет памятью и как функции создают замыкания. Теперь пришло время объединить эти знания, чтобы понять, как JavaScript реализует объектно-ориентированное программирование (ООП).

    Если вы пришли из языков вроде Java или C#, местная модель ООП может показаться вам странной. Здесь нет классов в привычном понимании (хотя слово class есть). Здесь правит бал прототипное наследование.

    Давайте разберемся, как создавать сложные структуры данных, не дублируя код и не забивая память лишними объектами.

    Проблема дублирования методов

    Представьте, что мы создаем игру, где есть тысячи юнитов. У каждого юнита есть имя и метод атаки.

    С точки зрения памяти, этот код неэффективен. Мы создали два разных объекта, и в каждом из них создали новую копию функции attack. Если юнитов будет 10 000, мы создадим 10 000 одинаковых функций, которые займут место в куче (Memory Heap).

    Нам нужен способ хранить функцию attack в одном месте и давать всем юнитам ссылку на неё. Именно эту задачу решают прототипы.

    Прототип: скрытая связь

    В JavaScript у объектов есть специальное скрытое свойство [[Prototype]] (в спецификации оно обозначается именно так, в двойных квадратных скобках). Это свойство может быть либо null, либо ссылкой на другой объект.

    Когда вы пытаетесь прочитать свойство из объекта, а его там нет, JavaScript автоматически идет по ссылке [[Prototype]] и ищет свойство там. Это называется прототипной цепочкой.

    !Визуализация поиска свойства по цепочке прототипов.

    Как задать прототип?

    Исторически для доступа к прототипу использовалось свойство __proto__. Сейчас оно считается устаревшим (хотя всё ещё работает в браузерах), и современный стандарт рекомендует использовать методы Object.getPrototypeOf() и Object.setPrototypeOf().

    В этом примере sniper прототипно наследует от soldier. Если мы вызовем sniper.attack(), this внутри метода будет указывать на sniper (объект перед точкой), несмотря на то, что метод найден в прототипе.

    Свойство F.prototype

    Здесь начинается самая большая путаница в JS. У функций (и только у них) есть свойство с именем prototype.

    Важно: Свойство F.prototype — это не прототип самой функции F. Это обычное свойство, которое используется оператором new для назначения [[Prototype]] новым объектам.

    Вспомним, как работает функция-конструктор:

    Что произошло под капотом при вызове new User:

  • Создался новый пустой объект.
  • Его скрытому свойству [[Prototype]] присвоилась ссылка на объект, лежащий в User.prototype.
  • Выполнилось тело функции User, где this — это новый объект.
  • Теперь u1 и u2 имеют общего родителя — объект User.prototype. Метод sayHi существует в единственном экземпляре в памяти.

    Встроенные прототипы

    Теперь вы можете понять, почему работают методы массивов или строк. Когда вы пишете [1, 2, 3], создается массив с помощью конструктора Array. У этого массива [[Prototype]] указывает на Array.prototype, где лежат методы push, map, filter и другие.

    Именно поэтому мы можем расширять возможности языка. Если добавить метод в Array.prototype, он станет доступен всем массивам в программе (хотя делать так не рекомендуется, это называется "monkey patching").

    Классы (Classes)

    До 2015 года (ES6) разработчики писали сложные конструкции с прототипами вручную. Это было неудобно и запутанно. Поэтому в язык добавили ключевое слово class.

    Важно понимать: классы в JavaScript — это синтаксический сахар над прототипным наследованием. Это не новая сущность, как в Java. Под капотом это всё те же функции и прототипы.

    Этот код делает то же самое, что и пример с User выше:

  • constructor становится телом функции.
  • Метод run записывается в Animal.prototype.
  • Наследование через extends

    Классы позволяют легко создавать цепочки наследования.

    Ключевое слово extends устанавливает две прототипные связи:

  • Rabbit.prototype получает прототипом Animal.prototype (для наследования методов экземпляров).
  • Сам класс Rabbit получает прототипом класс Animal (для наследования статических методов).
  • Super: обращение к родителю

    Если мы хотим переопределить конструктор в классе-наследнике, мы обязаны вызвать super() перед использованием this.

    Это связано с тем, как работает new при наследовании. В классе-наследнике конструктор не создает объект this сам, он ждет, пока это сделает родительский конструктор.

    Инкапсуляция: Приватные поля

    Долгое время в JS не было настоящих приватных свойств. Разработчики использовали соглашение: "если свойство начинается с _, не трогай его" (например, _name). Но технически к нему всё равно был доступ.

    В современном стандарте появились приватные поля, которые начинаются с решетки #.

    Эти поля не доступны даже через прототипы или this['#waterLimit']. Это жесткая инкапсуляция на уровне движка.

    Статические методы

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

    Статические методы записываются прямо в функцию-конструктор, а не в её prototype.

    Заключение

    Объектная модель JavaScript мощна и гибка, но требует понимания того, что происходит "под капотом".

    * В JS всё является объектами (или примитивами), и наследование идет через ссылку на другой объект (прототип). * Свойство [[Prototype]] объединяет объекты в цепочку. * Классы — это удобный способ работы с этой цепочкой, скрывающий рутинные операции.

    Понимая прототипы, вы не только сможете писать эффективный код, экономящий память, но и без труда разберетесь, как работают фреймворки и библиотеки, расширяющие базовое поведение объектов.

    В следующей статье мы погрузимся в мир асинхронности: разберем Promise, async/await и узнаем, как не попасть в "ад колбэков".

    4. Асинхронность в деталях: Event Loop, микрозадачи, Promise и async/await

    Асинхронность в деталях: Event Loop, микрозадачи, Promise и async/await

    В первой статье нашего курса мы выяснили, что JavaScript — это однопоточный язык. У него есть только один стек вызовов (Call Stack), и он может выполнять только одну операцию в единицу времени. Если бы JS работал только так, любой сетевой запрос или сложный расчет «замораживал» бы интерфейс браузера до завершения операции.

    Однако мы знаем, что этого не происходит. Мы можем кликать по кнопкам, пока грузятся данные с сервера, и видеть плавную анимацию. Как это возможно? Секрет кроется в окружении, в котором работает движок, и механизме под названием Event Loop (Цикл событий).

    Сегодня мы разберем архитектуру асинхронности, узнаем, почему setTimeout(..., 0) не срабатывает мгновенно, и как правильно управлять потоком выполнения с помощью Promise и async/await.

    Окружение: JS не одинок

    Сам по себе движок V8 (или любой другой) умеет только выполнять код и работать с памятью. Он ничего не знает о таймерах, DOM-дереве или сетевых запросах fetch. Все эти возможности предоставляет среда выполнения (Runtime Environment) — браузер или Node.js.

    Когда вы вызываете асинхронную функцию, происходит следующее:

  • Функция попадает в Call Stack.
  • Движок видит, что это обращение к внешнему API (например, setTimeout), и передает задачу браузеру (Web APIs).
  • Функция исчезает из стека вызовов, позволяя JS выполнять следующий код.
  • Браузер «в фоне» отсчитывает таймер или ждет ответа от сервера.
  • Когда событие наступило, браузер помещает колбэк (функцию обратного вызова) в специальную очередь.
  • !Взаимодействие движка JS, Web API браузера и очереди задач через Event Loop.

    Event Loop и Очередь макрозадач

    Итак, таймер сработал, и браузер готов выполнить ваш код. Но он не может просто «вклиниться» в стек вызовов, так как это нарушило бы работу текущего кода. Вместо этого колбэк попадает в Очередь макрозадач (Macrotask Queue).

    Сюда попадают: * setTimeout / setInterval * Обработчики событий DOM (клики, скролл) * Сетевые запросы (AJAX) * setImmediate (в Node.js)

    Здесь на сцену выходит Event Loop. Это бесконечный цикл, который выполняет очень простую работу. Его алгоритм можно описать так:

  • Проверить, пуст ли Call Stack.
  • Если стек пуст, проверить, есть ли задачи в Очереди.
  • Если задачи есть, взять первую из них и поместить в стек вызовов для выполнения.
  • Именно поэтому setTimeout(func, 0) не гарантирует выполнение через 0 миллисекунд. Это означает: «выполни эту функцию, как только стек освободится».

    Результат: 1, 3, 2. Даже с задержкой 0, таймер отправляет колбэк в очередь, а Event Loop ждет, пока синхронный код (вывод 1 и 3) завершится.

    Микрозадачи: VIP-очередь

    С появлением Промисов (Promises) в архитектуру добавился еще один слой сложности. Оказалось, что одной очереди недостаточно. Нам нужна возможность выполнять некоторые задачи «быстрее», чем обычные таймеры, но все же асинхронно.

    Так появилась Очередь микрозадач (Microtask Queue).

    Сюда попадают: * Promise.then, .catch, .finally * queueMicrotask() * MutationObserver

    Приоритет очередей

    Самое важное правило Event Loop: > Микрозадачи имеют приоритет над макрозадачами.

    Обновленный алгоритм работы Event Loop выглядит так:

  • Выполнить весь синхронный код в стеке.
  • Стек пуст? Выполнить ВСЕ задачи из очереди микрозадач, пока она не станет пустой.
  • Теперь выполнить ОДНУ задачу из очереди макрозадач.
  • Отрисовать изменения в браузере (Render UI).
  • Повторить цикл.
  • !Полный цикл Event Loop с учетом приоритета микрозадач и рендеринга.

    Давайте проверим это на коде:

    Порядок вывода:

  • 1: Синхронно (Стек)
  • 4: Синхронно (Стек)
  • 3: Микрозадача (Очередь микрозадач — выполняется сразу после очистки стека)
  • 2: Макрозадача (Очередь макрозадач — выполняется только когда микрозадачи закончились)
  • Опасность: Если вы будете бесконечно добавлять задачи в очередь микрозадач (например, рекурсивный Promise), Event Loop никогда не перейдет к макрозадачам и отрисовке интерфейса. Браузер зависнет.

    Promise: Укрощение асинхронности

    До появления промисов разработчики страдали от «Callback Hell» — вложенности колбэков, уходящей далеко вправо. Promise (Обещание) — это объект, который представляет собой результат успешного или неудачного завершения асинхронной операции.

    У промиса есть три состояния:

  • Pending (Ожидание): исходное состояние.
  • Fulfilled (Исполнено): операция завершена успешно.
  • Rejected (Отклонено): произошла ошибка.
  • Создание промиса

    Функция, переданная в конструктор, выполняется синхронно и немедленно. Асинхронность начинается в методах .then().

    Цепочки (Chaining)

    Главная сила промисов — возможность выстраивать их в цепочки. Каждый .then возвращает новый промис.

    Преимущества async/await

    * Читаемость: Код выглядит линейно, нет вложенности. * Отладка: try/catch позволяет обрабатывать и синхронные, и асинхронные ошибки в одном блоке. * Условия и циклы: Можно использовать обычные if и for с асинхронными операциями, что было очень сложно сделать на чистых промисах.

    Важный нюанс: Последовательное vs Параллельное выполнение

    Частая ошибка новичков — ставить await везде подряд, замедляя код.

    Плохо (последовательно):

    Хорошо (параллельно): Если данные не зависят друг от друга, их нужно запрашивать одновременно.

    Заключение

    Асинхронность в JavaScript — это не магия, а строгий алгоритм взаимодействия стека вызовов и очередей задач.

  • Event Loop координирует работу, перекладывая задачи из очередей в стек.
  • Микрозадачи (Promise) имеют высший приоритет и выполняются сразу после синхронного кода.
  • Макрозадачи (setTimeout) ждут, пока очистится очередь микрозадач.
  • Async/await делает работу с промисами удобной и читаемой, но под капотом это всё те же промисы и микрозадачи.
  • Понимание этого механизма позволяет избегать блокировок интерфейса и писать производительные приложения. В следующей части курса мы рассмотрим Модульную систему: как разбивать код на файлы, чем CommonJS отличается от ES Modules и как работают сборщики.

    5. Метапрограммирование и современный синтаксис: Proxy, Reflect, итераторы и генераторы

    Метапрограммирование и современный синтаксис: Proxy, Reflect, итераторы и генераторы

    Мы прошли долгий путь: от разбора движка V8 и стека вызовов до асинхронности и Event Loop. Теперь, когда вы понимаете, как JavaScript работает «под капотом», пришло время поговорить о магии. Или, выражаясь техническим языком, о метапрограммировании.

    Метапрограммирование — это написание кода, который читает, генерирует, анализирует или трансформирует другой код, а также меняет фундаментальное поведение языка. В JavaScript для этого существуют мощные инструменты: итераторы, генераторы, Proxy и Reflect. Именно на них строятся современные фреймворки вроде Vue 3, MobX или Redux Saga.

    Сегодня мы разберем, как сделать обычные объекты перебираемыми, как поставить «ловушки» на чтение свойств и как управлять потоком выполнения функции.

    Итераторы: как работает for..of

    Вы наверняка использовали цикл for..of для перебора массивов или строк. Но пробовали ли вы перебрать обычный объект?

    Ошибка возникает потому, что обычные объекты не являются итерируемыми (iterable). Чтобы объект стал таковым, он должен реализовать Итераторный протокол.

    Symbol.iterator

    В JavaScript есть специальные встроенные символы. Один из них — Symbol.iterator. Это метод, который должен вернуть объект-итератор.

    У объекта-итератора должен быть метод next(), который возвращает объект с двумя свойствами:

  • value: текущее значение.
  • done: булево значение (true, если перебор окончен, иначе false).
  • Давайте научим наш объект range работать в цикле for..of:

    Теперь движок JS понимает, как «ходить» по вашему объекту. Это фундаментальная концепция, на которой работает оператор spread (...), Array.from и многие другие конструкции.

    Генераторы: функции, которые можно поставить на паузу

    Создавать итераторы вручную, как мы сделали выше, довольно громоздко. Нужно следить за состоянием, возвращать объекты { value, done }. JavaScript предлагает элегантное решение — функции-генераторы.

    Генератор — это функция, выполнение которой можно приостановить и возобновить позже. Она объявляется с помощью звездочки function*.

    Ключевое слово yield

    Внутри генератора используется слово yield. Когда интерпретатор доходит до него:

  • Выполнение функции приостанавливается.
  • Значение справа от yield возвращается наружу (как value).
  • Функция ждет, пока её снова не «пнут» вызовом метода next().
  • Перепишем наш range с использованием генератора:

    Код стал в разы короче и понятнее. Генераторы автоматически создают итераторы.

    Двусторонний обмен данными

    Генераторы — это не просто «поставщики» данных. Вы можете передавать данные внутрь генератора через аргумент next(arg).

    Эта возможность лежит в основе библиотеки Redux Saga, которая позволяет писать сложную асинхронную логику (саги) в синхронном стиле.

    Proxy: перехват реальности

    Теперь перейдем к одной из самых мощных возможностей современного JS. Объект Proxy позволяет создать «обертку» вокруг другого объекта и перехватывать основные операции с ним: чтение свойств, запись, удаление, вызов функции и т.д.

    Синтаксис:

    * target — целевой объект, который мы оборачиваем. * handler — объект с «ловушками» (traps).

    !Визуализация принципа работы Proxy: посредник, контролирующий доступ к целевому объекту.

    Пример: Валидация данных

    Представьте, что у нас есть объект пользователя, и мы хотим запретить записывать в возраст некорректные данные.

    Ловушка get

    Мы можем перехватывать и чтение. Например, сделаем словарь, который переводит слова, а если перевода нет — возвращает исходное слово.

    Именно на базе Proxy построена система реактивности во Vue.js 3. Когда вы меняете свойство в data, Proxy перехватывает это изменение и сообщает компоненту, что нужно перерисовать интерфейс.

    Reflect: зеркало для Proxy

    Вместе с Proxy в язык был добавлен объект Reflect. Это встроенный объект, который содержит методы для перехватываемых операций JS. Методы Reflect один-в-один соответствуют ловушкам Proxy.

    Зачем он нужен, если мы можем просто написать target[prop] = value?

  • Корректная работа с this. Если оборачиваемый объект имеет геттеры/сеттеры, которые используют this, прямая запись может сломать контекст. Reflect.set и Reflect.get позволяют явно передать контекст (receiver).
  • Возвращаемые значения. Операции через Reflect всегда возвращают результат успеха (true/false), в то время как обычные операции могут выбрасывать ошибки или возвращать неочевидные значения.
  • Правильный способ написания ловушек в Proxy — всегда использовать Reflect:

    Использование receiver (третий аргумент) гарантирует, что если у объекта есть наследование и геттеры, this останется правильным прокси-объектом, а не «соскользнет» на оригинальный объект.

    Заключение

    Мы рассмотрели инструменты, которые превращают JavaScript из простого скриптового языка в мощную платформу для создания сложных архитектурных решений.

    * Итераторы позволяют любым объектам вести себя как массивы в циклах. * Генераторы дают контроль над остановкой и возобновлением функций, упрощая работу с потоками данных. * Proxy открывает дверь к метапрограммированию, позволяя переопределять базовое поведение объектов. * Reflect делает работу с Proxy безопасной и предсказуемой.

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