Магический this

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

Мы используем this так же, как местоимение в языках подобных английскому или русскому. Не всегда называем имя или предмет, а говорим применительно к нему «этот». То же самое происходит при использовании ключевого слова this в языках программирования. This всегда является ссылкой на свойство объекта, но что это за объект зависит от контекста.

var Person = {
  firstName: "Jack",
  lastName:  "Sparrow",

  fullName: function() {
    // this означает "этот"
    return this.firstName + ' ' + this.lastName;
    // аналогично такой записи:
    return Person.firstName + ' ' + Person.lastName;
  }
};

console.log(Person.fullName());
//-> Jack Sparrow

почему this

Если вместо this обращаться к объекту через его имя (Person), мы рискуем вызвать метод совсем не того объекта. Ведь код может быть написан не одним человеком. Вы уверены, что в скриптах, которые писали не вы, не будет глобальной переменной с таким же именем?

Объекты, как и функции, имеют свойства. Когда функция выполняется, она получает свойство this того объекта, который её вызвал. This обычно используется внутри функции, но при вызове в глобальной области видимости его значение будет зависеть от окружения. Например, для браузера глобальным объектом будет window, и код console.log(this.innerHeight); вернёт высоту окна.

При использовании строгого режима (strict mode) это не сработает: this не будет связан с глобальным объектом и код вызовет ошибку. Такое же поведение ожидаемо с анонимными функциями, ведь они не привязаны к конкретному объекту.

Небольшой пример того, как работает this с jQuery:

$('button').click(function(event) {
  // $(this) указывает на $('button') потому что объект $('button')
  // запускает метод click, в котором определён this
  console.log( $(this).prop('name') );
});

Когда что-то может пойти не так

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

Метод как функция обратного вызова

Объект Boxer содержит некие данные и метод clickHandler. Мы ищем объект-DOM с классом .boxer. Добавляем обработчик события и в качестве функции пытаемся передать ему clickHandler, который будет рандомно отображать в консоли имя одного из боксёров.

var Boxer = {
  data: [
    { name: 'M.Tyson', age: 49},
    { name: 'M.Ali', age: 74}
  ],
  clickHandler: function() {
    var randomNum = Math.random() * 2|0;
    console.log(this.data[randomNum].name + ' ' + this.data[randomNum].age);
  }
};

document.querySelector('.boxer').addEventListener('click', Boxer.clickHandler);
// -> Cannot read property '1' of undefined

Ничего не вышло. Всё дело в том, что функцию в данном контексте вызывает DOM-объект .boxer. Наш Boxer.clickHandler пытается быть выполненным в контексте .boxer.

Решение: явно привязать функцию обратного вызова к объекту через метод bind().

// передаём нужный объект методу bind()
document.querySelector('.boxer').
  addEventListener('click', Boxer.clickHandler.bind(Boxer));

Теперь всё в порядке.

this внутри замыкания

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

var Boxer = {
  'type of sport': 'boxing',
  data: [
    { name: 'M.Tyson', age: 49 },
    { name: 'M.Ali', age: 74 }
  ],

  clickHandler: function() {
    // ссылается на Boxer
    this.data.forEach(function(person) {
      // теперь мы внутри data
      console.log(person.name +
        '. His type of sport ' +  this['type of sport']);
    });
  }
};

Boxer.clickHandler();
//-> M.Tyson. His type of sport undefined

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

Решение: передать значение this другой переменной перед тем как создать замыкание. Такую переменную принято называть that.

clickHandler: function() {
  // передаём значение текущего this
  var that = this;

  this.data.forEach(function(person) {
    console.log(person.name +
      '. His type of sport ' + that['type of sport']);
    console.log(that.data[0]['age']);
  });
}

// M.Tyson. His type of sport boxing
// 49

Хорошие новости. В ES6 делать этого не нужно. Спокойно пользуйтесь стрелочными функциями, они не имеют своего this и просто берут тот, что снаружи.

var Boxer = {
  'type of sport': 'boxing',
  data: [
    { name: 'M.Tyson', age: 49 },
    { name: 'M.Ali', age: 74 },
  ],

  clickHandler() {
    this.data.forEach(person => console.log(
      `${person.name}. His sport ${this['type of sport']}`)
    );
  },
};

Boxer.clickHandler();

метод объекта передан в переменную

Вернёмся к нашим боксёрам. Добавим пару чемпионов.

var data = [
  { name: 'Joe Louis' },
  { name: 'Lennox Lewis' }
];

var Boxer = {
  data: [
    { name: 'Mike Tyson', age: 49 },
    { name: 'Muhammad Ali', age: 74 }
  ],
  clickHandler: function() {
    console.log (this.data[0].name);
  }
};

var showBoxer = Boxer.clickHandler;

console.log(data[0].name); //-> Joe Louis
console.log(showBoxer()); //-> Joe Louis

Вызов data из глобальной области видимости и вызов showBoxer оказался одинаковым. Когда showBoxer срабатывает, вызывается анонимная функция:

function() {
  console.log (this.data[0].name);
}

Всё верно, этот кусок кода был «заморожен» в clickHandler. Однако, сейчас он выполнился в контексте глобального объекта.

Решение: снова явно привязать метод к объекту.

var showBoxer = Boxer.clickHandler.bind(Boxer);
console.log(showBoxer());
//-> Mike Tyson

заимствованные методы

Четвёртый и последний случай: имеем два объекта, у одного из них хотим вызвать метод другого. Допустим, рассчитать среднее количество очков на раунд игры.

Ниже используется метод reduce. Он принимает значения:

  • последний (он же промежуточный) результат (prev)
  • текущий элемент массива (cur)
  • номер текущего элемента (index)
  • передаваемый массив (array)

В getTotal передаём переменной сумму всех очков, затем делим их на количество раундов.

// первая игра
var angryBirds = {
  // количество очков, 4 раунда
  scores:   [10, 5, 15, 30],
  // среднее значение ещё не рассчитано
  average: null
};

// вторая игра
var angryCats = {
  // количество очков, 5 раундов
  scores:   [25, 32, 16, 43, 56],
  average: null,

  getTotal: function () {
    var sumOfScores = this.scores.reduce(function (prev, cur, index, array) {
      return prev + cur;
    });
    this.average = sumOfScores / this.scores.length;
  }
};

angryBirds.average = angryCats.getTotal();
console.log(angryBirds.average);

Но такой фокус не пройдёт. Метод getTotal вызвается объектом angryCats.

Решение: apply позволяет вызывать метод одного объекта в контексте другого.

angryCats.getTotal.apply(angryBirds, angryBirds.scores);
console.log(angryBirds.average);

Объект angryBirds «одолжил» метод getTotal у angryCats. This передана angryBirds, потому что он указан первым параметром метода apply.