Перейти к содержимому

this и контекст вызова

Если замыкания — самая переоценённая «магия» JS у новичков, то this — самая недооценённая. Пока код пишется в объектном стиле, всё выглядит просто: метод — и this — объект. Но стоит передать метод в колбэк, и this «ломается». На собеседованиях это вопрос №1 по языку, а в продакшене — источник багов, которые воспроизводятся только «иногда».

Хорошая новость: механика this предельно детерминирована. В этой главе — пять правил в порядке приоритета, явная привязка, стрелочные функции и то, почему методы классов приходится «биндить».

Главный принцип: this определяется местом ВЫЗОВА

Заголовок раздела «Главный принцип: this определяется местом ВЫЗОВА»

Всё, что нужно запомнить: this не имеет отношения к тому, где функция написана. Он зависит исключительно от того, КАК её вызвали. Одна и та же функция может получить разный this при разных вызовах:

function whoAmI() {
console.log(this);
}
whoAmI(); // глобальный объект (или undefined в strict mode)
whoAmI.call({ name: 'объект' }); // { name: 'объект' }
const obj = { method: whoAmI };
obj.method(); // obj — вызван как метод

Теперь пять правил в порядке убывания приоритета. Движок применяет первое подходящее.

Если при вызове используется fn.call(ctx), fn.apply(ctx, args) или fn.bind(ctx), this — переданный контекст. Приоритет выше всех остальных правил.

function introduce(city, hobby) {
console.log(`${this.name} из ${city}, увлечение: ${hobby}`);
}
const person = { name: 'Влад' };
// call — аргументы списком
introduce.call(person, 'Ростов', 'DevOps');
// apply — аргументы массивом (полезно, когда аргументы уже в массиве)
introduce.apply(person, ['Ростов', 'DevOps']);
// bind — НЕ вызывает, возвращает новую функцию с «прошитым» this
const introduceVlad = introduce.bind(person);
introduceVlad('Москва', 'код');
// bind частичное применение: часть аргументов можно зафиксировать
const introduceVladRnd = introduce.bind(person, 'Ростов');
introduceVladRnd('код'); // «Влад из Ростов, увлечение: код»

call и apply эквивалентны, различаются только способом передачи аргументов. bind возвращает новую функцию — это важно для колбэков.

Правило 2: Неявная привязка — вызов метода

Заголовок раздела «Правило 2: Неявная привязка — вызов метода»

Если функция вызвана «через точку» — obj.method()this — объект перед точкой:

const user = {
name: 'Аня',
greet() {
console.log(`Привет, ${this.name}`);
},
};
user.greet(); // «Привет, Аня» — this === user
const greet = user.greet; // ссылку на функцию можно скопировать!
greet(); // «Привет, undefined» — вызов уже НЕ методом, правило не сработало

Последний случай — потеря контекста: функция та же, но способ вызова изменился. Это и есть главный баг-генератор. Объект перед точкой исчез, движок переходит к следующим правилам.

Если функция вызвана через new Fn(), создаётся пустой объект, он связывается с this, и, если функция не вернула свой объект, возвращается этот новый объект:

function User(name) {
// 1. создаётся this = {}
// 2. prototype этого this ссылается на User.prototype
this.name = name; // 3. инициализация
// 4. return this (неявный)
}
const u = new User('Игорь');
console.log(u.name); // «Игорь»

Правило 4: Дефолтное — глобальный объект или undefined

Заголовок раздела «Правило 4: Дефолтное — глобальный объект или undefined»

Если ни одно правило не сработало (обычный вызов fn()), в нестрогом режиме this — глобальный объект (window в браузере, globalThis в Node.js). В строгом режиме ('use strict') — undefined:

function loose() { return this; }
function strict() { 'use strict'; return this; }
console.log(loose()); // глобальный объект
console.log(strict()); // undefined
// Вот почему в strict mode этот код бросает TypeError, а не молча читает window:
// function f() { 'use strict'; console.log(this.name); }
// f(); // TypeError: Cannot read properties of undefined

