Контекст виконання в JavaScript
Контекст виконання (execution context) - це середовище, яке JavaScript створює для кожного фрагменту коду. У ньому зберігаються змінні, прив'язка this і посилання на зовнішній scope.
Теорія
TL;DR
- Уяви робочий стіл: кожен виклик функції отримує свій стіл (контекст) із власними нотатками (змінними), знає хто керівник (outer scope) і яке поточне завдання (
this). - Три типи: глобальний (один на скрипт), функціональний (один на виклик), eval (всередині
eval()). - Дві фази: фаза створення виділяє пам'ять, фаза виконання запускає код.
varпіднімається доundefinedу фазі створення;let/constзалишаються неініціалізованими (Temporal Dead Zone).- Контексти стекуються в call stack. Внутрішні контексти мають доступ до зовнішніх через scope chain. Назад - ні.
Швидкий приклад
console.log(x); // undefined - var піднятий у фазі створення
var x = 1;
console.log(x); // 1
function test() {
console.log(y); // ReferenceError - let залишається в TDZ
let y = 2;
}
test();var x виділяється і отримує значення undefined до того, як виконується будь-який код. let y всередині test() існує в пам'яті, але до нього не можна звернутись поки не виконається той рядок. Цей проміжок і є Temporal Dead Zone.
Що всередині execution context
Кожен контекст, глобальний чи функціональний, має однакову структуру:
- LexicalEnvironment зберігає
let,constі оголошення функцій, плюс посилання на зовнішнє середовище (scope chain). - VariableEnvironment зберігає
var-декларації і аргументи функції. - ThisBinding тримає те, на що вказує
thisу цьому контексті.
У браузері глобальний контекст встановлює this як window. У Node.js як globalThis (або {} у модулі).
Дві фази детальніше
Фаза створення виконується до першого рядка коду. Рушій сканує scope, виділяє пам'ять і:
- встановлює
var-декларації уundefined - позначає
let/constяк неініціалізовані (TDZ) - зберігає повні оголошення функцій (не функціональні вирази) одразу
Фаза виконання запускає код зверху вниз. Змінні отримують реальні значення, викликаються функції, і кожен виклик додає новий контекст до call stack.
Call stack
Call stack - це структура LIFO. Коли викликається функція, новий контекст кладеться зверху. Коли функція повертається, контекст знімається.
function first() {
second();
}
function second() {
third();
}
function third() {
console.log("third");
}
first();
// Стек у найглибшій точці:
// [Global] -> [first()] -> [second()] -> [third()]V8 (Chrome і Node.js) підтримує приблизно 10 000 фреймів, після чого кидає RangeError: Maximum call stack size exceeded. Нескінченна рекурсія досягає цього ліміту дуже швидко.
Ключова різниця: LexicalEnvironment і VariableEnvironment
Обидва живуть всередині кожного execution context, але зберігають різне. let, const і оголошення функцій йдуть у LexicalEnvironment. var і arguments - у VariableEnvironment. Практичний наслідок: var ініціалізується до undefined під час створення, тому його можна зчитати до відповідного рядка. let/const - ні, звернення раніше кидає помилку.
Замикання (closure) працює тому, що LexicalEnvironment внутрішньої функції тримає посилання на середовище зовнішнього контексту. Це посилання не зникає після того, як зовнішня функція повернулась.
Коли застосовувати це знання
- Відлагоджуєш
this is undefinedу callback: відтвори execution context подумки, щоб зрозуміти що такеthisна момент виклику. - Пояснюєш чому
var-змінна дорівнюєundefinedзамістьReferenceError: фаза створення, дивись hoisting. - Розбираєшся зі stale closure у React: хук захопив змінні з контексту попереднього рендеру.
- Трейсиш scope вкладених функцій: йди по scope chain вгору через батьківські контексти.
Як V8 обробляє це всередині
V8 спочатку парсить код в Abstract Syntax Tree. Для кожного execution context інтерпретатор Ignition будує записи LexicalEnvironment і VariableEnvironment, потім резолвить this. Для async-генераторів Ignition призупиняє контекст на yield і відновлює його з тим самим середовищем, не розмотуючи call stack. Так само працює await: функціональний контекст призупиняється, управління повертається до event loop, а черга мікрозадач планує відновлення.
Типові помилки
Вважати що let піднімається як var:
console.log(name); // ReferenceError, не undefined
let name = 'Alice';let піднімається в тому сенсі, що рушій знає про його існування, але не ініціалізує до відповідного рядка. Звернення раніше кидає помилку. Ця помилка часто зустрічається у кодових базах, де var і let використовуються разом у спадковому коді.
Втрата this у вкладеному callback:
const obj = {
path: '/users',
handle: function(req, res) {
setTimeout(function() {
console.log(this.path); // undefined - новий контекст, this = global
}, 0);
}
};Стрілкова функція вирішує це. Вона не створює власного this і успадковує його від оточуючого контексту.
setTimeout(() => {
console.log(this.path); // '/users'
}, 0);Очікувати window як this у strict mode:
'use strict';
function fn() {
console.log(this); // undefined, не window
}
fn();У strict mode this функціонального контексту стає undefined, коли функція викликається без явного отримувача.
Переповнення стека через рекурсію:
function factorial(n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
factorial(10000); // RangeError у V8 (ліміт ~10k фреймів)Кожен виклик додає новий execution context. Використовуй цикл або trampoline-патерн для глибокої рекурсії.
Де зустрічається в реальному коді
- React: кожен рендер компонента - це виклик функції. Хуки
useStateіuseCallbackпрацюють тому, що їх замикання зберігають функціональний контекст відповідного рендер-циклу. - Express: кожен route handler виконується у власному функціональному контексті. Стрілкові функції у middleware зберігають
thisз зовнішнього scope. - Node.js ESM: кожен модуль виконується у власному функціональному контексті, що дає приватний scope без забруднення
globalThis. - Redux: редьюсери отримують доступ до стану через замикання з контексту store, а не через
this.
Питання на співбесіді
Q: Що відбувається у фазі створення?
A: Рушій виділяє пам'ять для всіх декларацій в scope. var-змінні отримують undefined, оголошення функцій зберігаються повністю, let/const позначаються як неініціалізовані. Прив'язується this і встановлюється посилання на зовнішнє середовище.
Q: Як замикання (closure) пов'язане з execution context?
A: Коли визначається внутрішня функція, її LexicalEnvironment зберігає посилання на середовище зовнішньої функції. Це посилання живе навіть після того, як зовнішня функція повернулась. Це і є замикання.
Q: Що таке Temporal Dead Zone?
A: Період між початком фази створення і рядком де ініціалізується let або const. У цей час змінна існує в пам'яті, але звернення до неї кидає ReferenceError.
Q: Яка різниця між глобальним контекстом у браузері та Node.js?
A: У браузері this глобального контексту дорівнює window. У Node.js CommonJS-модулях this на верхньому рівні - це {}, а не global. Сам об'єкт global доступний, але він не збігається з this у модулі.
Q: Як V8 обробляє async-генератор з точки зору execution context?
A: Ignition призупиняє execution context генератора на кожному yield. Контекст зберігається в пам'яті, а не в call stack, і відновлюється коли генератор знову ітерується. Для async-функцій await робить те саме: контекст призупиняється, а черга мікрозадач планує відновлення з тим самим середовищем.
Приклади
Базовий: підняття (hoisting) у фазі створення
function outer() {
console.log(a); // undefined - var піднятий під час створення
var a = 1;
function inner() {
console.log(a); // 1 - зчитує з контексту outer через scope chain
}
inner();
}
outer();
// Виведе:
// undefined
// 1Після виклику outer створюється новий функціональний execution context. У фазі створення a піднімається до undefined. У фазі виконання a стає 1. Коли запускається inner, власного a у неї немає, тому вона піднімається по scope chain до контексту outer і знаходить a = 1.
Середній: stale closure в React effect
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// Захоплює 'count' з контексту рендеру, коли запустився effect.
// Якщо count був 0 тоді - він залишиться 0 тут, stale closure.
console.log(count); // завжди 0, якщо deps array порожній
}, 1000);
return () => clearInterval(interval);
}, []); // порожні deps: запускається один раз, захоплює початковий контекст
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}Callback setInterval замикається на count з контексту першого рендеру. Той контекст заморожений на count = 0. Наступні рендери створюють нові функціональні контексти з новими значеннями, але інтервал їх не бачить. Рішення: додай count до масиву залежностей, або використовуй setCount(c => c + 1), щоб взагалі не потрібен count у замиканні.
Просунутий: втрата this у Express route handler
const express = require('express');
const router = express.Router();
router.get('/users', function(req, res) {
// Звичайна функція: this = router
setTimeout(function() {
// Новий execution context: this = global (або undefined у strict mode)
console.log(this); // {} або undefined
res.json({ ok: true });
}, 0);
});
// Рішення: стрілкова функція успадковує this з оточуючого контексту
router.get('/users-fixed', function(req, res) {
setTimeout(() => {
console.log(this); // router - успадкований від контексту route handler
res.json({ ok: true });
}, 0);
});Стрілкова функція всередині setTimeout не створює власного execution context для this. Вона бере this з найближчого звичайного функціонального контексту вище, тобто з route handler. Це весь механізм того, чому стрілкові функції вирішують проблему втрати this у callback-ах.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.