Замикання і область видимості в JS
Перед розглядом складних замикань давайте розглянемо більш прості види областей видимості.
Область видимості
Тож Область видимості (scope) - це концепт в JS, який визначає доступність змінних і функцій в тому чи іншому місці коду. Ця концепція лежить також в основі замикань, поділяючи змінні на локальні та глобальні.
В JS існує кілька видів області видимості. При цьому варто зазначити, що вони звичайно ж можуть бути вкладеними.
Блоковий скоуп
Блоки коду, як if, for, while, або ж штучні ({ }
) визначають нову область видимості для змінних, оголошених з використанням let
і const
. var
натомість не має блокової області видимості - змінні, оголошені з використанням var
, доступні майже скрізь завдяки механізму hoisting'а.
Скоуп функції
Функція, змісно ж, також має власну область видимості. Що правда, вона також працює і для var'ів
навідміну від блокового скоупу. При цьому не важливо, функція оголошена як function declaration
чи function expression
.
Скоуп модуля
Логічно, що і у модулів також є свій скоуп. Якщо ми імпортували модуль, але він не експортує будь-яких змінних, ми не зможемо отримати до них доступ:
// модуль circle const pi = 3.14; console.log(pi); // 3.14 // кінець модуля circle // інший модуль import './circle' console.log(pi) // ReferenceError // кінець іншого модуля
Глобальний скоуп
Це найвища область видимості, яка обмежується скриптом. Вона, до речі, створена для того, щоб ми могли мати доступ до глобальних обʼєктів типу Window
або document
.
Насправді існує ще одна область видимості - лексична. Саме про це і поговоримо далі, в контексті замикань - так це простіше зрозуміти.
Замикання
Замикання - це функції, які посилаються на незалежні змінні. Тобто, функція, визначена у замиканні, запамʼятовує "середовище", в якому була створена. Незалежні змінні тут - це ті змінні, які були використані у функції як аргументи і не були створені у ній локально.
function makeWorker() { let name = "Pete"; return function() { console.log(name); }; }; let name = "John"; let work = makeWorker(); work(); // "Pete", "John" чи взагалі помилка?
На співбесідах достатньо сказати те, що я написав вище, а також, можливо, показати цей приклад. А його суть, а також те, як це працює, я опишу далі.
Лексичне середовище
Що таке змінна в JS? Непоганий початок, так? Насправді, змінних не існує. Зате існує таке поняття як лексичне середовище (Lexical Environment). В JS у кожної функції, виконуваного блоку коду чи скрипта є деякий обʼєкт, який має назву лексичне середовище. Цей обʼєкт складається з двох частин:
- Environment Record - обʼєкт, у якому всі створені в рамках даного блоку коду змінні зберігаються у якості полів;
- Посилання на зовнішнє лексичне середовище. У глобального дорівнює
null
.
Отже, змінна - це насправді поле деякого обʼєкта, який звʼязаний з тим блоком коду, в якому ця змінна оголошена.
Це якщо казати про змінні. А як щодо функцій (function declaration
) і змінних, оголошених з використанням var
? Насправді, все просто: перед початком виконання коду вони вже зберігаються у глобальному лексичному середовищі, дякуючи нашому улюбленому механізму hoisting'у
.
Тепер поговоримо про вкладені лексичні середовища.
Під час виклику функції у нас, крім глобального лексичного середовища, зʼявляється ще одне - те, яке створюється для функції, що ми її викликаємо. Звідси можемо зробити висновок: коли код хоче отримати доступ до якої-небудь змінної, спочатку відбувається її пошук у внутрішньому лексичному середовищі функції, потім у зовнішньому, і так далі аж до глобального, поки змінна не буде знайдена. Саме тому якщо функція хоче отримати доступ до значення деякої змінної, вона завжди спершу шукає його у своєму власному лексичному середовищі. Тобто відповідь до першого фрагменту коду буде така:
function makeWorker() { let name = "Pete"; return function() { console.log(name); }; }; let name = "John"; let work = makeWorker(); work(); // "Pete"
Варто зазначити, що лексичне середовище створюється кожен раз перед викликом функції, тобто новий виклик = нове лексичне середовище.
Вкладені функції
Тепер ближче до теми замикань. Іноді ми створюємо функцію, яка повертає нову функцію. Та функція, що всередині, називається вкладеною:
function makeCounter() { let count = 0; return function() { return count++; // є доступ до зовнішньої змінної count }; } let counter = makeCounter(); alert(counter()); // 0 alert(counter()); // 1 alert(counter()); // 2
Як це працює? При виконанні функції counter
починається пошук змінної count
, яка буде знайдена у батьківському лексичному середовищі. Тож є два питання:
- Чому значення змінної
count
зберігається? Ти ж казав, що кожного разу при виклику функції створюється нове лексичне середовище... - Чи буде ця змінна спільною, якщо ми створимо два таких лічильники:
let counter1 = makeCounter(); let counter2 = makeCounter(); alert(counter1()); // 0 alert(counter1() ; // 1 alert(counter2()); // 1 чи 0?
Отже, по-перше, всі функції при "народженні" отримують приховане поле (функції ж насправді - об'єкти) під іменем [[Environment]]
, яке містить посилання на лексичне середовище місця, де функція була створена. Таким чином, функції знають, де вони були створені. Саме тому, коли ми визначаємо функцію makeCounter
, у її поле [[Environment]]
записується посилання на глобальне лексичне середовище. Далі ми викликаємо цю функцію і записуємо результат її виконання у змінну counter
, яка вже, до речі, є у глобальному середовищі, але ще не задана (містить undefined
).
При виклику makeCounter
створюється її лексичне середовище, в якому є поле count
. Далі ми створюємо анонімну функцію, яка одразу ж отримує посилання на батьківське лексичне середовище, тобто лексичне середовище makeCounter
, і повертаємо її.
Отже, ми записали вкладену функцію у змінну counter
і тепер хочемо її викликати. Оскільки у цій функції записане посилання на лексичне середовище makeCounter
, вона звертається при всіх своїх викликах до однієї і тої ж змінної counter
. Тобто, так, при виклику counter кожен раз саме для неї створюється нове лексичне середовище, але батьківське натомість залишається тим самим. Це відповідь на перше питання.
І друге питання: чи буде ця змінна спільною для різних лічильників. Відповідь - ні, тому що як раз тут при кожному виклику makeCounter
створюється нове лексичне середовище, в якому зберігається початкове значення змінної counter
, унікальної для кожного виклику.
Ось тепер маємо відповідь на найперше питання - що ж таке замикання. Замикання - це функція, яка запамʼятовує свої зовнішні змінні і може отримати до них доступ. І найголовніше - в JS усі функції за замовчуванням є замиканнями. А ось лексичне середовище, по суті - це реалізація механізму області видимості (Scope).
Блоки коду
Попередні приклади зосереджувались на функціях, але лексичне середовище існує для будь-якого блоку коду (інакше як би все працювало без змінних :О).
Лексичне середовище створюється при виконанні блоку коду і містить локальні змінні цього блоку. Наприклад:
for (let i = 0; i < 10; i++) { let x = i + 1; } alert(x); // Помилка, немає такої змінної
Тут особливість в тім, що, по-перше, на кожній ітерації циклу створюється нове лексичне середовище, а по-друге, хоч і здається, що змінна i
- лічільник циклу, візуально знаходиться не в його тілі, насправді це один і той же блок коду.
IIFE
В минулому в JS не було лексичного середовища на рівні блоків коду, тому вигадали "костиль". Оскільки лексичне середовище працювало лише для функцій, їх почали використовувати, щоб створити ізольований блок коду:
(function() { let message = "Hello"; alert(message); // Hello })();
Так, тут ми просто створюємо анонімну функцію, яку одразу ж виконуємо, все просто. Це має назву immediately invoked function expression - IIFE.
Сейчас так делать не нужно, лексическое окружение создается для любого блока кода.
Збірка сміття
Зазвичай лексичне середовище видаляется одразу ж після того, як функція чи блок коду виконались:
function f() { let value1 = 123; let value2 = 456; } f();
Але якщо провернути трюк зі вкладеною функцією - у неї залишиться посилання на лексичне середовище батьківської функції, тому воно просто не зможе видалитись, поки не видалиться вкладена функція. У наступному прикладі зберігаються одразу ж три лексичні середовища:
function f() { let value = Math.random(); return function() { alert(value); }; } // Три функції в масиві, кожна з яких посилається на // лексичне середовище з відповідного виклику f() let arr = [f(), f(), f()];
Сюрпризи в дебагері
Єдине, про що варто також зазначити - приколи дебагера. Якщо ви якось захочете скористатись дебагером у браузері з двигуном V8
, знайте, що він любить все оптимізувати, тому при відладці можуть бути сюрпризи:
let value = "Сюрприз!"; function f() { let value = "найближче значення"; function g() { debugger; // в консолі напишіть alert(value): отримаєте "Сюрприз!" } return g; } let g = f(); g();
function f() { let value = Math.random(); function g() { debugger; // в консолі напишіть alert(value): такої змінної немає! } return g; } let g = f(); g();
Ще раз: такі приколи зʼявляються лише у відладці, при звичайній роботі все буде ок.
Висновки
В JS існує 5 видів області видимості: блокова, функція, модуль, глобальна, а також лексична. До блокових відносяться всі конструкції циклів, умовні конструкції, а також штучні блоки коду.
Глобальна область видимості створена для роботи з полями глобального обʼєкту:Window
,document
,Math
...
Просте визначення замикання: замикання - це функції, що посилаються на незалежні змінні. Таким чином, функція, що визначена у іншій функції (замиканні) запамʼятовує середовище, в якому була створена.
Під капотом замикання засновані на механізмі лексичного середовища (Lexical Environment).
В JS всі функції є замиканнями (і стрілкові теж), а лексичне середовище по суті - це реалізація механізму області видимості (Scope).
У блоків коду в JS, таких як цикли, умовні конструкції, а також у штучних блоків коду ({ }
) також є лексичне середовище.
Раніше у блоків коду не було власного лексичного середовища, тому використовували лайфхак: створювали анонімні функції і одразу ж викликали їх. Це зветься immediately invoked function expression (IIFE).
Лексичне середовище видаляється одразу ж після закінчення виконання функції чи блоку. Але якщо у скоупі відповідної функції є вкладена функція - батьківське лексичне середовище буде жити допоки на нього існують посилання.