JS: this

2023-03-17
18 мин.

this-in-js

Что такое this

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

This вне функций

Напечатав this в консоли браузера, мы получим глобальный контекст исполнения – объект window в браузере.

На самом деле делать так не очень-то и полезно, так как window или, в последних версиях EcmaScript globalThis и так всегда доступен из любой части программы.
Для обращения к globalThis всегда лучше предпочитать явное обращение к нему через window или globalThis.

This в произвольной функции / методе

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

function foo() {
  console.log(this)
}

foo() // 👈 window

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

Было очевидно?! Тогда другой пример:

function foo() {
  console.log(this)
}

const obj = {
  name: 'Hello',
  foo: foo, // 👈 используем функцию как метод
}

foo() // 👈 window
obj.foo() // 👈 Object { name: "hello", foo: foo() }
const fooFromObj = obj.foo
fooFromObj() // 👈 window 🤔

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

Методы объектов

Любая функция может быть использована в качестве метода объекта. Для этого достаточно передать ее как поле. Все примеры ниже будут работать одинаково:

function getName() {
  return this.name
}
const person = {
  name: 'Andrew',

  // можно передать уже существующую функцию
  getName: getName,

  // или создать функцию на месте
  getName: function getName() {
    return this.name
  },

  // или создать анонимную функцию
  // (функцию без имени)
  getName: function () {
    return this.name
  },

  // или используем синтаксический сахар
  // для более краткой записи.
  // В большинстве случаев стоит предпочитать этот способ
  getName() {
    return this.name
  },
}

const name = person.getName()
console.log(name) // 👈 'Andrew'

Привязка контекста

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

bind

bind создает новую функцию, контекст которой не изменяется и всегда является первым аргументом, переданным в bind. Пример:

function f() {
  console.log(this)
}
f() // 👈 Window

const bound = f.bind(2 /* this = 2 */)
bound() // 👈 2

// Можно сделать предыдущую запись короче:
//   Но если нам нужен разовый вызов функции
//   с определенным контекстом,
//   лучше использовать call или apply
f.bind(2)() // 👈 2

В данном случае this внутри bound указывает на число 2.

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

call и apply

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

function f(...numbers) {
  const sum = numbers.reduce((a, b) => a + b, 0)
  const total = this + sum
  console.log(total)
}

f.call(2) // 👈 2, не передали numbers
f.apply(2) // 👈 2, не передали numbers

f.call(2, 3, 4, 5) // 👈 14
f.apply(2, [3, 4, 5]) // 👈 14
// vs
f.bind(2, 3, 4, 5)() // 👈 14
f.bind(2, 3, 4)(5) // 👈 14 🤔 ух ты

Частые примеры использования и проблемы

Использование методов массивов для array-like объектов

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

Далее, для подсчета суммы можно использовать reduce, но, так как arguments – это объект, а не массив, у него такого метода не будет. Поэтому можно позаимствовать у Array.prototype этот метод и вызвать его, передавая в качестве контекста arguments:

function getSum() {
  return Array.prototype.reduce.call(
    arguments,
    (sum, x) => sum + x,
    0
  )
}

getSum() // 0
getSum(1) // 1
getSum(1, 2) // 3
getSum(1, 2, 3) // 6

Более современное и изящное решение было бы использовать оператор rest, он же ..., который преобразует все переданные аргументы в массив:

function getSum(...numbers) {
  return numbers.reduce((sum, x) => sum + x, 0)
}

getSum() // 0
getSum(1) // 1
getSum(1, 2) // 3
getSum(1, 2, 3) // 6
Для тех, кому интересно, как работает оператор rest

Код ниже - преобразования babel, который переводит более современную версию JS в ES2015, для корректной работы всех браузеров, которые могут не поддерживать ES6.

function f(...args) {
  console.log(args)
}

Преобразуется в:

function f() {
  for (
    var _len = arguments.length,
      args = new Array(_len),
      _key = 0;
    _key < _len;
    _key++
  ) {
    args[_key] = arguments[_key]
  }
  console.log(args)
}

Передача метода объекта в HOF (функцию высшего порядка)

const eventHandlers = {
  greeting: 'HEY',
  onClick(e) {
    console.log(this, this.greeting, e.clientX, e.clientY)
  },
}

document.addEventListener('click', eventHandlers.onClick)

// Тыкая на экран, можно получить такой вывод:
// HTMLDocument undefined 915 190

Из-за того, что onClick вызывается где-то в другом контексте, this безнадежно потерян. Поэтому, можем воспользоваться bind, чтобы убедиться, что контекст остается таким, как нам надо:

document.addEventListener(
  'click',
  eventHandlers.onClick.bind(eventHandlers)
)

// Тыкая на экран, можно получить такой вывод:
// Object { greeting: "HEY", onClick: onClick(e) } HEY 1064 243

Сохраняем контекст навсегда

В предыдущем примере можно сделать еще более безопасный механизм, который предотвратит потерю контекста. Для этого можно сделать так:

const eventHandlers = {
  greeting: 'HEY',
  onClick(e) {
    console.log(this, this.greeting, e.clientX, e.clientY)
  },
}
// Привязываем веревками
eventHandlers.onClick =
  eventHandlers.onClick.bind(eventHandlers)

// теперь всегда передаем уже привязанную функцию
document.addEventListener('click', eventHandlers.onClick)
document
  .querySelector('.button')
  .addEventListener('click', eventHandlers.onClick)

