Пошук уроків, статей та іншого контенту
Розберіть, що насправді відбувається з деклараціями до виконання коду та чому пояснення «JavaScript піднімає змінні нагору» є лише спрощеною моделлю. Дізнайтеся, як створення Execution Context пояснює поведінку hoisting точніше.
Hoisting — неформальний термін для опису поведінки JavaScript, за якої деякі декларації доступні ще до того, як виконання коду дійде до їхнього рядка.
Поширене пояснення звучить так:
JavaScript піднімає декларації змінних і функцій на початок файлу.
Це зручна, але неточна модель. JavaScript не переставляє рядки програми перед виконанням. Натомість під час створення контексту виконання рушій готує області видимості та створює прив’язки для декларацій.
Саме тому різні види декларацій поводяться по-різному:
var створюється та одразу отримує значення undefined;
let і const створюються, але до виконання декларації залишаються неініціалізованими;
декларації функцій зазвичай доступні повністю ще до місця оголошення;
класи мають поведінку, подібну до let і const;
ініціалізатори змінних не виконуються під час підготовки контексту.
JavaScript виконує код у межах Execution Context — контексту виконання.
Основні види контекстів:
глобальний контекст — створюється під час запуску скрипту;
контекст функції — створюється під час кожного виклику функції;
контекст модуля — використовується для ES-модулів.
У спрощеній моделі контекст виконання проходить два етапи:
Створення — рушій готує область видимості, параметри, декларації функцій і змінних.
Виконання — код виконується послідовно зверху вниз.
Це не означає, що JavaScript обов’язково має буквально дві окремі проходи по коду. Це модель, яка допомагає зрозуміти результат роботи специфікації та рушія.
Під час створення контексту JavaScript:
визначає доступні області видимості;
створює прив’язки для параметрів і декларацій;
обробляє декларації функцій;
готує змінні var;
готує лексичні декларації let, const і class;
створює інші внутрішні дані контексту.
Після цього інструкції виконуються в порядку появи:
console.log(value); // undefined
var value = 42;
console.log(value); // 42Під час створення контексту прив’язка value вже існує та має значення undefined.
Під час виконання:
console.log(value) читає поточне значення undefined;
var value не створює змінну заново;
value = 42 змінює її значення;
другий console.log виводить 42.
Умовно це можна уявити так:
var value; // підготовлено під час створення контексту
console.log(value);
value = 42; // виконується у своєму початковому місці
console.log(value);Це лише пояснювальна модель, а не фактичне переписування початкового коду.
varДекларація var має функціональну або глобальну область видимості. Вона створюється на етапі підготовки контексту та одразу ініціалізується значенням undefined.
function showValue() {
console.log(value); // undefined
var value = "JavaScript";
console.log(value); // JavaScript
}
showValue();Умовно поведінка схожа на таку:
function showValue() {
var value = undefined;
console.log(value);
value = "JavaScript";
console.log(value);
}Важливо розділяти:
декларацію — var value;
ініціалізацію та присвоєння — value = "JavaScript".
Під час підготовки контексту обробляється декларація, але вираз "JavaScript" не обчислюється наперед.
console.log(randomNumber); // undefined
var randomNumber = Math.random();
console.log(randomNumber); // випадкове числоMath.random() виконується лише тоді, коли виконання дійде до цього рядка.
Тому таке пояснення неправильне:
Весь рядок
var randomNumber = Math.random()піднімається нагору.
Точніше сказати:
Прив’язка
randomNumberстворюється заздалегідь, але її ініціалізатор виконується у початковому місці.
varvar не має блочної області видимості. Блоки if, for і while не створюють для нього окрему область.
if (true) {
var message = "Готово";
}
console.log(message); // ГотовоВодночас var обмежений функцією:
function createValue() {
var value = 10;
}
createValue();
console.log(value); // ReferenceErrorУ цій ситуації value існує лише в контексті виконання функції createValue.
var у блоціfunction example() {
console.log(status); // undefined
if (true) {
var status = "active";
}
console.log(status); // active
}
example();Для var блок if не є окремою областю. У межах функції це приблизно відповідає такому коду:
function example() {
var status = undefined;
console.log(status);
if (true) {
status = "active";
}
console.log(status);
}Саме через таку поведінку в сучасному коді зазвичай віддають перевагу let і const.
let і const: прив’язка існує, але значення недоступнеНа відміну від var, змінні let і const не отримують undefined під час створення контексту.
Їхні прив’язки створюються заздалегідь, але залишаються неініціалізованими до виконання декларації.
console.log(value); // ReferenceError
let value = 42;Для const результат такий самий:
console.log(config); // ReferenceError
const config = {
mode: "production",
};Період між створенням прив’язки та її ініціалізацією називають Temporal Dead Zone, або TDZ — часовою мертвою зоною.
TDZ починається на початку відповідної області видимості та закінчується, коли виконання доходить до декларації змінної.
{
// Початок TDZ для count
console.log(count); // ReferenceError
let count = 3;
// Після декларації count доступна
console.log(count); // 3
}Змінна вже відома рушію, але читати її ще не можна.
Це відрізняється від ситуації, коли змінна взагалі не оголошена:
console.log(notDeclared); // ReferenceError: notDeclared is not definedУ першому випадку прив’язка існує, але не ініціалізована. У другому випадку прив’язки немає в доступних областях видимості.
typeof і TDZОператор typeof зазвичай не створює помилку для неоголошеної змінної:
console.log(typeof missingValue); // undefinedАле він не обходить TDZ:
{
try {
console.log(typeof value);
} catch (error) {
console.log(error.name); // ReferenceError
}
let value = 10;
}Тут value уже має прив’язку в поточному лексичному середовищі, тому typeof не може скористатися спеціальною поведінкою для повністю неоголошених імен.
let і const мають блочну область видимості.
if (true) {
const message = "Всередині блоку";
console.log(message); // Всередині блоку
}
console.log(message); // ReferenceErrorБлок створює окреме лексичне середовище. Це стосується також циклів:
for (let index = 0; index < 3; index += 1) {
console.log(index);
}
// console.log(index); // ReferenceErrorНа відміну від var:
for (var index = 0; index < 3; index += 1) {
console.log(index);
}
console.log(index); // 3Декларація функції зазвичай створюється під час підготовки контексту разом із функціональним об'єктом. Тому функцію можна викликати до рядка її декларації.
sayHello(); // Привіт!
function sayHello() {
console.log("Привіт!");
}Це не означає, що код функції виконується під час створення контексту. Під час створення доступним стає сам об'єкт функції, але тіло функції виконається лише під час виклику.
showMessage(); // Повідомлення
function showMessage() {
console.log("Повідомлення");
}Умовна модель:
// Під час створення контексту:
// showMessage уже посилається на функцію
showMessage();Важливо відрізняти декларацію функції від функціонального виразу:
run(); // TypeError: run is not a function
var run = function () {
console.log("Виконання");
};Під час створення контексту:
var run = undefined;Тому виклик run() намагається викликати значення undefined.
Для стрілкової функції з let буде TDZ:
run(); // ReferenceError
let run = () => {
console.log("Виконання");
};Стрілкова функція не має окремого спеціального правила hoisting. Вона є значенням, яке присвоюється змінній під час виконання відповідного рядка.
Декларації класів також створюються під час підготовки лексичного середовища, але до виконання декларації перебувають у TDZ.
const instance = new User(); // ReferenceError
class User {
constructor(name) {
this.name = name;
}
}Клас не поводиться як декларація функції, навіть якщо синтаксично він також оголошується через декларацію.
Правильний порядок:
class User {
constructor(name) {
this.name = name;
}
}
const instance = new User("Олена");
console.log(instance.name); // ОленаКожен виклик функції створює новий контекст виконання. Декларації всередині різних викликів є незалежними.
function showValue() {
console.log(value); // undefined
var value = "локальне значення";
console.log(value); // локальне значення
}
var value = "глобальне значення";
showValue();
console.log(value); // глобальне значенняПід час виклику showValue створюється локальна прив’язка value. Вона маскує глобальну змінну ще до виконання її декларації.
Це називають shadowing — перекриттям змінної з зовнішньої області видимості.
Параметри також створюються в контексті функції.
function multiply(number, factor) {
console.log(number); // 5
console.log(factor); // 2
return number * factor;
}
console.log(multiply(5, 2)); // 10Параметри отримують передані аргументи під час виклику. Якщо аргумент не передано, значення параметра за замовчуванням буде undefined, якщо не задано значення за замовчуванням.
function greet(name) {
console.log(name); // undefined
}
greet();Параметри та локальні декларації мають взаємодіяти в межах одного контексту, тому однакові імена можуть створювати плутанину:
function example(value) {
var value = "нове значення";
console.log(value); // нове значення
}
example("початкове значення");Краще не використовувати однакові імена для параметрів і локальних змінних.
Якщо в одній області видимості є кілька декларацій функції з однаковим іменем, поведінка може бути неочевидною:
show(); // Другий варіант
function show() {
console.log("Перший варіант");
}
function show() {
console.log("Другий варіант");
}У такому випадку пізніша декларація перезаписує попередню в тій самій області видимості.
Не варто покладатися на такі конфлікти. У реальному коді кожна функція повинна мати унікальне та зрозуміле ім'я.
ES-модулі мають лексичну область видимості. Імена імпортованих значень доступні в модулі за правилами модульного середовища, але самі значення не можна використовувати до їхньої ініціалізації.
console.log(total); // ReferenceError
const total = 100;Імпорти також мають спеціальну статичну обробку: рушій може визначити їх під час аналізу модуля ще до виконання його тіла. Проте це не означає, що будь-який код, пов'язаний з імпортом, виконується «нагорі».
Для практичної роботи достатньо пам'ятати:
let, const і class у модулі мають TDZ;
імпортовані імена не можна довільно переприсвоювати;
модулі не поводяться як звичайний глобальний скрипт;
порядок завантаження та виконання модулів визначається графом залежностей.
console.log(varValue); // undefined
try {
console.log(letValue);
} catch (error) {
console.log(error.name); // ReferenceError
}
console.log(declaredFunction()); // "результат функції"
var varValue = "var";
let letValue = "let";
function declaredFunction() {
return "результат функції";
}
console.log(varValue); // var
console.log(letValue); // letРізниця пояснюється не тим, що одні рядки «піднялися», а інші ні:
varValue має створену та ініціалізовану значенням undefined прив’язку;
letValue має створену, але ще неініціалізовану прив’язку;
declaredFunction уже посилається на функціональний об'єкт;
присвоєння "var" і "let" виконуються лише у своїх початкових місцях.
Щоб правильно аналізувати поведінку декларації, поставте чотири запитання:
Який це тип декларації: var, let, const, function чи class?
Яка область видимості її містить?
Чи створюється прив’язка під час підготовки контексту?
Яке значення має прив’язка до виконання рядка декларації?
Приблизна відповідність:
var → прив’язка існує та має undefined;
let → прив’язка існує, але перебуває в TDZ;
const → так само перебуває в TDZ;
class → так само перебуває в TDZ;
function declaration → доступна функція;
function expression з var → змінна має undefined;
function expression або стрілкова функція з let/const → змінна в TDZ.
Неправильно:
JavaScript переносить усі декларації на початок файлу.
Точніше:
Під час створення контексту рушій готує прив’язки відповідно до типу декларації, а виконання виразів відбувається у початковому порядку.
var одразу має фактичне значенняconsole.log(name); // undefined
var name = "Марія";Значення "Марія" не існує до виконання присвоєння.
start(); // TypeError
var start = function () {
console.log("Старт");
};Це не декларація функції, а присвоєння функціонального значення змінній start.
let і const не hoisted взагаліПопулярне твердження:
letіconstне піднімаються.
Практично воно пояснює відсутність доступу до змінної, але технічно неточне. Їхні прив’язки створюються під час підготовки контексту. Просто до декларації вони перебувають у TDZ.
undefined і відсутність змінної одним і тим самимvar declared = undefined;
console.log(declared); // undefined
console.log(notDeclared); // ReferenceErrorУ першому випадку змінна існує. У другому — імені немає в доступній області видимості.
Хоча декларації функцій можна викликати до місця оголошення, такий стиль не завжди покращує читабельність.
Краще:
оголошувати залежності до їх використання, якщо це робить код зрозумілішим;
використовувати const і let;
не покладатися на складні випадки hoisting;
уникати повторних декларацій однакових імен.
У сучасному JavaScript зазвичай варто:
використовувати const, якщо змінну не потрібно переприсвоювати;
використовувати let, якщо значення змінюється;
уникати var у новому коді;
оголошувати функції та змінні перед використанням, коли це покращує структуру;
не покладатися на виклик функцій до їхньої декларації без зрозумілої причини;
пам'ятати про TDZ під час роботи з let, const і class;
не використовувати однакові імена в сусідніх або вкладених областях без необхідності.
Hoisting — не буквальне переміщення рядків коду.
Точніше пояснення hoisting спирається на створення контексту виконання.
На етапі створення рушій готує області видимості та прив’язки.
var до виконання декларації має значення undefined.
let, const і class до виконання декларації перебувають у TDZ.
Декларації функцій зазвичай доступні до свого місця в коді.
Функціональні вирази та стрілкові функції отримують значення лише під час виконання присвоєння.
Ініціалізатори змінних не виконуються наперед.
var має функціональну область видимості, а let і const — блочну.
Розуміння контекстів виконання пояснює hoisting точніше, ніж метафора «підняття змінних нагору».