Як насправді розв’язується проблема змінних у ланцюзі областей видимості JavaScript
Пояснює механізм лексичного ланцюга областей видимості, який використовується під час пошуку змінних, чому використання var спричиняє проблеми у циклах, та як з цього природно випливають закриті функції.
Пошук змінних не пов’язаний із круглими дужками
Типовий спосіб, яким люди вперше дізнаються про область видимості, — це твердження, що «область видимості — це все, що знаходиться всередині дужок». Таке описування допомагає пройти тест, але воно не допоможе передбачити, що насправді робить певний фрагмент коду. Справжній механізм є більш механічним та прогнозованим, ніж може здатися з цього скороченого опису, і як тільки ви його зрозумієте, закриті функції більше не здаватимуться чимось магічним.
Область видимості визначається місцем написання коду, а не способом його виконання
JavaScript розв’язує проблеми зі змінними лексично. Це означає, що область видимості змінної визначається її фізичним розташуванням у вихідному файлі на момент її створення, а не тим, яка функція викликає іншу під час виконання програми.
const value = "outer";
function readValue() {
console.log(value);
}
function runWithDifferentValue() {
const value = "inner";
readValue(); // still logs "outer", not "inner"
}
runWithDifferentValue();
readValue не цікавиться тим, хто його викликає чи які змінні існують у середовищі викликаючого коду. Єдине, що має для нього значення — це місце, де він був фізично визначений, а саме поруч із const value = „outer“. Це єдина змінна value, яку він коли-небудь зможе побачити, незалежно від того, звідки саме у вашій програмі його викликають. Саме тут зазвичай заплутуються розробники, які працювали з мовами з динамічним обмеженням області видимості (або, точніше, ті, хто ніколи не змушений був про це думати, адже лексичне обмеження є стандартом майже скрізь). Область видимості закладена у саму структуру коду з моменту його написання; вона не змінюється залежно від стеку викликів під час виконання.
Справжній механізм пошуку: перебір ланцюга областей видимості
Коли двигун має розібратися з посиланням на змінну, він не шукає у всьому кодовому базі. Він починає саме з місця, де використовується змінна, і просувається назовні, по одному оболонковому контексту за раз, зупиняючись як тільки знаходить відповідність:
const a = "global";
function outer() {
const b = "outer";
function inner() {
const c = "inner";
console.log(a, b, c); // "global outer inner"
}
inner();
}
outer();
inner спочатку перевіряє наявність c та знаходить його прямо там, без необхідності пошуку далі. Потім вона перевіряє b – його немає у локальному області видимості, тож пошук переходить у область видимості outer, де він і знаходиться. Далі перевіряється a, якого немає ні в inner, ні в outer, тому пошук продовжується, поки не досягне глобальної області видимості, де він нарешті знаходиться. Ця послідовність – область видимості inner, потім область, яка її оточує, потім ще одна область, яка оточує попередню, аж до глобальної – і є всім алгоритмом пошуку. Не відбувається нічого складнішого, ніж „подивитися тут, потім подивитися на рівень вище, повторювати, поки не знайдеться“.
Саме цей механізм пояснює явище затінення змінних без необхідності окремого правила. Якби inner оголосив власну змінну const b, пошук припинився б як тільки знайшов би цю локальну змінну b і навіть не дійшов би до змінної b, визначеної у outer. У цьому сценарії ніщо не підписується заміною; просто спочатку знаходиться більш близький варіант, тому пошук не має причин продовжуватися.
Чому var поводиться інакше та чому це спричиняє відому помилку
let та const прив’язуються до найближчого обгортаючого блоку, тобто до будь-якої пари фігурних дужок, чи то умовного оператора if, чи тіла циклу. var зовсім не дотримується цього правила. Натомість var прив’язується до найближчої обгортаючої функції, повністю ігноруючи межі блоків.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3
У цьому фрагменті є рівно одна змінна i, яка є локальною для функції, і кожна ітерація циклу використовує саме цю єдину змінну. До того часу, як будь-який з калебів setTimeout справді виконається, цикл вже завершив свою роботу, а змінна i вже набрала значення 3. Усі три калеби посилаються на однакову змінну, а не отримують кожен свою копію, тому всі вони повідомляють остаточне значення, яке вона мала наприкінці.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2
Оскільки ключове слово let використовується у блоковому області видимості, і оскільки мова спеціально створює нову змінну i для кожного проходження циклу, кожен калебек у підсумку працює з власною окремою змінною i, яка залишається незмінною на значення, яке вона мала під час тієї конкретної ітерації. Код виглядає майже ідентично до попереднього прикладу, але правила області видимості тут інші, що призводить до значно прогнозованіших результатів.
Калебеки — це не окрема функція, а лише наслідок ланцюга областей видимості
Як тільки ви зрозумієте, як працює ланцюг областей видимості, замикання майже не потребують додаткових пояснень. Замикання — це не якийсь додатковий механізм, що додається до областей видимості. Це просто те, що природньо відбувається кожного разу, коли ви визначаєте функцію всередині іншої функції, а ця внутрішня функція потім використовується десь після того, як зовнішня область видимості зазвичай була б викинута.
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
const debouncedSearch = debounce((query) => runSearch(query), 300);
debounce виконується рівно один раз. Якщо ви звикли до мов без клоузур, у вас може скластися враження, що timeoutId має зникнути відразу після завершення виконання debounce. Однак це не відбувається, оскільки функція, яку повертає debounce, була визначена всередині неї, що означає, що ланцюг областей видимості цієї функції постійно включає власну область видимості debounce, а разом з нею і timeoutId. Кожен наступний виклик debouncedSearch, незалежно від того, наскільки пізніо він відбувається, простежує цей самий ланцюг областей видимості до того самого timeoutId. Саме тому працює логіка дебаунсування: потрібна єдина, постійна змінна для відстеження чекаючого таймауту під час кожного виклику, і саме клоузура гарантує це.
Та сама функція, яка забезпечує роботу клоузур, також може спричинити витік пам’яті
Те, що робить замикання корисними, — це саме та властивість, яка створює певну постійну проблему: замикання зберігає весь свій оточуючий область видимості, а не лише змінні, на які воно фактично посилається, і воно має живий посилання на саму змінну, а не заморожену копію її значення на момент створення замикання.
function createHandlers() {
let clickCount = 0;
const massiveDataset = loadHugeArray(); // large, no longer needed after setup
return {
onClick: () => {
clickCount++; // only this variable is actually used
console.log(clickCount);
},
};
}
onClick захоплює весь обсяг, що належить до createHandlers, включаючи massiveDataset, хоча onClick насправді ніколи його не читає. Доки onClick існує, усе інше, що він захопив, також залишається активним, що становить справжню, хоча й зазвичай незначну, проблему з пам’яттю, коли довгоживучий закритий функціонал зберігає посилання на великі або непотрібні дані. Оскільки закритий функціонал зберігає справжню змінну, а не скопійоване значення, він завжди бачить поточний, актуальний стан. Це той самий основний принцип, який забезпечив правильну роботу прикладу з debounce та змусив цикл із var виводити 3, 3, 3 — єдиний механізм, який у одному контексті є корисною функцією, а в іншому — помилкою.
Розуміння механізму краще, ніж запам’ятовування визначення
Формулювання з підручника „замикання — це функція, поєднана з її лексичним середовищем“ є технічно правильним, але його значення рідко усвідомлюється, поки ви кілька разів вручну не простежите ланцюг областей видимості та не побачите, де саме припиняється пошук. Як тільки простеження цього ланцюга стає звичкою, замикання більше зовсім не потребують особливих пояснень. Це просто звичайна наслідок того, як завжди функціонувала область видимості, застосована до функції, яка випадково існує довше, ніж середовище, в якому вона була створена.
Пов’язана література
- Що насправді робить та чого не робить нативна підтримка TypeScript у Node.js — У цій статті пояснюється, як Node.js виконує файли .ts нативно шляхом видалення типів, чому він пропускає перевірку типів та коли все одно потрібен справжній крок компіляції.