Разберем что в действительности делает JavaScript-движок для создания контекста выполнения. Этот процесс проходит в два этапа:

  1. Этап создания
  2. Этап выполнения

Создание контекста выполнения и “всплытие”

Этап создания - это этап, в котором JavaScript-движок вызвал функцию, но само ее выполнение еще не началось. На этапе создания контекста JavaScript-движок находится на фазе компиляции (сборки), то есть он сначала просматривает и анализирует код этой функции для последующего его выполнения.

Именно вследствие этого этапа создания контекста следующий код будет работать:

showText();
console.log(value);

var value = "Переменная value";

function showText() {
 console.log("Вызов функции showText()");
}

Результат выполнения кода:

> "Вызов функции showText()"
> undefined

Это может показаться неожиданным поведением программы, так как вызов функции showText() и обращение к переменной value происходит до их объявления в коде. Но в JavaScript это работает потому, что на этапе создания контекста, до выполнения функции, JavaScript-движок исследует код и формирует контекст, который содержит в себе указатель на текущее Лексическое окружение, связанное с выполняемым кодом.

Указатель на Лексическое Окружение в Контексте выполнения

В предыдущей части говорилось, что лексическое окружение содержит в себе Запись Окружения, но то, что она из себя представляет, мы рассматривали на этапе уже выполненного кода. Например:

// globalEnvironment
// outer: null - нет родительского окружения
// environmentRecord = {x: 10}
var x = 10;
// Выполнение уже на этой строке.

Хотя в действительности, Запись Окружения меняется в течение выполнения кода. Она создаётся и начинает формироваться на этапе создания контекста. В неё помещаются все объявленные в коде переменные и функции, тем самым для них резервируется место в памяти еще до выполнения первой строчки кода.

Вот как выглядит запись окружения на этапе создания контекста выполнения

// выполнение еще не началось

// globalEnvironment
// outer: null - нет родительского окружения

// environmentRecord = {x: undefined}

var x = 10;

Здесь важно заметить, что на этапе создания контекста, переменные и функциональные выражения, такие как:

var x = 10;
var func = function(params) {
 // тело функции
};

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

// выполнение еще не началось

// globalEnvironment
// outer: null - нет родительского окружения

// environmentRecord = {x: undefined, func: undefined}

var x = 10;
var func = function(params) {
 // тело функции
};

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

function name(params) {
 // инструкции...
}

Они целиком, вместе с телом функции и встроенными свойствами, помещаются в запись окружения

// выполнение еще не началось

// globalEnvironment
// outer: null - нет родительского окружения

// environmentRecord = {name: function name(params){...}}

function name(params) {
 // инструкции...
}

Именно из-за формирования записи окружения и выделения памяти под переменные до выполнения кода к ним можно обращаться до их объявления в программе. Такое поведения называется “всплытие” или hoisting. К сожалению, в некоторых ресурсах всплытие описывают так, что объявление переменной или функции физически поднимается в начало вашего кода, хотя в действительности это не так. На самом же деле, объявления переменных остаются в коде на том же месте, где вы их объявили, только память под них выделяется с самого начала, еще до выполнения кода.

Однако, важно обратить внимание на то, что для переменных let и const есть некоторые отличия от var переменных в механизме всплытия. Например, если обратиться к переменным let и const до их объявления, то возникнет ошибка ReferenceError, в отличие от переменной var, значение которой в таком случае отобразится как undefined.

console.log(foo); // undefined
console.log(pi); // Uncaught ReferenceError: pi is not defined
console.log(bar); // Uncaught ReferenceError: bar is not defined
var foo = 2;
const pi = 3.14;
let bar = 2;

Такая ошибка ReferenceError из-за попытки получить или установить значение let или const переменной до её объявления называется ошибкой “Временной мертвой зоны” (Temporal Dead Zone (TDZ) error).

В некоторых источниках можно встретить утверждение, что переменные let и const вообще не всплывают, на самом деле это не так. Они также попадают в запись окружения, как и var переменные при создании контекста.

// выполнение еще не началось

// globalEnvironment
// outer: null - нет родительского окружения

