Из моего опыта преподавания и после опросов студентов я понял, что контекст исполнения - это тема, которая очень часто "ну никак не идет". Студентам трудно понять что-то такое отвлеченное, то, что невозможно увидеть, понять что когда и зачем в нем создается.
Так что я здесь для того, чтобы по-классике попытаться донести до вас эту непростую на первый взгляд штуку. Поехали :)
Контекст исполнения в JavaScript - это, пожалуй, та самая важная вещь, которую вы должны понять, поскольку четкое понимание этого даст вам те базовые знания, которые необходимы для понимания более сложных концепций, таких как hoisting (поднятие) и closure (замыкание), потому что без понимания этого вы просто не сможете работать.
Сразу оговорюсь, что статья объясняет, как это работает в пределах языка, а не движителя. Основные принципы и там, и там одинаковы (или хотя бы схожи, в зависимости от движителя), а реализация может отличаться в разных движителях.
Что же такое контекст исполнения?
Очень упрощенно, контекст выполнения – это среда, в которой работает наш код.
Чем глубже мы будем разбираться в этой теме, тем больше вы будете понимать, что такое окружение (тот же контекст). Сейчас же вы можете представлять его как коробку, где лежит весь наш код.
В JavaScript у нас есть разные типы кода, а именно:
- код, находящийся в глобальном контексте;
- код, находящийся внутри контекста функции;
- также есть код, находящийся внутри функции eval.
Каждый раз, когда ваша программа вызывает функцию, создается новый контекст исполнения. В рекурсивных функциях всякий раз, когда функция вызывает сама себя, создается новый контекст выполнения, поэтому теоретически вы можете иметь нескончаемое количество контекстов выполнения. И этого совершенно не стоит опасаться.
Короче говоря, вы можете иметь 3 разных типа контекста выполнения:
1. Глобальный контекст выполнения:
К этому относится весь код, который не находится внутри функции, и он находится в глобальном окружении.
Глобальное окружение может быть только одно, и глобальное окружение также содержит глобальный объект (окно в браузере), значение которого в строгом режиме равно глобальному окружению.
let globalVar = "Це глобальна змінна";
const showGlobalVar = () => {
console.log(globalVar);
};
showGlobalVar(); // Виводить: Це глобальна змінна
2. Контекст выполнения функции:
Каждый раз, когда выполняется функция, для нее создается новый контекст исполнения. Таким образом, каждая функция имеет свой контекст выполнения, который создается, когда код вызывает функцию, и точно не раньше.
const outerFunction = () => {
let outerVar = 'Я зовнішня змінна';
const innerFunction = () => {
let innerVar = 'Я внутрішня змінна';
console.log(outerVar); // Виводить: Я зовнішня змінна
console.log(innerVar); // Виводить: Я внутрішня змінна
};
innerFunction();
};
outerFunction();
3. Контекст выполнения функции eval:
Он создается при вызове функции eval, но поскольку большинство разработчиков не используют eval, мы не будем говорить о нем в этой статье.
const globalVariable = "Це глобальна змінна";
const evalExample = () => {
eval('var evalVar = "Це змінна, створена в eval"; console.log(evalVar); console.log(globalVariable);');
};
evalExample();
// Вивід у консолі:
// Це змінна, створена в eval
// Це глобальна змінна
Стек выполнения (на других языках (более популярных) - стек вызовов) - это структура данных стека, которая используется как коллекция всех контекстов выполнения, которые являются активными при работе кода.
Стек работает по принципу LIFO (Last in, first out). Это означает, что последний элемент, который попадает в стек, также является первым, выходящим из стека.
Для более простого объяснения прикреплю изображение, с помощью которого понять будет очень просто:
Разберемся, как работает стек
Теперь, когда у нас есть базовое представление о том, как выглядит стек, мы можем рассмотреть, как он работает.
В общем-то, каждый контекст можно рассматривать как отправителя или получателя функций. Например, если одна функция вызывает другую, контекст этого вызова является отправителем, а контекст вызываемой - получателем.
Контекст может быть одновременно и отправителем, и получателем, например, функция, вызываемая из глобального контекста, а затем вызывающая другую функцию. Здесь уже становится труднее для понимания.
Когда так называемый отправитель вызывает функцию, он останавливает ее выполнение и фактически передает поток управления получателю. В этот момент вызов перемещается в контекст выполнения и становится активным контекстом выполнения.
Как только код в активном контексте выполнения завершает работу, поток управления возвращается к отправителю, и функция продолжает выполняться.
То есть следующий код:
function firstFunc() {
console.log('Executing first function')
secondFunc();
}
function secondFunc() {
console.log('Executing second function')
}
firstFunc();
Будет выглядеть следующим образом:
Как вы можете понять, стек вызовов работает синхронно, активный стек всегда представляет контекст, который активен в данный момент, в том порядке, в котором он был вызван.
На этом мы двигаемся дальше.
Рекомендуем курс по теме
Как выглядит процесс создания контекста исполнения?
Теперь, когда мы понимаем, что такое контекст выполнения и как работает стек вызовов, остается несколько важных вопросов. Зачем вообще нужен контекст исполнения? За что он отвечает? И что содержит он содержащий в себе?
Каждый раз создание контекста выполнения происходит в два этапа:
- Фаза создания;
- Фаза исполнения.
Фаза создания начинается после создания контекста выполнения, но до того, как код будет запущен. Возьмем для примера вызов функции.
Когда вы вызываете функцию, вы можете подумать, что код немедленно выполняется, но на самом деле начинается фаза создания, хотя код не выполняется. Есть некоторые вещи, которые происходят до того, как он будет исполнен.
Я представлял это как процесс формирования заготовок. При создании первой фазы мы создаем заготовку, а на этапе выполнения эта заготовка заполняется конкретной нужной информацией.
Что я подразумеваю под заготовкой?
На этапе создания заготовки двигатель просматривает код, и каждый раз, когда он встречает объявление переменной или функции, он сохраняет переменные без их фактических значений (за исключением аргументов функции, где значения сохраняются).
Затем, на этапе выполнения, двигатель пролистывает эту заготовку и выполняет каждую соответствующую часть.
Этот процесс повторяется каждый раз, когда создается новый контекст выполнения, движитель создает шаблон объявлений переменных и функций, и только затем на этапе выполнения присваивает значение переменным и фактически выполняет код. Это и была фаза исполнения.
Компоненты контекста исполнения.
Технически контекст выполнения содержит следующие элементы:
ExecutionContext = {
ThisBinding: привʼязка this,
VariableEnvironment: { ... },
LexicalEnvironment: { ... }
}
Все эти элементы создаются на первом этапе создания и каждый из них выполняет свою роль. Например, среда переменных – это то, что на самом деле содержит переменные и их значения.
Но глубже в тему компонентов контекста исполнения мы погружаться сейчас не будем, хотя каждый из компонентов – это абсолютно неотъемлемая часть контекста исполнения. Для того чтобы что-то понять - это нужно "переварить", потому что так лучше усваивается :) А о компонентах - мы можем поговорить в будущем.
Вывод
Есть 3 типа контекста исполнения: глобальный контекст исполнения, функциональный контекст исполнения, контекст исполнения eval.
Каждый контекст исполнения управляется стеком исполнения в виде отправителя и получателя. Мы узнали, что существует две фазы, которые происходят всякий раз, когда создается контекст выполнения: фаза создания и фаза выполнения.
На современном этапе создания создается определенная заготовка переменных и функций.
На этапе выполнения двигатель просматривает код, выполняет присвоение переменным и выполняет код.
Мы также упоминали, что контекст выполнения содержит такие вещи, как: LexicalEnvironment, VariableEnvironment и thisBinding. Поскольку каждая из них является отдельной темой, мы продолжим говорить о них в следующих статьях.
Так что до встречи!