Модули (ES-модули) всегда в строгом режиме — поэтому в современном коде «молчаливый глобальный this» почти не встречается.

Правило 5: Лексический this — стрелочные функции

Заголовок раздела «Правило 5: Лексический this — стрелочные функции»

Стрелочные функции не имеют собственного this. Они захватывают this из окружения, где были созданы — как обычную переменную через замыкание. Это единственное исключение из правила «this зависит от вызова»:

const team = {
name: 'Platform',
members: ['Аня', 'Борис'],
list() {
// Стрелка внутри метода: this взял из list() → team
this.members.forEach((m) => {
console.log(`${m} из команды ${this.name}`);
});
},
};
team.list();
// «Аня из команды Platform», «Борис из команды Platform»
// С обычной функцией тот же код сломался бы:
// forEach(function (m) { ... this.name ... }) — this был бы undefined (strict)

Стрелочные функции нельзя использовать как конструктор (new () => {} — SyntaxError) и нельзя перепривязать: bind/call/apply на них не влияют на this (хотя аргументы передать можно):

const arrow = () => console.log(this);
arrow.call({ a: 1 }); // не { a: 1 }! this взят из внешнего окружения

Потеря контекста в колбэках: классика багов

Заголовок раздела «Потеря контекста в колбэках: классика багов»

Паттерн повторяется бесконечно: метод класса передаётся как обработчик, this теряется:

class Player {
constructor(name) {
this.name = name;
}
attack() {
console.log(`${this.name} атакует!`);
}
}
const player = new Player('Геральт');
// Передаём МЕТОД, а не вызов. При клике this будет кнопкой (или undefined)
document.querySelector('#attack').addEventListener('click', player.attack);

Четыре способа лечения:

// 1. Стрелочный колбэк — this берётся из окружения subscribe
button.addEventListener('click', () => player.attack());
// 2. bind при передаче — создаётся новая привязанная функция
button.addEventListener('click', player.attack.bind(player));
// 3. bind в конструкторе — классика React до появления полей классов
class Player {
constructor(name) {
this.name = name;
this.attack = this.attack.bind(this); // attack всегда «свой»
}
attack() {
console.log(`${this.name} атакует!`);
}
}
// 4. Поле класса со стрелкой (современный синтаксис, babel/TS поддерживают)
class Player {
name = 'Геральт';
attack = () => {
console.log(`${this.name} атакует!`); // this — экземпляр, всегда
};
}

Варианты 3 и 4 — «привязка раз и навсегда»: независимо от того, куда передали метод, this останется экземпляром.

Приоритеты правил на практике: конфликтные случаи

Заголовок раздела «Приоритеты правил на практике: конфликтные случаи»

Правила перечислены по приоритету, но что, если два применимы одновременно? Разберём два частых конфликта.

Явная привязка против конструктора. bind выигрывает у new:

function Point(x) { this.x = x; }
const BoundPoint = Point.bind({ x: 'захвачен' });
const p = new BoundPoint(5);
console.log(p.x); // 5 — new создал СВЕЖИЙ объект, bind-контекст проигнорирован

Это редкий случай, но он подтверждает приоритет: конструкторский this (новый объект) важнее захваченного. Спецификация это описывает через [[BoundThis]] игнорирование при конструктивном вызове.

Стрелка против всего. Стрелки игнорируют и call, и new, и точку — их this зафиксирован при создании:

const arrow = () => this;
console.log(arrow.call({ a: 1 }) === arrow.apply({ b: 2 })); // true — всегда один и тот же this
// new arrow(); // SyntaxError: arrow is not a constructor

Отсюда практический вывод для фреймворков: колбэки, определённые стрелками в методах класса, нельзя переопределить извне. Если нужна возможность перепривязки — обычная функция.

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

const userA = { name: 'Аня', greet() { return `Я ${this.name}`; } };
const userB = { name: 'Борис' };
const greetA = userA.greet.bind(userA);
const greetB = userA.greet.bind(userB);
console.log(greetA()); // «Я Аня»
console.log(greetB()); // «Я Борис» — та же функция, другой контекст
console.log(userA.greet()); // «Я Аня» — оригинал не пострадал

