Разбираемся с this в JavaScript раз и навсегда

Бесконечно можно смотреть на три вещи: как горит огонь, как течет вода и то как фронтендеры пишут очередную статью про this.

Но все же такое количество статей существует не просто так, тема действительно для многих запутанная и иногда даже сами авторы статей неправильно понимают this и соответственно закладывают неправильное понимание этого у читателей. Есть и много хороших статей, где все описано верно, но в основном чисто с практической точки зрения, без вникания в то «как это работает на самом деле». А статей с достаточно глубоким погружением в теорию буквально единицы.

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

Тип Reference Record

Одним из основополагающих понятий для определения значения this является Reference Record.

Reference Record — это один из типов спецификации ECMAScript (не путать с типами в JavaScript, иначе за вами придет Мурыч), который используется внутри языка. Он имеет следующие поля: Base, ReferencedName, Strict и ThisValue. Пройдемся по ним по порядку:

Base — значение равно одному из типов данных JavaScript, либо Enviroment Record (еще один тип спецификации).

ReferencedName — String, Symbol или Private Name.

Strict — Boolean.

ThisValue — один из типов данных JavaScript.

Простыми словами, Base это, как пример, может быть объект или Enviroment Record (по сути специальный объект, в котором хранятся все локальные переменные), ReferencedName это название свойства объекта или идентификатора, Strict обозначает strict mode, а ThisValue связано с ключевым словом super.

Далее поля Strict и ThisValue можно опустить, если они не понадобятся нам для чего-то конкретного.

Как же нам понять что относится к этому типу? Тут все довольно просто — интерпретатор определяет что-то как тип Reference в двух случаях:

  1. Когда мы используем Property Accessor, то есть используем точку или квадратные скобки — obj.name, obj[‘name’] или obj.method(). Кстати, обычно говоря про this используется фраза, что this равно тому что находится слева от точки, забывая про вариант со скобками.

    4a6fdc19104803e40ff721ad865f6b76.png
  2. И когда мы имеем дело с Identifier, это могут быть переменные, свойства или функции.

    c08fec04f2778bc59c902d6ddfe9039e.png

