JavaScript — замыкания, прототипы и модули

JavaScript использует лексическую область видимости, функции как значения и прототипную модель наследования. На этих механизмах основаны замыкания, классы, функции высшего порядка и разные способы организации модулей.


Лексическое окружение

Лексическое окружение связывает выполняемый код с доступными ему переменными. Оно содержит локальные привязки и ссылку на внешнее окружение. Поиск имени начинается в текущей области и продолжается наружу.

const globalMessage = "Глобальная переменная";

function outer() {
  const outerMessage = "Переменная outer";

  function inner() {
    const innerMessage = "Переменная inner";
    console.log(innerMessage, outerMessage, globalMessage);
  }

  inner();
}

outer();

Область видимости функции определяется местом её объявления, а не вызова:

const value = "глобальное";

function showValue() {
  console.log(value);
}

function run() {
  const value = "локальное";
  showValue();
}

run(); // глобальное

Замыкания

Замыкание — функция вместе с лексическим окружением, в котором она создана. Функция сохраняет доступ к внешним переменным даже после завершения внешней функции.

function createCounter() {
  let count = 0;

  return function () {
    count += 1;
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Каждый вызов createCounter() создаёт независимое окружение:

const first = createCounter();
const second = createCounter();

console.log(first());  // 1
console.log(first());  // 2
console.log(second()); // 1

Сокрытие состояния

function createAccount(initialBalance = 0) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Некорректная сумма");
      balance += amount;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
console.log(account.balance);      // undefined

Фабрика функций

function createMultiplier(multiplier) {
  return (value) => value * multiplier;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(10)); // 20
console.log(triple(10)); // 30

Замыкание в цикле

let создаёт отдельную привязку для каждой итерации:

const handlers = [];

for (let i = 0; i < 3; i += 1) {
  handlers.push(() => console.log(i));
}

handlers[0](); // 0
handlers[1](); // 1
handlers[2](); // 2

С var все функции замкнут одну переменную и после цикла выведут 3.

Замыкания применяют для приватного состояния, фабрик функций, обработчиков, кеширования и частичного применения. Замкнутые данные остаются в памяти, пока существует использующая их функция.


this в разных контекстах

У обычной функции this в основном определяется способом вызова.

Метод объекта

const user = {
  name: "Анна",
  greet() {
    return `Привет, ${this.name}!`;
  },
};

console.log(user.greet());

Объект перед точкой становится this. Одна функция может использоваться разными объектами:

const anotherUser = {
  name: "Максим",
  greet: user.greet,
};

console.log(anotherUser.greet()); // Привет, Максим!

Потеря контекста

const greet = user.greet;
greet(); // this не равен user

В строгом режиме при обычном вызове this равен undefined. ES-модули работают в строгом режиме автоматически.

Стрелочная функция

Стрелка не создаёт собственный this, а берёт его из внешней области:

const user = {
  name: "Анна",
  greetLater() {
    setTimeout(() => {
      console.log(this.name);
    }, 1000);
  },
};

Стрелку обычно не используют как метод, если нужен объект перед точкой:

const user = {
  name: "Анна",
  greet: () => console.log(this.name), // this не равен user
};

Конструктор и обработчик

При вызове с new значение this указывает на создаваемый объект:

function User(name) {
  this.name = name;
}

const user = new User("Анна");

В обычном DOM-обработчике this обычно указывает на элемент регистрации. Для ясности можно использовать event.currentTarget:

button.addEventListener("click", (event) => {
  console.log(event.currentTarget);
});
Вызов Значение this
object.method() Объект перед точкой
fn() в строгом режиме undefined
fn.call(value) value
fn.apply(value) value
функция после bind(value) Зафиксированное value
new Constructor() Новый объект
стрелочная функция this внешней области

call, apply, bind

Эти методы явно задают this обычной функции.

call()

Вызывает функцию сразу, аргументы передаются через запятую:

function introduce(city, role) {
  return `${this.name}, ${city}, ${role}`;
}

const user = { name: "Анна" };
console.log(introduce.call(user, "Казань", "разработчик"));

apply()

Вызывает функцию сразу, аргументы передаются массивом:

introduce.apply(user, ["Казань", "разработчик"]);

Для массива аргументов часто удобнее spread:

const numbers = [4, 12, 7];
console.log(Math.max(...numbers)); // 12

bind()

Возвращает новую функцию и не вызывает её сразу:

const boundIntroduce = introduce.bind(user, "Казань");
console.log(boundIntroduce("разработчик"));
Метод Вызывает сразу Аргументы Возвращает
call Да Через запятую Результат функции
apply Да Массивом Результат функции
bind Нет Через запятую Новую функцию

call, apply и bind не меняют лексический this стрелочной функции.


Прототипное наследование

Объект может иметь ссылку на другой объект — прототип. Если свойства нет в самом объекте, JavaScript ищет его по цепочке прототипов.

const animal = { eats: true };
const dog = Object.create(animal);
dog.name = "Бим";

console.log(dog.name); // собственное свойство
console.log(dog.eats); // унаследованное свойство
console.log(Object.getPrototypeOf(dog) === animal); // true

Собственные свойства

console.log(Object.hasOwn(dog, "name")); // true
console.log(Object.hasOwn(dog, "eats")); // false
console.log("eats" in dog);              // true

Object.hasOwn() проверяет только объект, а in — всю цепочку.

Собственное свойство затеняет одноимённое свойство прототипа:

animal.sound = "звук";
dog.sound = "гав";

console.log(dog.sound);    // гав
console.log(animal.sound); // звук

Prototype chain

const livingBeing = { alive: true };
const animal = Object.create(livingBeing);
const dog = Object.create(animal);
dog → animal → livingBeing → Object.prototype → null

Поиск заканчивается при нахождении свойства или при достижении null.

prototype и [[Prototype]]

[[Prototype]] — внутренняя ссылка конкретного объекта. Constructor.prototype — свойство функции-конструктора; этот объект становится прототипом экземпляров, созданных через new.

function User(name) {
  this.name = name;
}

User.prototype.greet = function () {
  return `Привет, ${this.name}!`;
};

const anna = new User("Анна");
const maxim = new User("Максим");

console.log(anna.greet());
console.log(maxim.greet());
console.log(Object.getPrototypeOf(anna) === User.prototype); // true

Метод хранится один раз в прототипе и доступен всем экземплярам.

Оператор new упрощённо:

  1. создаёт объект;
  2. связывает его с Constructor.prototype;
  3. вызывает конструктор с новым объектом как this;
  4. возвращает созданный объект.

instanceof проверяет присутствие прототипа конструктора в цепочке:

console.log(anna instanceof User);   // true
console.log(anna instanceof Object); // true

Не рекомендуется изменять Array.prototype, Object.prototype и другие встроенные прототипы: это создаёт конфликты и неожиданное поведение.


Классы ES6

class — удобный синтаксис поверх прототипной модели.

class User {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return `Привет, ${this.name}!`;
  }
}

const user = new User("Анна");
console.log(user.greet === User.prototype.greet); // true

Класс вызывают с new. Методы экземпляров размещаются в прототипе класса.

Наследование и super

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} издаёт звук`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    return `${super.speak()}, а точнее — лает`;
  }
}

const dog = new Dog("Бим", "корги");
console.log(dog instanceof Animal); // true

В конструкторе дочернего класса нельзя обращаться к this до super().

Статические методы

Статические элементы принадлежат классу, а не экземплярам:

class User {
  static count = 0;

  constructor(name) {
    this.name = name;
    User.count += 1;
  }

  static createGuest() {
    return new User("Гость");
  }
}

const guest = User.createGuest();
console.log(User.count); // 1

Они подходят для фабрик и вспомогательных операций, относящихся к классу в целом.

Приватные поля

Приватные элементы начинаются с # и доступны только внутри класса:

class BankAccount {
  #balance;

  constructor(initialBalance = 0) {
    this.#balance = initialBalance;
  }

  deposit(amount) {
    if (amount <= 0) throw new Error("Некорректная сумма");
    this.#balance += amount;
  }

  get balance() {
    return this.#balance;
  }
}

const account = new BankAccount(1000);
account.deposit(500);
console.log(account.balance); // 1500
// account.#balance;          // SyntaxError

Имя _balance является только соглашением и не обеспечивает приватность.

Метод класса может потерять this при передаче отдельно. Используйте bind() или обёртку:

button.addEventListener("click", user.greet.bind(user));
// или
button.addEventListener("click", () => user.greet());

Модули

Модуль — файл с собственной областью видимости и явным публичным интерфейсом. Модули разделяют программу, скрывают детали реализации и описывают зависимости.

Основные системы — CommonJS и ES Modules.


ES Modules

ES Modules, или ESM, используют export и import.

Файл math.js:

export const PI = 3.14159;

export function add(a, b) {
  return a + b;
}

export default function multiply(a, b) {
  return a * b;
}

Файл app.js:

import multiply, { PI, add } from "./math.js";

console.log(add(2, 3));
console.log(multiply(4, 5));

Виды импорта и экспорта

// переименование
import { add as sum } from "./math.js";

// пространство имён
import * as math from "./math.js";

// реэкспорт
export { formatDate } from "./date.js";
export { default as User } from "./User.js";

Именованных экспортов может быть несколько. default-экспорт в модуле один, а при импорте ему можно дать любое имя.

Импорты являются живыми связями:

// counter.js
export let count = 0;
export const increment = () => { count += 1; };

// app.js
import { count, increment } from "./counter.js";
increment();
console.log(count); // 1

Перезаписать импортированную привязку нельзя.

Динамический импорт

import() возвращает Promise и позволяет загружать код по требованию:

button.addEventListener("click", async () => {
  const { openDialog } = await import("./dialog.js");
  openDialog();
});

В браузере и Node.js

<script type="module" src="app.js"></script>

ES-модули работают в строгом режиме, имеют файловую область видимости и обычно выполняются один раз. В Node.js ESM можно включить через .mjs или package.json:

{
  "type": "module"
}

CommonJS

CommonJS традиционно используется в Node.js. Импорт выполняется через require(), экспорт — через module.exports.

// math.cjs
function add(a, b) {
  return a + b;
}

module.exports = { add };
// app.cjs
const { add } = require("./math.cjs");
console.log(add(2, 3));

Отдельные свойства можно экспортировать через exports:

exports.add = (a, b) => a + b;

Изначально exports ссылается на module.exports, но присваивание нового значения ломает эту связь:

exports = { add };        // не заменяет экспорт модуля
module.exports = { add }; // заменяет экспорт правильно
Характеристика CommonJS ES Modules
Импорт require() import
Экспорт module.exports export
Происхождение Node.js Стандарт JavaScript
Строгий режим Не автоматически Автоматически
Браузерная поддержка Нужны инструменты сборки Встроенная
Типичные расширения .cjs, .js .mjs, .js

В новом браузерном коде обычно используют ESM. В Node.js выбор зависит от проекта. Без необходимости не смешивайте две системы.


IIFE и паттерн модуля

IIFE — функциональное выражение, вызываемое сразу после создания:

(function () {
  const message = "Локальная переменная";
  console.log(message);
})();

(() => {
  console.log("Стрелочная IIFE");
})();

До появления let, const и ESM IIFE часто использовали для изоляции переменных.

Module Pattern

IIFE и замыкание позволяют создать приватное состояние и публичный интерфейс:

const counterModule = (() => {
  let count = 0;

  function increment() {
    count += 1;
  }

  function getValue() {
    return count;
  }

  return { increment, getValue };
})();

counterModule.increment();
console.log(counterModule.getValue()); // 1
console.log(counterModule.count);      // undefined

Современный аналог — ES-модуль:

// counter.js
let count = 0;

export function increment() {
  count += 1;
}

export function getValue() {
  return count;
}

count не экспортируется и остаётся внутренней переменной модуля.


Функции высшего порядка

Функция высшего порядка принимает функции как аргументы, возвращает функцию или делает и то и другое.

function calculate(a, b, operation) {
  return operation(a, b);
}

const add = (a, b) => a + b;
const multiply = (a, b) => a * b;

console.log(calculate(4, 5, add));      // 9
console.log(calculate(4, 5, multiply)); // 20

Методы map, filter, reduce, find, some, every и forEach — функции высшего порядка.

Возврат функции

function createValidator(minLength) {
  return (value) => value.length >= minLength;
}

const isValidPassword = createValidator(8);
console.log(isValidPassword("secret123")); // true

Композиция

function pipe(...functions) {
  return (initialValue) =>
    functions.reduce((value, fn) => fn(value), initialValue);
}

const createSlug = pipe(
  (value) => value.trim(),
  (value) => value.toLowerCase(),
  (value) => value.replaceAll(" ", "-")
);

console.log(createSlug("  JavaScript Modules  "));
// javascript-modules

Декоратор функции

function withLogging(fn) {
  return function (...args) {
    console.log("Аргументы:", args);
    const result = fn.apply(this, args);
    console.log("Результат:", result);
    return result;
  };
}

const loggedAdd = withLogging((a, b) => a + b);
loggedAdd(2, 3);

apply(this, args) сохраняет контекст и передаёт все аргументы исходной функции.


Частые ошибки

Стрелка как метод с динамическим this

const user = {
  name: "Анна",
  greet: () => console.log(this.name), // неверное ожидание this
};

Используйте сокращённый метод greet() { ... }.

Передача метода без объекта

setTimeout(user.greet, 1000); // контекст потерян
setTimeout(user.greet.bind(user), 1000); // исправление

Путаница прототипов

function User() {}
const user = new User();

console.log(User.prototype === Object.getPrototypeOf(user)); // true

User.prototype — свойство конструктора; внутренний прототип принадлежит экземпляру.

Циклические зависимости

Если a.js импортирует b.js, а b.js импортирует a.js, могут появиться частично инициализированные значения. Общую логику лучше вынести в третий модуль.

Удержание лишних данных замыканием

Замыкание может сохранять большой объект или удалённый DOM-элемент. Удаляйте ненужные обработчики и ссылки, чтобы сборщик мусора мог освободить память.


Общий пример

Файл Task.js:

export class Task {
  static #nextId = 1;
  #completed = false;

  constructor(title) {
    if (!title.trim()) throw new Error("Название обязательно");
    this.id = Task.#nextId++;
    this.title = title.trim();
  }

  complete() {
    this.#completed = true;
  }

  get completed() {
    return this.#completed;
  }

  toJSON() {
    return {
      id: this.id,
      title: this.title,
      completed: this.#completed,
    };
  }
}

Файл PriorityTask.js:

import { Task } from "./Task.js";

export class PriorityTask extends Task {
  constructor(title, priority = "normal") {
    super(title);
    this.priority = priority;
  }

  toJSON() {
    return { ...super.toJSON(), priority: this.priority };
  }
}

Файл taskStore.js:

const tasks = [];

export const addTask = (task) => tasks.push(task);

export const getTasks = (predicate = () => true) =>
  tasks.filter(predicate).map((task) => task.toJSON());

export function createCompletionCounter() {
  let count = 0;

  return (task) => {
    if (!task.completed) {
      task.complete();
      count += 1;
    }
    return count;
  };
}

Файл app.js:

import { Task } from "./Task.js";
import { PriorityTask } from "./PriorityTask.js";
import { addTask, getTasks, createCompletionCounter } from "./taskStore.js";

const registerCompletion = createCompletionCounter();
const first = new Task("Изучить модули");
const second = new PriorityTask("Повторить прототипы", "high");

addTask(first);
addTask(second);
registerCompletion(first);

console.log(getTasks((task) => task.completed));

Здесь используются приватные поля, наследование, super, ES-модули, функция-предикат и замыкание со счётчиком.


Краткие рекомендации