DEV Community

Anna
Anna

Posted on

this не магия: практическое руководство по контексту выполнения в JavaScript

Если бы у JavaScript был конкурс на самую запутанную концепцию, то первое место уверенно заняло ключевое слово this, его поведение на первый взгляд кажется непредсказуемым, но на самом деле это не так.

А причина такой путаницы в том, что this в отличии от других переменных не имеет жёсткой привязки, его значение нельзя посмотреть в момент объявления функции, ведь оно определяется в момент вызова функции. Даже если мы будем сравнивать контекст в обычных и стрелочных функциях результат может отличаться.

Самая лучшая аналогия про this - это аналогия со словом "я". Значение этого слова полностью зависит от того, кто его произносит. Если скажете вы, то "я" - это вы, если ваш друг, то это он и так далее. Также и с контекстом, this - это слово "я" внутри функции.

Основные проблемы, возникающие с this

  1. Один и тот же код, но разные результаты
  2. Возможность возникновения потери контекста
  3. Кажущаяся сложность

Цель данной статьи - разобрать правила определения this и убрать страх перед использованием контекста.


Что такое this?

this - это ключевое слово, которое пришло в JavaScript для того, чтобы помочь в реализации объектно-ориентированного программирования. Если говорить проще, то this - это ссылка на некий объект, к свойствам которого можно обратиться внутри вызова функции. this также называют контекстом выполнения.
Это специальная переменная, которая автоматически создается внутри каждой функции(за некоторым исключением, о котором мы поговорим позже) в момент её выполнения.

Ключевая характеристика контекста вызова - это её динамичность, то есть значение this определяется в момент вызова функции, а не в момент создания. Это является фундаментальным отличием между контекстом вызова и переменными, которые имеют область видимости


Режим исполнения и глобальный объект

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

Разберемся что такого глобальный объект.
Глобальный объект в JavaScript - это корневой объект, переменные и функции которого доступны в любом месте программы. Данный объект представляет из себя глобальную область видимости, если мы говорим про frontend разработку, то в браузере глобальный объект - это window.

Мы разобрались, что такое глобальный объект, теперь разберемся, для чего нам это нужно. Когда скрипт выполняется, он может быть вызван в одном из двух режимов:

  1. Нестрогий(режим по умолчанию)
  2. Строгий(use strict)
    Данные режимы определяют поведение контекста при вызове функций.

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

function checkThis() {
  console.log(this);
}

checkThis();// В браузере будет выведен глобальный объект window
Enter fullscreen mode Exit fullscreen mode

Если мы захотим изменить свойства объекта через this, то это может привести к ошибкам и мы можем изменить свойства глобального объекта, поэтому чаще всего используют строгий режим.

  1. Если функция будет вызвана, как независимая функция, но в строгом режиме, то ответ будет отличаться, значение this в данной функции будет становиться undefined:
'use strict'; // Включение строгого режима

function checkThisStrict() {
  console.log(this);
}

checkThisStrict();// В результате будет undefined
Enter fullscreen mode Exit fullscreen mode

Это более безопасное поведение, обращение к полям такого объекта вызовет ошибку, которую будет легко обнаружить.

Данное поведение является базовым, все остальные правила - это способ переопределить поведение и явно указать функции, на что должно ссылаться ключевое слово this.


Основных правила определения this

Понимание контекста вызова строится на четырех основным правилах, по которым компилятор определяет значение this. То есть, чтобы найти контекст вызова необходимо последовательно проверять условия каждого правила.

Правило 1: Простой вызов (Default Binding)
Функция, которая вызывается сама по себе, без каких-либо дополнений. Данное поведение мы рассмотрели выше, поведение в строгом и нестрогом режимах.

Важно: это правило срабатывает чаще всего там, где мы не ожидаем, особенно при работе с callback.

Рассмотрим пример с использованием "голых" функций:

function foo(){
    var bar = "local";
    console.log(this.bar);
} 
var bar = "global";

foo();  // global
Enter fullscreen mode Exit fullscreen mode

Разберемся, почему конечный вывод именно такой. Как известно, переменная, объявленная с помощью var попадает в глобальную область видимости, именно поэтому, когда мы вызываем независимую функцию в нестрогом режиме, то в нее попадает значение, хранящееся в глобальной области видимости (window.bar).

