Функции, scope, замыкания
Функция в JavaScript это первоклассное значение. Её можно положить в переменную, передать аргументом другой функции, вернуть из неё, записать в массив или объект. На языках, где функции остаются просто именами, такое выглядело бы фокусом, но в JS такая возможность есть с первой строки кода: язык спроектирован так, что функция и значение неотличимы.
Та же идея лежит в основе функционального программирования
(см. урок 04-fp-foundations в 02-cs).
Только в JS она работает не как академическая красивость, а как
ежедневная рабочая лошадка. Callback в addEventListener, middleware
в Express, редьюсер в Redux, фабрика виджетов, отложенный таймер на
setTimeout. Всё это обычные функции, которые передаются и
возвращаются точно так же, как числа или строки.
Вместе с этой свободой приходит ответственность за окружение. Каждая
функция помнит, в каком scope она объявлена, и тащит этот scope за
собой даже после того, как он перестал быть видимым: так появляются
замыкания. У каждой формы вызова свой this, и стрелочная функция
отличается от обычной как раз тем, что отказывается переопределять
его. Параметр может быть поднят на верх блока или оказаться в TDZ,
может совпасть по имени с внешней переменной и затмить её.
Этот урок про связь функции с её средой. Разберём четыре формы
объявления, hoisting и TDZ, scope chain, динамический и лексический
this, замыкания и их частые применения, arguments против
rest-параметров. Поговорим, почему IIFE в 2026 году ещё иногда нужен,
хотя ESM закрывает почти все кейсы изоляции.
1. Объявить функцию: четыре формы
// 1. Function statement (declaration)
function add(a, b) {
return a + b;
}
// 2. Function expression
const add = function (a, b) {
return a + b;
};
// 3. Named function expression
const add = function sumTwo(a, b) {
return a + b;
};
// 4. Arrow function expression
const add = (a, b) => a + b;
Снаружи все четыре делают одно и то же: вызови, передай аргументы,
получи результат. Но они отличаются в трёх местах: как поднимаются
(hoisting), что значит this внутри, и есть ли у них arguments.
| Форма | Hoisting | this | arguments | Можно new? |
|---|---|---|---|---|
| function statement | поднимается целиком | dynamic | да | да |
| function expression | как переменная (TDZ для let/const) | dynamic | да | да |
| named function expression | как переменная | dynamic | да | да |
| arrow | как переменная | lexical (от внешнего scope) | нет | нет |
Эта таблица, ядро того, что отличает стрелочные функции и где они ломаются в неожиданном месте.
2. Hoisting и TDZ
Hoisting, не магия и не перемещение строк. Это два прохода по scope: сначала JS регистрирует все декларации, потом исполняет код сверху вниз.
// этот код работает: function statement поднялся целиком
console.log(add(1, 2)); // 3
function add(a, b) { return a + b; }
// этот, нет: const переменная в TDZ до строки декларации
console.log(addExpr(1, 2)); // ReferenceError: Cannot access 'addExpr' before initialization
const addExpr = (a, b) => a + b;
Что именно поднимается:
| Декларация | Видна в scope | Доступна до строки? |
|---|---|---|
var x | сразу | да, значение undefined |
let x, const x | сразу (TDZ) | нет, ReferenceError |
function f() {} | сразу с телом | да, можно вызвать |
class C {} | сразу (TDZ) | нет, ReferenceError |
var x = function() {} | имя сразу, тело потом | имя undefined до строки |
var ещё путает тем, что игнорирует блоки:
function bug() {
if (true) {
var x = 1;
}
return x; // 1, var живёт в function scope, не в block scope
}
То же самое с let, не работает:
function fixed() {
if (true) {
let x = 1;
}
return x; // ReferenceError: x is not defined
}
В новом коде var нет вообще. let и const живут в block scope,
поведение интуитивное.
3. Scope chain
Каждый раз, когда JS встречает имя, он смотрит:
- Свой block scope.
- Block scope родителя.
- Дальше вверх до функции.
- Дальше вверх до module / global scope.
Если имя нашлось, всё. Если нет, до глобала, потом ReferenceError.
const a = 'module';
function outer() {
const b = 'outer';
function inner() {
const c = 'inner';
console.log(a, b, c); // 'module', 'outer', 'inner'
}
inner();
}
outer();
Это то, что называется lexical scope: видимость переменных определяется по тексту программы, не по стеку вызовов. Это база замыканий.
4. Замыкания
Замыкание, это функция плюс захваченное окружение. Когда функция возвращается из другой функции, она утаскивает с собой ссылки на переменные внешнего scope.
function makeCounter() {
let n = 0;
return () => {
n += 1;
return n;
};
}
const next = makeCounter();
next(); // 1
next(); // 2
next(); // 3
Здесь n живёт ровно столько, сколько живёт next. Снаружи n
недоступна, есть только функция, которая её знает. Это и есть
приватное состояние через замыкание (до того, как в JS появились
class fields с #).
Захват по ссылке, не по значению. Это та самая ловушка с for-циклом:
// до ES6 это печатало 5 пять раз, потому что i это общая var
for (var i = 0; i < 5; i += 1) {
setTimeout(() => console.log(i), 0); // 5, 5, 5, 5, 5
}
// с let, у каждой итерации свой block scope, отдельный i
for (let i = 0; i < 5; i += 1) {
setTimeout(() => console.log(i), 0); // 0, 1, 2, 3, 4
}
Замыкание захватило i по ссылке. С var все пять колбеков смотрят
на одну и ту же переменную, к моменту срабатывания таймаута её
значение 5. С let, у каждой итерации свой i.
с var
function scopefor (var i = 0; i < 5; i += 1) {
setTimeout(() => console.log(i), 0);
}с let
block scopefor (let i = 0; i < 5; i += 1) {
setTimeout(() => console.log(i), 0);
}
Замыкание держит переменную по ссылке, не по значению. С var
все колбеки смотрят в одну и ту же ячейку: к моменту срабатывания
таймера там уже 5. С let каждая итерация
цикла создаёт свой block scope с собственной i: каждый
колбек захватил свою. Жми шаг, смотри, как стрелки
сходятся в одну точку у var и расходятся по своим у
let.
4.1. Применения замыканий
| Случай | Пример |
|---|---|
| Приватное состояние | makeCounter() выше |
| Мемоизация | memoize(fn) через объект-кэш в замыкании |
| Фабрика обработчиков | (prefix) => (msg) => log(prefix + msg) |
| Частичное применение | (a) => (b) => fn(a, b) |
Подробнее, в уроке 04-fp-style-in-js.
4.2. Утечки
Замыкание удерживает в памяти всё, на что оно ссылается. Если в замыкании “случайно” остался большой объект, его не уберёт GC.
function attach(largeBlob) {
// даже если используем largeBlob.id раз, замыкание держит весь объект
const handler = () => console.log(largeBlob.id);
document.body.addEventListener('click', handler);
return handler;
}
Решение, явный extract в локальную переменную:
function attach(largeBlob) {
const id = largeBlob.id; // вытащили только то, что нужно
const handler = () => console.log(id);
document.body.addEventListener('click', handler);
return handler;
}
V8 в современном виде в простых случаях сам отбрасывает неиспользуемые свойства, но полагаться на это нельзя. Минимизируй захват осознанно, особенно в DOM-обработчиках, таймерах и долгоживущих коллбеках.
Теория про приватное состояние проверяется одной маленькой функцией. Напиши обёртку, которая пускает вызов внутрь ровно один раз, а дальше отдаёт запомненный результат. Тесты гоняются прямо в браузере, ставить ничего не нужно.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.