Раздел 3 · JavaScript

Функции, scope, замыкания

junior~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Функции, 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.

ФормаHoistingthisargumentsМожно 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 встречает имя, он смотрит:

  1. Свой block scope.
  2. Block scope родителя.
  3. Дальше вверх до функции.
  4. Дальше вверх до 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.

Closure capture: idle

с var

function scope
for (var i = 0; i < 5; i += 1) {
  setTimeout(() => console.log(i), 0);
}
scope
i--

с let

block scope
for (let i = 0; i < 5; i += 1) {
  setTimeout(() => console.log(i), 0);
}
scopes

Замыкание держит переменную по ссылке, не по значению. С 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.