Пример с callback мы разберем позже, чтобы лучше понять материал.

Правило 2: Вызов как метода объекта (Implicit Binding)
Функция, которая вызывается как метод объекта, то есть через точку или квадратные скобки. В данном случае this будет ссылаться на сам объект, который стоит перед точкой в момент вызова.
Рассмотрим пример:

const user = {
  name: 'Анна',
  logName() {
    console.log(this.name);
  }
};

user.logName(); // Выведет "Анна"
Enter fullscreen mode Exit fullscreen mode

Мы создали объект, внутри которого прописали функцию вывода имени пользователя. Как мы уже сказали выше, контекст вызова будет ссылаться на объект перед точкой, как мы видим из строчки user.logName();, объект перед точкой - это user, значит this = user.

Важно: this определяется в момент вызова, а не в момент создания.

На основе этой информации рассмотрим еще несколько примеров:

Пример №1

const user = {
  name: 'Анна',
  logName() {
    console.log(this.name);
  }
};

// Создаем новую переменную, которая ссылается на функцию logName
const logNameFunction = user.logName;

// Вызываем функцию без объекта перед точкой.
logNameFunction(); // undefined или ''
Enter fullscreen mode Exit fullscreen mode

В результате мы получим не совсем тот результат, который мы ожидали, разберемся, почему это происходит.
Мы создали объект точно также, как и в предыдущем примере, но при этом результат получило совершенно иной. Это связано с тем, как мы вызываем метод объекта, в данном случае, мы функцию кладем в переменную logNameFunction, это действие эквивалентно следующему коду:

function logNameFunction () {
    console.log(this.name);
}
Enter fullscreen mode Exit fullscreen mode

Поэтому, когда мы вызываем данную функцию, она действует по правилу 1, то есть ссылается на глобальный объект.

Пример №2

const user = {
  name: 'Анна',
  logName() {
    console.log(this.name);
  }
};

// Правило 2: вызов как метода объекта. Сработает.
user.logName(); // Анна

// А теперь передадим метод как колбэк
setTimeout(user.logName, 1000); // undefined или ''
Enter fullscreen mode Exit fullscreen mode

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

setTimeout(function() {user.greet()}, 1000);// Анна
Enter fullscreen mode Exit fullscreen mode

При такой записи мы не теряем контекст за счет того, что вызов происходит сразу, когда this ссылается на объект user. Есть и другие способы решения этой проблемы, рассмотрим их позже.

В рассмотренных выше примерах показывается самая частая проблема при работе с контекстом - потеря контекста, когда this не указывает на объект, откуда функция была взята.

Правило 3: Явное указание контекста (Explicit Binding)
В JavaScript мы можем явно указать какой объект должен использоваться в качестве контекста вызова. Для этих целей методы call, apply, bind.

Методы call и apply очень похожи, их разница в том, как мы передаем аргументы в функцию, поэтому рассмотрим их в связке.
Данные методы позволяют вызывать функции в контексте других объектов, иными словами, мы явно указываем контекст вызова this. Особенностью данных методов является то, что они немедленно вызывают функцию.
Синтаксис:
func.call(this, arg1, arg2, ...)
func.apply(this, [arg1, arg2, ...])
Как можно заметить, разница данных методов в синтаксе, а именно в том, как передаются аргументы в функцию.

Метод bind очень похож на предшественников, но его отличие в том, что данный метод НЕ вызывает функцию сразу, он возвращает новую функцию, которая будет привязана к необходимому контексту.
Синтаксис:
func.bind(this, arg1, arg2, ...)

Теперь мы можем дать еще одно решение для проблемы потери контекста из примера №2.

setTimeout(user.logName.bind(user), 1000); // Анна
Enter fullscreen mode Exit fullscreen mode

В данном случае, мы привязали объект, поэтому в момент вызова функции, контекст вызова будет равен объекту user, что решает нашу проблему.

Правило 4: Вызов с оператором new (New Binding)
В JavaScript можно создавать функции-конструктор с помощью оператора new.
Функция-конструктор - это специальная функция, предназначенная для создания объектов. Чаще всего функции-конструкторы применяют для создания объектов по шаблону. Внутри такой функции this будет ссылаться на новосозданный пустой объект.