this и возвращаемые объекты: тонкость конструктора

Заголовок раздела «this и возвращаемые объекты: тонкость конструктора»

Правило 3 (конструктор) имеет исключение: если конструктор явно возвращает объект, new вернёт его вместо свежесозданного this. Возврат примитива игнорируется:

function Custom() {
this.plain = true;
return { replaced: true }; // объект побеждает this
}
console.log(new Custom().replaced); // true
console.log(new Custom().plain); // undefined
function Primitive() {
this.kept = true;
return 'строка игнорируется'; // примитив — не в счёт
}
console.log(new Primitive().kept); // true

Это легализует паттерн «конструктор-фабрика» (возвращаем кэшированный экземпляр или подтип), но в классах ES6 return объекта запрещён (new Class с return {} бросает TypeError) — классы строже функций-конструкторов.

DOM задаёт собственные правила: обработчик события вызывается браузером с this === текущий элемент (если это обычная функция):

button.addEventListener('click', function () {
console.log(this === button); // true — this = элемент-цель
console.log(this.textContent); // текст кнопки
});
// Стрелка — this из внешнего окружения, НЕ элемент:
button.addEventListener('click', () => {
console.log(this); // window/undefined, а не button!
});

Это часто сюрприз в две стороны: классическая функция получает «лишний» контекст (кнопку вместо своего объекта), стрелка — теряет его. Отсюда практика фреймворков: React синтетические события вообще не полагаются на this DOM-элемента (там своя система компонентов), а jQuery-эпоха строила целые API на $(this).

В современном коде правило простое: обработчики — стрелки или привязанные методы, а DOM-элемент бери из event.currentTarget, а не из this:

button.addEventListener('click', (event) => {
const btn = event.currentTarget; // явно, независимо от вида функции
btn.disabled = true;
});

event.currentTarget — элемент, на котором сработал слушатель; event.target — элемент, где событие возникло (может быть вложенным). Путать их — частая причина «не тот this/не тот элемент».

bind против стрелок: какую привязку выбрать

Заголовок раздела «bind против стрелок: какую привязку выбрать»

Оба инструмента решают одну задачу — стабильный контекст. Различия в деталях, которые важны при проектировании API:

class Emitter {
constructor() {
this.events = new Map();
// Привязка в конструкторе: метод можно сравнивать по ссылке
this.dispatch = this.dispatch.bind(this);
}
dispatch(event, payload) { /* ... */ }
}
// Экземплярная стрелка: НОВАЯ функция на каждый экземпляр,
// между собой не равны даже у одного класса
class Button {
handleClick = () => { /* ... */ };
}
const b = new Button();
const c = new Button();
console.log(b.handleClick === c.handleClick); // false — каждому экземпляру своя

Различия в таблице:

Критерий bind в конструкторе Поле-стрелка класса
Функция одна на все экземпляры своя на каждый экземпляр
Сравнение по ссылке a.dispatch === b.dispatch — true false
Наследование переопределяется вручную наследуется как поле
Подходит для методов, идентичность важна обработчиков, интроспекция

Когда сравнение по ссылке важно? Отписка от событий (removeEventListener требует ту же функцию), мемоизация методов, тесты с mock-функциями. В этих случаях bind предпочтительнее: одна стабильная функция на класс.

Когда без разницы — короткие обработчики в компонентах — поле-стрелка читается лучше: вся логика рядом с полями, без конструкторной церемонии.

Есть и третий путь — не привязывать вообще, а передавать вызов: onClick={() => btn.handleClick()}. Минус: новая функция-обёртка на каждый рендер (в React — лишние ре-рендеры детей, если они мемоизированы). Для критичных мест — привязка, для остальных — обёртки допустимы.

Привязка методов классов: почему это вообще нужно

Заголовок раздела «Привязка методов классов: почему это вообще нужно»