// environmentRecord = {foo: undefined, pi: undefined, bar: undefined}

console.log(foo); // undefined
console.log(pi); // Uncaught ReferenceError: pi is not defined
console.log(bar); // Uncaught ReferenceError: bar is not defined
var foo = 2;
const pi = 3.14;
let bar = 2;

Только к ним нельзя получить доступ для чтения или записи до тех пор, пока не будет выполнена строка с объявлением этой переменной на этапе выполнения контекста. Механизм, обеспечивающий такое отличие доступа к переменным let и const от var детально будет рассмотрен в следующий части, а сейчас приведем примеры работы временной мертвой зоны.

{
 // Заход в новую блочную область видимости
 // Начало временной мертвой зоны

 // Попытки обращения к переменной tmp до её объявления (запись в неё значения и чтения этого значения)
 tmp = "abc"; // Uncaught ReferenceError: tmp is not defined
 console.log(tmp); // Uncaught ReferenceError: tmp is not defined

 let tmp; // конец временной мертвой зоны, переменная tmp инициализируется значением undefined
 console.log(tmp); // undefined

 tmp = 123;
 console.log(tmp); // 123
}

Механизм временной мертвой зоны базируется именно на контексте выполнения. В следующем примере видно, что временная мертвая зона заканчивается именно тогда, когда само выполнение кода доберется до объявления переменной. И выполнение кода дальше пройдет без ошибок несмотря на то, что сама функция func, где запрашивается переменная, написана (определена лексически) раньше объявления let myVar = 3.

{
 // Заход в новую блочную область видимости
 // Начало временной мертвой зоны

 const func = function() {
  console.log(myVar); // 3
 };

 // Здесь действует временная мертвая зона
 // и обращение к переменной myVar вызовет ошибку ReferenceError

 let myVar = 3; // конец временной мертвой зоны
 func(); // вызов функции после окончания временной мертвой зоны
}

Еще одним интересным моментом является поведение оператора typeof при временной мертвой зоне. Оператор typeof возвращает тип данных переменной и часто используется для проверки существования глобальных переменных.

console.log(typeof foo); // Uncaught ReferenceError: foo is not defined
console.log(typeof aVariableThatDoesNotExist); // undefined
let foo;

В случае с необъявленной переменной aVariableThatDoesNotExist, которой не существует, оператор покажет undefined. А в случае с объявленной foo возникнет ошибка, так как эта переменная объявлена, но запрошена во время действия временной мертвой зоны.

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

Выполнение кода. Однопоточность и синхронное выполнение

После этапа создания контекста начинается второй этап - выполнение кода. Написанный код, построчно интерпретируется и выполняется JavaScript-движком.

Рассмотрим пример выполнения кода:

console.log("Первое обращение: " + text);

var text = "Всем привет!";

console.log("Второе обращение: " + text);

Результат выполнения кода:

> "Первое обращение: undefined"
> "Второе обращение: Всем привет!"

В данном случае, на этапе выполнения, первое обращение не вызовет ошибки и выведет undefined именно из-за пройденного этапа создания контекста выполнения, на котором код был целиком исследован/обработан JavaScript-движком, в результате чего объявленная в нём переменная text добавилась в запись окружения и под неё выделилось место в памяти. Иными словами произошло “всплытие этой переменной”. Но само значение этой переменной еще не было присвоено, это произойдет только после выполнения строки с объявлением переменной, где ей присваивается определенное значение — “Всем привет!”. Поэтому при первом обращении будет выведено undefined, а при втором выведется уже заданное при объявлении значение переменной.

console.log("Первое обращение: " + text); // <-- при выполнении этой строки environmentRecord = {text: undefined}

var text = "Всем привет!"; // <-- выполнение этой строки меняет environmentRecord на {text: "Всем привет!"}

console.log("Второе обращение: " + text); // <-- при выполнении этой строки environmentRecord = {text: "Всем привет!"}

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

Схематично это можно изобразить так:

Однопоточное выполнение задач

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

Например, вызовем 50 раз (очень неэффективную) функцию, которая ищет простые числа среди довольно больших чисел.

const iterations = 50;
const multiplier = 1000000000;