Рассмотрим пример создания функции-конструктора:

function User(name, age) {
  // В данный момент this = {}
  this.name = name;
  this.age = age;
  this.isAdmin = false;
}

const userAnna= new User('Анна', 25);
console.log(userAnna.name); // 'Анна'
console.log(userAnna.age); // 25
Enter fullscreen mode Exit fullscreen mode

При этом, в случае, если мы неправильно создадим объект, то есть без оператора new, то поведение будет совсем иное, тогда контекст вызова будет работать по правилу 1 и ссылаться на глобальный объект.

Важно: всегда использовать new с функциями-конструкторами.

Приоритет правил
Может случиться так, что к this подходят сразу несколько правил, на этот случай в JavaScript есть приоритет правил, рассмотрим от высшего приоритета к низшему.

  1. new (Правило 4) имеет самый высокий приоритет, в функции-конструкторе this - это всегда новый объект.
  2. Явная привязка (Правило 3), когда привязываем контекст с помощью call, apply, bind.
  3. Неявная привязка (Правило 2), вызов функции через метод объекта.
  4. Простой вызов (Правило 1) имеет самый низкий приоритет, контекст ссылается на глобальный объект, зависит от строгого или нестрогого режима.

Рассмотрим пример:

const obj = { 
  name: 'Анна',
  getName() {
    console.log(this.name)
  }
};
function foo() {
  console.log( this.name);
}
// Правило 1
foo(); // undefined

// Правило 2
obj.getName(); // "Анна"

// Правило 3
const boundFoo = foo.bind(obj);
boundFoo(); // "Анна"

// Правило 4
const newObj = new boundFoo(); // undefined
Enter fullscreen mode Exit fullscreen mode

На данном примере хорошо видна приоритезация между правилами 3 и 4. Почему в последней строке выводится undefined, хотя контекст явно привязан через метод bind? Как и говорилось выше, при использовании new контекст вызова - это пустой массив, то есть this = {}, внутри этого объекта нет свойства name, именно поэтому мы и получаем такой ответ.

Мы рассмотрели все правила и их приоритет, теперь определять, чему равен this намного проще.


Особое поведение: Стрелочные функции

Стрелочные функции ведут себя иначе с точки зрения контекста вызова по сравнению с обычными функциями. Главное правило стрелочной функции - это то, что у нее нет своего this.

Внутри стрелочной функции контекст берется извне, то есть она не создает свой контекст исполнения, а ссылает на объект ближайший по иерархии. Это называется "лексический this".

Если говорить проще, то this внутри стрелочной функции равно this снаружи её.

Рассмотрим пример использования контекста со стрелочной функцией и когда это будет полезно:

const user = {
  name: 'Анна',
  greet: function() { 
    setTimeout(function() {
      console.log('Обычная функция:', this.name); // undefined или ''
    }, 100);

    setTimeout(() => {
      console.log('Стрелочная функция:', this.name); // 'Анна'
    }, 200);
  }
};

user.greet();
Enter fullscreen mode Exit fullscreen mode

При вызове через обычную функцию, у нас произойдет потеря контекста, так как функция вызывается сама по себе позже и подчиняется правилу 1, обращаясь к глобальному объекту. Вызов через стрелочную функцию решит данную проблему, потому что у нее нет своего контекста, и она обращается к внешнему this из функции greet, а внутри нее по правилу 2 контекст равен user, стрелочная функция "запомнила" это значение и использует его всегда, когда бы не вызвали.

Важно: к стрелочным функциям нельзя применять методы call, apply и bind, this внутри таких функций зафиксирован и не меняется.

Важно: стрелочные функции не могут быть конструкторами, вызов функции с new выбросит ошибку.

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


this в обработчиках событий DOM

Ключевая задача frontend разработчика - это работа с DOM деревом, в JavaScript также есть правила работы с контекстом в обработчиках событий.

Стандартное поведение - обычные функции обработчики
Когда мы создаем прослушиватель событий и назначает обычную функцию обработчик, то в данном случае this ссылается на DOM-элемент, на котором событие было вызвано.
При вызове addEventListener он неявно привязывает контекст вызова к элементу, на котором случилось событие.