В других языках (Java, C#) методы привязаны к объекту автоматически — ссылка на метод и есть «метод объекта». В JS функция — самостоятельное значение, и метод — лишь «функция, вызванная через точку». Это даёт гибкость (переиспользование, высшие функции), но ценой обязанности следить за this.

Правила гигиены:

  • Публичные методы, передаваемые наружу (обработчики, колбэки API), привязывай в конструкторе или делай полями-стрелками.
  • Внутренние вызовы this.helper() внутри класса — безопасны: вызов методом.
  • Библиотечные функции высшего порядка (forEach, addEventListener, промисы) обычно вызывают колбэки «голыми» — будь готов.
  1. const f = obj.method; f() — потеря контекста. Лечится bind, стрелкой при передаче или полем-стрелкой.
  2. Вложенная обычная функция внутри метода теряет this. В строгом режиме — undefined. Лечится стрелкой или сохранением const self = this (устаревший стиль).
  3. Ожидать, что bind изменит существующую функцию. bind возвращает новую; исходная не меняется.
  4. Стрелка как метод объекта или прототипа. this будет не тем объектом. Методы — обычные функции.
  5. Отладка через console.log(this) внутри колбэка, где ожидался объект. В строгом режиме это undefined, а не объект перед точкой где-то в другом месте. Проверяй способ вызова, а не место определения.
  6. Двойная привязка в наследовании: super.method() в стрелке теряет this подкласса — стрелка захватывает this лексически, а super ищется по [[HomeObject]], тонкости есть, но на практике: методы с super — обычные функции.
  1. Чем определяется значение this? Только способом вызова. Пять правил по приоритету: явная привязка (call/apply/bind) → метод (точка) → конструктор (new) → дефолт (глобал/undefined) → лексический (стрелки не имеют своего this).
  2. Разница между call, apply, bind? call(ctx, ...args) и apply(ctx, argsArray) — немедленный вызов, различие в передаче аргументов. bind(ctx, ...args) — возвращает новую функцию без вызова.
  3. Почему стрелочные функции нельзя использовать как методы? У них нет собственного this — он захватывается из внешнего лексического окружения, а не из объекта перед точкой.
  4. Как исправить потерю контекста при передаче метода в колбэк? Назови три способа. Стрелка-обёртка при передаче, bind при передаче, привязка в конструкторе (или поле-стрелка класса).
  5. Что выведет код?
    const obj = { n: 1, getN: function () { return this.n; } };
    const fn = obj.getN;
    console.log(obj.getN(), fn.call(obj), fn());
    1 1 undefined (strict) — первые два вызова методом/через call, третий — потеря контекста.
  6. Можно ли перепривязать this у стрелочной функции через bind? Нет. bind у стрелки влияет только на аргументы (при частичном применении), this остаётся лексическим.
  1. Напиши полифил myBind(fn, ctx, ...presetArgs): возвращает функцию, при вызове которой fn выполняется с this === ctx и аргументами presetArgs + новыми. Критерий: проверь с конструктором (new) — корректный полифил должен сохранять поведение new (возможно упрощённо, верни осознанное решение).
  2. Реализуй pipe(...fns) — композицию функций слева направо через reduce. Критерий: pipe(add, square)(2) === square(add(2)); сделай так, чтобы внутри каждой функции this пробрасывался (используй fn.call(this, ...)).
  3. Найди и исправь баг: в классе Timer метод tick передаётся в setInterval и падает с TypeError (чтение this.seconds). Исправь тремя способами из главы.
  4. Напиши memoize(fn, ctx): мемоизатор, который при повторном вызове возвращает закэшированный результат, а this внутри fn — всегда переданный ctx. Критерий: метод объекта, обёрнутый в memoize, может передаваться куда угодно без потери контекста.
  5. Создай иерархию: class Animal с методом describe() (использует this.name), class Dog extends Animal с describe через super. Проверь, что при передаче dog.describe в колбэк и привязке контекст сохраняется, и объясни, где именно движок берёт super-метод (по [[HomeObject]]).