Во всех других случаях, например, при использовании операторов (x + y, x ? y : z, !x) и литералов ("abc”, [1, 2, 3] и тд), мы получим уже другие типы.

Например, у нас есть объект person:

const person = {
  name: "Dasha”,
  catName: "Thomas”,
}

person.catName

Проходясь по этому коду, интерпретатор увидит, что используется Property Accessor для доступа к свойству catName, следовательно, перед нами выражение типа Reference Record, который будет выглядеть так:

{
  Base: person,
  ReferencedName: catName,
}

Или возьмем переменную:

const cat = "Thomas"

Это идентификатор, а значит тоже Reference, который выглядит так:

{
  Base: global object,
  ReferencedName: cat,
}

К слову про global object — это глобальный объект, который в зависимости от окружения и strict mode может иметь разные значения, например в браузере это объект window или undefined (в strict mode). Чтобы не перечислять каждый раз все возможные значения, далее я просто буду использовать слово global.

Таким образом, можно сказать, что значение Base это контекст, в котором находится ReferencedName.

This и тип Reference

Теперь, когда мы разобрались с тем что такое Reference Record, можно переходить к тому, как это помогает нам узнать значение ключевого слова this.

Дело в том, что единственный случай, когда значение this зависит от контекста — это когда мы имеем дело с Reference, используя значение поля Base.

Для понимания этого на практике рассмотрим несколько примеров.

const person = {
  name: "Dasha”,
  catName: "Thomas”,
  getCatName() {
    return this.catName;
  }
}

person.getCatName()

Метод объекта вызывается через точку, а значит перед нами тип Reference, где Base равно person, а значит и this равно person.

const getCatName = person.getCatName

getCatName()

А что будет в этом случае? Вызов идентификатора getCatName тоже будет относиться к типу Reference, где Base будет global, а следовательно и this равно global.

Все это следует из алгоритма спецификации для вызова функции. Для первого примера мы прошли проверку на первом шаге, которая через первый подпункт приводит (isPropertyReference означает, что мы вызываем свойство объекта) к получению this из Base.

b6c13302e39e2e8acbd842a35bc29da0.png

Во втором же примере мы оказываемся во втором подпункте, т.к. значение не является PropertyReference, где в Base устанавливается Enviroment Record, что в данном случае является global object.

d78156b8cad9b4b89e4332884d0ff4d4.png

Рассмотрим более сложный классический пример:

(person.getCatName)();
(false && person.getCatName)();
false && person.getCatName();

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

Во втором случае тоже используются оператор группировки, но к нему добавляется оператор &&, который приводит к вычислению выражения и потере типа Reference, поэтому this становится global. Кроме того, в строгом режиме, при попытке выполнить строку this.catName мы получим ошибку из-за того, что this будет undefined и по сути мы обращаемся к undefined.catName.

В третьем случае используется оператор, но без скобок, а значит результатом будет вызов person.getCatName() с точечной нотацией с типом Reference, функция вернет 'Thomas'.

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

В частности это важно для определения this в коллбэк функциях, где его значение зависит от конкретной функции, куда передается коллбэк. Например, this внутри коллбэк функции для обработчика клика вернет document:

document.addEventListener('click', function() {
  console.log(this)
})

А в методе forEach уже global:

[1, 2, 3].forEach(function() {
  console.log(this)
})

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

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

529e8d32ef9ae19773ed32c549bef883.png

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

Разберем несколько примеров.

const person = {
  catName: "Thomas”,
  getCatName: () => {
  	return this.catName
  }
}

person.getCatName()

Родительским лексическим окружением для стрелочной функции getCatName в данном случае будет являться объект global. А значит вызов person.getCatName() вернет undefined или ошибку в strict mode.

А что если мы обернем вызов стрелочной функции в обычную функцию?

const person = {
  catName: "Thomas”,
  getCatName: () => {
    return this.catName
  },
  wrapperFoo() {
    return this.getCatName()
  }
}

person.wrapperFoo()

Ничего не изменится, this для функции getCatName() уже установлен статически и неважно, как и где мы ее вызываем.

Но, если стрелочная функция изначально будет установлена внутри обычной, то тогда получится, что this возьмется от этой обычной функции, а вызов person.wrapperFoo2() вернет «Thomas»:

const person = {
  catName: "Thomas”,
  getCatName: () => {
  	return this.catName
  },
  wrapperFoo() {
    return this.getCatName();
  },
  wrapperFoo2() {
    return () => {
      return this.catName;
    }
  }
}

person.wrapperFoo2()

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

const person = {
  catName: "Thomas”,
  wrapperFoo() {
    return () => {
      return this.catName;
    }
  }
}

const foo = person.wrapperFoo()

foo(); // ‘Thomas’

Помните, как говоря про тип Reference мы говорили о том, что подобное присваивание и вызов функции без точечной нотации приводит к потере this? Для стрелочной функции это не проблема, потому что значение this уже было задано в момент ее создания и не зависит от способа вызова.

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

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

bind()

bind ()

call() и apply()

call () и apply ()

Методы bind, call, apply

Данные методы позволяют явно задать this при вызове функции в качестве первого аргумента. Но среди них особое место занимает метод bind(), который приводит к созданию особой bound function (или привязанная функция). Главной особенностью которой является фиксированное значение this в поле BoundThis.

007b2c868b73704116971bd930f60ee5.png

Посмотрим на это на примере:

const cat = {
  name: ‘Thomas’,
  getOwnerName() {
  	return this.ownerName
  }
}

const owner = {
  ownerName: ‘Dasha’,
}

const boundGetOwnerName = cat.getOwnerName.bind(owner)

boundGetOwnerName()

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

const anotherOwner = {
  ownerName: "Ilia”,
}

boundGetOwnerName.apply(anotherOwner)
boundGetOwnerName.call(anotherOwner)

Это также следует из спецификации, где говорится, что если эти методы применяются к bound function, то аргумент со значением this игнорируется:

call() и apply()

call () и apply ()

Таким образом, в нашем примере котик всегда остается привязан к своему изначальному хозяину, с которым мы его связали. И даже если мы снова попытаемся переопределить значение this с помощью bind(), то у нас не получится это сделать.

Хотя из этого правила есть одно исключение — переданное значение this для bind() игнорируется, если функция создается с оператором new. Исходя из спецификации это объясняется тем, что при использовании конструктора [[Construct]] просто нет шага, где определяется BoundThis, в отличие от обычного вызова [[Call]]:

650ad130abcd5dab9d74eb3387a4ab1e.png

Конструктор new

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

function Cat() {
  this.name = ‘Thomas’
}

const newCat = new Cat()
newCat.name // ‘Thomas’

Или другой пример с классом:

class Cat {
  constructor(name) {
    this.name = name
  }
  getCatName() {
    return this.name
  }
}

const cat = new Cat(‘Thomas’)
cat.getCatName(); // Thomas

const cat2 = new Cat(‘Lesya’)
cat2.getCatName(); // Lesya

Но и здесь есть одна особенность, которая кроется во фразе — «объекта, который возвращается конструктором»:

function Cat() {
  this.name = ‘Thomas’
  return {
	name: ‘Lesya’,
	getName() {
      return this.name
    }
  }
}

const newCat = new Cat()
newCat.getName() // ‘Lesya’, а не ‘Thomas’

И наконец рассмотрим ключевое слово super. Здесь стоит обговорить два момента.

Первое — нельзя вызывать this выше чем объявлено super, это вызовет ошибку.

Второе — даже если мы вызовем метод через super, например, super.getName (), this внутри него не будет ссылаться на объект, к которому относится super:

class Parent {
    constructor() {
        this.name = "Parent"
    }
    getName() {
        console.log("Name:", this.name)
    }
}

class Child extends Parent {
    constructor() {
        super();
        this.name = "Child"
    }
    testSuperMethod() {
        super.getName()
    }
}

const child = new Child()
child.testSuperMethod()  // "Name: Child", а не "Name: Parent”

Таким образом, даже при вызове методов через super, контекст this остается привязанным к текущему объекту.

И кстати говоря, здесь мы снова возвращаемся к типу Reference. Потому что вычисление кода super.getName () в конечном итоге приводит нас к определению типа Reference с заданным значением поля ThisValue и Base в виде текущего объекта, где было вызвано super:

984f22c772ac8cf15c6c5bdf4caf551b.png0cae3a3fbfa10d24d0d5bdfae97476f5.png

Заключение

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

© Habrahabr.ru