Рассмотрим пример:

const button = document.getElementById('myBtn');

button.addEventListener('click', function() {
  // this внутри этой обычной функции будет указывать на элемент button
  console.log(this); // Выведет: <button id="myBtn">Нажми меня</button>
  console.log(this.id); // Выведет: 'myBtn'
  console.log(this.textContent); // Выведет: 'Нажми меня'

  this.style.backgroundColor = 'red'; // Можно напрямую менять стили элемента
});
Enter fullscreen mode Exit fullscreen mode

Данное поведение удобно, потому что дает прямой доступ к элементу, с которым происходит взаимодействие.

Исключение - стрелочные функции обработчики
Если использовать стрелочную функцию в качестве обработчика, то поведение с this меняется. Внутри стрелочной функции-обработчика контекст this будет равен контексту той функции, внутри которой она была вызвана.

Пример с кнопкой и стрелочной функцией:

const button = document.getElementById('myBtn');

button.addEventListener('click', () => {
  console.log(this); // window или undefined

  this.style.backgroundColor = 'red';// строка вызовет ошибку в строгом режиме
});
Enter fullscreen mode Exit fullscreen mode

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


Специальные случаи и тонкости

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

this в методах массива (forEach, map, filter и т.д.)
У методов массивов, которые принимают callback функции, например: forEach, map, filter, some, every и прочие, есть второй параметр, который является необязательным. Это объект, который будет использоваться в качестве this, то есть можно сказать, что это аналог явной привязки с помощью call, apply, bind.
Синтаксис:

array.method(callback, thisArg)
Enter fullscreen mode Exit fullscreen mode

Рассмотрим пример с методом map:

const validator = {
  min: 10,
  max: 20,
  validate(value) {
    return value >= this.min && value <= this.max;
  }
};

const numbers = [5, 15, 25, 35];

const resultBad = numbers.map(validator.validate);
console.log(resultBad); // [false, false, false, false] 

const resultGood = numbers.map(validator.validate, validator);// [false, true, false, false] 
console.log(resultGood);
Enter fullscreen mode Exit fullscreen mode

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

Альтернативным способом является переписать callback функцию внутри метода, чтобы this ссылалось на верные данные. Пример реализации:

const resultBad = numbers.map((item) => (validator.validate(item)));
Enter fullscreen mode Exit fullscreen mode

this в конструкторах встроенных объектов
Многие встроенные в JavaScript конструкторы, например, такие как Promise, Set, Map, Date имеют особенности при работе с контекстом вызова, они сами управляют контекстом и внутри них он обычно равен undefined(в строгом режиме).


Итог: Алгоритм определения this для любой функции

Подведем итог и выявим алгоритм для определения контекста.

Задаем вопросы по порядку, как только условие шага выполняется, то ответ найден.

  1. Функция вызвана с помощью оператора new?

ДА --> Значит this ссылается на пустой только что созданный объект.
НЕТ --> Переходим к следующему шагу

  1. Функция вызвана с помощью методов call, apply, bind?

ДА --> Значит this ссылается на объект переданный в метод.
НЕТ --> Переходим к следующему шагу

  1. Функция вызвана как метод объекта?

ДА --> Значит this ссылается на объект перед точкой.
НЕТ --> Переходим к следующему шагу

  1. Это стрелочная функция?

ДА --> Значит this ссылается на контекст, который находится выше по иерархии на момент ее объявления.
НЕТ --> Переходим к следующему шагу

  1. В иных случаях - это простой вызов функции

Значит this ссылается на глобальный объект, если это нестрогий режим и undefined, если строгий режим.
Простая блок схема для определения значения this.

Блок схема поиска this


Заключение

Тема контекста this в JavaScript является одной из самых сложных, но после изучения всех тонкостей поведение контекста становится предсказуемым.

Ключевые выводы, которые можно сделать по этой теме:

  1. Контекст определяется в момент вызова функции, а не в момент объявления.
  2. Существует два координально разных типа функций с точки зрения контекста: обычные и стрелочные функции.
  3. Необходимо подбирать инструменты под определенные задачи.
  4. Существует проблема потери контекста, бороться с ней можно, при этом важно понимать причину.

this понятен. Можно идти дальше :)

Top comments (0)