// Тыкая на экран, можно получить такой вывод:
// Object { greeting: "HEY", onClick: onClick(e) } HEY 1064 243

Важно помнить, что на момент создания объекта, this все еще указывает на внешний контекст, а не на сам объект, поэтому такой код будет работать не так, как мы могли бы ожидать:

const obj = {
  f: function () {
    console.log(this) // этот this будет определен в момент вызова
  }.bind(this), // а этот `this` уже есть. И в данном случае он window
}

obj.f() // 👈 Window

Код выше аналогичен следующему:

const f = function () {
  console.log(this)
}.bind(this)

const obj = {
  f: f,
}
obj.f() // 👈 Window

this для реализации вызова цепочкой (chaining)

Что-то схожее реализовано в библиотеке lodash. Иногда такой подход позволяет скрыть мутацию данных внутри какого-то класса или объекта и упростить понимание кода.

Например, можно "скрыть" реализацию создания CSV:

const csv = createCSV()
  .setHeaders(['id', 'name', 'age'])
  .addRow([1, 'Ivan', 18])
  .addRow([2, 'Andrew', 25])
  .addRow([3, 'Oleg', 20])

function createCSV(header = [], rows = []) {
  return {
    setHeaders(newHeader) {
      header = newHeader
      return this
    },
    setRows(newRows) {
      rows = newRows
      return this
    },
    addRow(row) {
      rows.push(row)
      return this
    },
    deleteRowAtIndex(i = 0) {
      rows.splice(i, 1)
      return this
    },
    toString() {
      const data = [
        header.join(','),
        ...rows.map((row) => row.join(',')),
      ]
      return data.join('\n') // 👈 возвращаем значение, а не this. Прерываем "цепочку"
    },
    get() {
      return { header, rows } // 👈 возвращаем значение, а не this. Прерываем "цепочку"
    },
  }
}

Теперь, когда csv готов, нам остается только записать его в файл или распечатать в консоли.

const rawData = csv.get()
console.log(rawData)
// 👆
// {
//   header: ['id', 'name', 'age'],
//   rows: [
//     [1, 'Ivan', 18],
//     [2, 'Andrew', 25],
//     [3, 'Oleg', 20],
//   ],
// }

const csvString = csv.toString()
console.log(csvString)
// 👆
// "id,name,age
// 1,Ivan,18
// 2,Andrew,25
// 3,Oleg,20"

this внутри методов массивов (и любых других callback'ов)

const utils = {
  hasEvenNumber(...numbers) {
    console.log('outside', this)
    const hasEven = numbers.some(function (x) {
      console.log('inside', this)
      return x % 2 === 0
    })
  },
}

const hasEven = utils.hasEvenNumber([1, 2, 3])
// outside Object { hasEvenNumber: hasEvenNumber(numbers) }
// inside Window 👹

Есть несколько путей решения проблемы. Самый простой и олдскульный – сохранить this в переменную:

const utils = {
  hasEvenNumber(...numbers) {
    const that = this
    console.log('outside', this)
    const hasEven = numbers.some(function (x) {
      console.log('inside', that)
      return x % 2 === 0
    })
  },
}

const hasEven = utils.hasEvenNumber([1, 2, 3])
// outside Object { hasEvenNumber: hasEvenNumber(numbers) }
// inside Object { hasEvenNumber: hasEvenNumber(numbers) }

Более красивый и лаконичный – использовать стрелочную функцию:

const utils = {
  hasEvenNumber(...numbers) {
    console.log('outside', this)
    const hasEven = numbers.some((x) => {
      console.log('inside', this)
      return x % 2 === 0
    })
  },
}

const hasEven = utils.hasEvenNumber([1, 2, 3])
// outside Object { hasEvenNumber: hasEvenNumber(numbers) }
// inside Object { hasEvenNumber: hasEvenNumber(numbers) }

И вот мы подошли к необходимости изучить

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

Стрелочные функции – еще один способ написания функций. Обычно код получается более лаконичным. Зачастую, они работают интуитивно понятнее и проще, однако имеют ряд отличий от обычных функций:

  1. Не имеют своего this. this всегда указывает на то место в коде, в котором стрелочная функция была создана (а не вызвана, как у обычных функций)
  2. Не имеют псевдомассива arguments
  3. Не могут быть функциями-конструкторами (нельзя использовать с ключевым словом new)

Самое интересное - это их работа с this. Стоит относиться к стрелочной функции так: это обычная функция, которая привязана к this. То есть:

const f = () => {
  console.log(this)
}
// то же, что и
const f = function () {
  console.log(this)
}.bind(this)

Зная это, становится очевидным, что

const obj = {
  f: () => {
    console.log(this) // всегда Window
  },
}

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

class Person {
  constructor(name) {
    this.name = name
  }
  sayName = () => {
    console.log(this.name)
  }
}

const ivan = new Person('Ivan')
ivan.sayName() // 👈 Ivan

const say = ivan.sayName
say() // 👈 Ivan, потому что функция привязана!

Вывод

this в JS – довольно странная и сложная тема. Работа с контекстом в JS отличается от работы this в других языках программирования. Стрелочные функции помогают сделать нашу жизнь проще в большинстве случаев. Однако не всегда они являются лучшим решением проблемы. Есть ситуации, в которых стоит предпочитать обычные функции.

Материалы

ММФ-ноутс, ммф нотс, ммф-ноутс, mmf, notes, mmf-notes