function calculatePrimes(iterations, multiplier) {
 var primes = [];
 for (var i = 0; i < iterations; i++) {
  var candidate = i * (multiplier * Math.random());
  var isPrime = true;
  for (var c = 2; c <= Math.sqrt(candidate); ++c) {
   if (candidate % c === 0) {
    // не простое число
    isPrime = false;
    break;
   }
  }
  if (isPrime) {
   primes.push(candidate);
  }
 }
 return primes;
}

const before = window.performance.now(); // время до начала выполнения
console.log("Простые числа: " + calculatePrimes(iterations, multiplier));
let delay = window.performance.now() - before;
console.log("Этот вывод в консоль ждал: " + delay + " мс");

Или, например, метод alert полностью блокирует выполнение последующих операций

const before = window.performance.now(); // время до начала выполнения
alert("alert блокирует выполнение последующих операций");
let delay = window.performance.now() - before;
console.log("Этот вывод в консоль ждал: " + delay + " мс");

Задачи на понимание механизма всплытия

Каким будет результат выполнения следующих программ?

Задача №1

let value = 100;
function worker() {
 value = 10;
 return; // завершает выполнение функции и возвращает значение undefined
 function value() {}
}
worker();
console.log(value);

Ответ: Несмотря на то, что в функции worker() команда return указана в коде раньше, чем объявлена функция value(), это объявление всё равно всплывет на этапе создания контекста выполнения. Поэтому при вызове worker() в её запись окружение запишется свойство value со значением указанной функции

let value = 100;
function worker() {
 // environmentRecord: {value: function value(){}}
 value = 10;
 return; // завершает выполнение функции и возвращает значение undefined
 function value() {}
}
worker();
console.log(value);

Далее, присвоение value = 10 изменит значение именно этого всплывшего идентификатора value и никак не затронет глобальную переменную let value = 100;. Это произойдет потому, что поиск необходимой переменной происходит по цепочке лексических окружений, начиная с текущего. В данном случае, из-за механизма всплытия, в текущем окружении функции worker() нужная переменная value сразу обнаружится, поиск прекратится и ей присвоится новое значение:

let value = 100;
function worker() {
 // environmentRecord: {value: 10}
 value = 10; // выполнение этой строки изменит значение value в текущей записи окружения
 return; // завершает выполнение функции и возвращает значение undefined
 function value() {}
}
worker();
console.log(value);

Соответственно на последней строке console.log(value); запрашивается глобальная переменная value, значение которой так и осталось прежним — 100

> 100

Задача №2

function outer() {
 function inner() {
  return 3;
 }

 return inner(); // результат выполнения функции inner() и будет возвращаемым результатом выполнения функции outer()

 function inner() {
  return 8;
 }
}

let result = outer();
console.log(result);

Ответ:

Опять же несмотря на то, что в функции outer() команда return указана в коде раньше, чем второе объявление функции inner(), оно всё равно всплывет и перезапишет предыдущее значение, установленное первым объявлением этой функции

function outer() {
 // первое всплывшее объявление добавит идентификатор inner в запись окружения — environmentRecord: {inner: function inner(){ return 3;}}
 // вторым всплывшим объявлением его значение перезапишется — environmentRecord: {inner: function inner(){ return 8;}}

 function inner() {
  return 3;
 }

 return inner(); // результат выполнения функции inner() и будет возвращаемым результатом выполнения функции outer()

 function inner() {
  return 8;
 }
}

let result = outer();
console.log(result);

Поэтому в консоли отобразится — 8

> 8

Задача №3

function parent() {
 var hoisted = "I'm a variable";

 function hoisted() {
  return "I'm a function";
 }

 return hoisted(); // результат выполнения функции hoisted() и будет возвращаемым результатом выполнения функции parent()
}

let result = parent();
console.log(result);

Ответ:

Ход выполнения функции parent() будет следующим, сначала всплывет объявление var переменной hoisted, тем самым в запись окружения добавится идентификатор hoisted со значением undefined. Далее всплывет объявление функции с тем же идентификатором hoisted, поэтому его значение в записи изменится на объявленную функцию.

