Когда определяется this в JavaScript

Ключевое слово this в JavaScript ставит в тупик всех разработчиков — от джунов до сеньоров. В первой из двух частей углубимся в основы, необходимые для лучшего понимания this и того, как он работает.

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

Иногда я оказывался прав — к моей весьма скромной чести, — но не потому, что я знал, почему или что-то в этом роде. Теперь, после многих лет, проведённых в раздумьях, методах проб и ошибок, а также заглядывая в Stack Overflow так долго, как только позволяли надвигающиеся сроки, я думаю, что довольно хорошо разобрался в this. То есть, плюс-минус эмоциональная поддержка console.log время от времени; я всего лишь человек.

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

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

Это кошмарное предложение, я знаю, но потерпите. Оно сводится к двум важным понятиям: this ссылается на объект, связанный с функцией, а объект, на который ссылается this, определяется в момент вызова функции. Как только разберётесь с этими двумя аспектами, this станет… ну, всё ещё немного странным, но вполне понятным.

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

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

Контексты выполнения (execution context)

Чтобы лучше понять, как JavaScript думает, необходимо лучше понять модель выполнения JavaScript и природу стека вызовов (call stack) — структуры данных первый вошёл, последний вышел, используемой интерпретатором JavaScript для выполнения кода.

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

function theFunction() {
const theVariable = true;
};

Когда движок JavaScript встречает этот код, он создаёт лексическое окружение для theFunction — структуры данных, представляющей код в том виде, в котором он был написан.

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

function theFunction() {
console.log( theVariable );

let theVariable;
}

theFunction();
// результат: Uncaught ReferenceError: can't access lexical declaration 'theVariable' before initialization

Запись окружения, созданная для контекста выполнения этой функции, содержит свойство theVariable, потому что оно находится прямо там, в лексическом окружении функции. JavaScript знает о нашей переменной в тот момент, когда выполняет функцию и сталкивается с этим console.log, но мы делаем с ней что-то довольно странное, поэтому JavaScript выбрасывает ошибку, чтобы спасти нас от самих себя. Эта осведомлённость, основанная на записях в окружении, и есть то, как работает поднятие/hoisting переменной, но это уже тема для другого раза.

После определения лексического смысла функции и создания записи окружения движок JavaScript создаёт контекст выполнения (иногда называемый фреймом) для функции. Этот контекст выполнения включает в себя функцию и всё, необходимое для её выполнения.

Стек вызова (call stack)

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

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

Возьмём следующий скрипт:

const theValue = 4;

function checkTheValue( theFunctionVariable ) {
const successMsg = "High enough.";
const failureMsg = "Too low.";

if( theFunctionVariable > 2 ) {
consoleLogger( successMsg );
} else {
consoleLogger( failureMsg );
}
};

function consoleLogger( msg ) {
console.clear();
console.log( msg );
};

checkTheValue( theValue );

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

Диаграмма. \"Ведро\", представляющее стек, со стрелкой, направленной от вершины к низу. В стеке есть блок, обозначенный как глобальный контекст исполнения. Внутри блока глобального контекста выполнения каждый блок из приведённого выше примера выделен цветом.

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

Как только интерпретатор встречает вызов функции checkTheValue в глобальном контексте выполнения, он создаёт контекст выполнения функции для этого вызова в верхней части стека. Глобальный контекст выполнения приостанавливается, а вновь добавленный контекст выполнения функции немедленно выполняется.

Ссылаясь на предыдущую иллюстрацию, утверждение `checkTheValue( theValue )` в глобальном контексте выделено, а стрелка указывает на блок \"контекст выполнения функции `checkTheValue`\", добавленный над блоком глобального контекста выполнения. Он содержит следующие утверждения: `const successMsg = \"High enough\"`, `const failureMsg = \"Too low\"` и `if ( theFunctionVariable > 2 consoleLogger( successMsg ) else consoleLogger( failureMsg )`