function parent() {
 // первое всплывшее объявление переменной добавит идентификатор hoisted со значением undefined в запись окружения — environmentRecord: {hoisted: undefined}
 // вторым всплывшим объявлением его значение перезапишется — environmentRecord: {hoisted: function hoisted(){ return "Я функция!";}}

 var hoisted = "Я переменная!";

 function hoisted() {
  return "Я функция!";
 }

 return hoisted(); // результат выполнения функции hoisted() и будет возвращаемым результатом выполнения функции parent()
}

let result = parent();
console.log(result);

После этапа создания контекста начнется этап выполнения и выполнение строки var hoisted = "Я переменная!"; снова изменит значение идентификатора hoisted на "Я переменная!". Поэтому вызов hoisted() как функции после ключевого слова return вызовет ошибку TypeError, так как идентификатор hoisted уже указывает не на функцию, а на строку.

function parent() {
 // environmentRecord: {hoisted: "Я переменная!"}

 var hoisted = "Я переменная!"; // <-- выполнение это строки изменит значение hoisted в записи окружения с функции на строку "Я переменная!"

 function hoisted() {
  return "Я функция!";
 }

 return hoisted(); // попытка вызова строки вместо функции вызовет ошибку TypeError: hoisted is not a function
}

let result = parent();
console.log(result);

Поэтому в консоли отобразится ошибка и выполнение программы прервётся

> Uncaught TypeError: hoisted is not a function

Задача №4

Какой результат покажет такая же функция parent() как в задаче выше, но с одним отличием — переменная hoisted в ней объявлена через let?

function parent() {
 let hoisted = "I'm a variable";

 function hoisted() {
  return "I'm a function";
 }

 return hoisted(); // результат выполнения функции hoisted() и будет возвращаемым результатом выполнения функции parent()
}

let result = parent();
console.log(result);

Ответ:

В данном случае при вызове функции возникнет ошибка Uncaught SyntaxError: Identifier 'hoisted' has already been declared, потому что объявление let или const переменной с одинаковым идентификатором запрещено, даже если повторное объявление происходит с помощью ключевого слова var или объявления функции с тем же именем. Подробнее об этом рассказывалось в теме Повторное объявление переменных в одной области видимости

Задача №5

let result = outer();
console.log(result);

function outer() {
 var inner = function() {
  return 3;
 };

 return inner();

 var inner = function() {
  return 8;
 };
}

Ответ:

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

let result = outer();
console.log(result);

function outer() {
 // environmentRecord: {inner: undefined}
 var inner = function() {
  return 3;
 };

 return inner();

 var inner = function() {
  return 8;
 };
}

Только на этапе выполнения значение inner меняется на функцию, возвращающую значение 3. После первого присвоения следует инструкция return inner(); которая прервет дальнейшее выполнение функции outer(), не дойдя до выполнения присваивания inner новой функции.

let result = outer();
console.log(result);

function outer() {
 // environmentRecord: {inner: function() {return 3;}; }
 var inner = function() {
  return 3;
 }; // <-- выполнение это строки изменило значение inner в записи окружения

 return inner();

 var inner = function() {
  return 8;
 };
}

И в качестве результата выполнения outer() вернется результат выполнения функции inner(), то есть — 3

> 3

Задача №6

var value = 10;

let worker = function() {
 console.log("Первое значение: " + value);
 var value = 20;
 console.log("Второе значение: " + value);
};

worker();
console.log("Третье значение: " + value);

Ответ: Так как при выполнении функции worker() переменная value на этапе создания контекста сначала всплывет со значением undefined, то первым значением в консоли отобразится именно undefined. После этого, на этапе выполнения, ей присвоится значение 20, и поэтому вторым значением в консоли отобразится именно оно — 20. Третьим значением отобразится 10, так как глобальная переменная value не была подвергнута никаким изменениям из-за всплытия в функции worker() собственной локальной переменной value.

В итоге в консоли будет выведено

> "Первое значение: undefined"
> "Второе значение: 20"
> "Третье значение: 10"

Дата изменения:

Обнаружили ошибку или хотите добавить что-то своё в документацию? Отредактируйте эту страницу на GitHub!

Оставить комментарий