Ссылаясь на предыдущую иллюстрацию, утверждение checkTheValue( theValue ) в глобальном контексте выделено, а стрелка указывает на блок "контекст выполнения функции checkTheValue", добавленный над блоком глобального контекста выполнения. Он содержит следующие утверждения: const successMsg = 'High enough', const failureMsg = 'Too low' и if ( theFunctionVariable > 2 consoleLogger( successMsg ) else consoleLogger( failureMsg )

Этот контекст выполнения функции содержит вызов consoleLogger, поэтому контекст выполнения функции для этого вызова добавляется на вершину стека и — вы угадали — немедленно исполняется.

Строка `consoleLogger( sucessMsg )` в контексте выполнения функции `checkTheValue` выделена, а стрелка указывает на вновь добавленное \"исполнение функции `consoleLogger` на вершине стека\"

Строка consoleLogger( sucessMsg ) в контексте выполнения функции checkTheValue выделена, а стрелка указывает на вновь добавленное исполнение функции consoleLogger на вершине стека.

После завершения работы consoleLogger удаляется из стека, а JavaScript продолжает выполнение вызвавшей его функции checkTheValue.

Блок \"Контекст выполнения функции consoleLogger\" был удалён с вершины стека, оставив блок \"Контекст выполнения функции `checkTheValue`\" и блок \"Глобальный контекст выполнения\".

Блок "Контекст выполнения функции consoleLogger" был удалён с вершины стека, оставив блок "Контекст выполнения функции checkTheValue" и блок "Глобальный контекст выполнения".

Функция checkTheValue завершается, выводится из стека, а глобальный контекст выполнения продолжается.

Блок \"контекст выполнения функции `checkTheValue`\" был удалён с вершины стека, оставив только глобальный контекст выполнения.

Блок "контекст выполнения функции checkTheValue" был удалён с вершины стека, оставив только глобальный контекст выполнения.

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

Когда определяется значение this

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

На мой взгляд, именно в этом кроется суть путаницы вокруг this. Когда мы пишем функцию, содержащую const theVariable = true, неважно, каким путём движок JavaScript доберётся до этого утверждения: лексическое окружение содержит утверждение, инициализирующее theVariable, поэтому неизменный факт существования theVariable отмечен в записи окружения. Если и когда это утверждение будет достигнуто в контексте выполнения, значение переменной theVariable будет true, и точка:

const theObject = {
theMethod() {
const theVariable = true;
console.log( theVariable );
}
};

theObject.theMethod();
// результат: true

Вы не сможете ошибиться, если предположите, что this работает точно так же, несмотря на то, что JavaScript выполняет установку вместо нас (пока не беспокойтесь о специфике этого примера):

const theObject = {
theMethod() {
console.log( this );
}
};

theObject.theMethod();
// результат: Object { theMethod: theMethod() }

Мы ещё поговорим о природе объекта, связанного с функцией во второй части этой серии статей, но здесь мы видим, что this выводит в консоль внутри theMethod() в результате приводит к объекту, содержащему метод (theObject). Даже несмотря на все мои оговорки, этот фрагмент кода, вероятно, читается так, как будто this в методе ссылается на объект, потому что, посмотрите, метод находится прямо в этом объекте. Однако если мы вызовем этот же метод немного другим способом:

const theObject = {
theMethod() {
console.log( this );
}
};

const theFunctionIdentifier = theObject.theMethod;

theObject.theMethod();
// результат: Object { theMethod: theMethod() }

theFunctionIdentifier();
// результат: Window {...}

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

const theObject = {
theMethod() {
console.log( this === theObject );
}
};

const theFunctionIdentifier = theObject.theMethod;

theObject.theMethod();
// результат: true

theFunctionIdentifier();
// результат: false

В момент вызова функции.

Не самая интуитивная вещь в мире, конечно, но, по крайней мере, то, когда устанавливается значение this, имеет некий глубокий смысл в JavaScript-машине.

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

До встречи в следующей части.

Комментарии


Дополнительные материалы

Предыдущая Статья

PHP 8.5: Новые функции array_first и array_last

Следующая Статья

Проблемы преобразования значений в строки в JavaScript