Контекст выполнения функции (this) в JS
Очень популярный вопрос в моей менторской практике: что такое this
и как оно работает? Давайте так: я возьму и сразу расскажу, что это такое.
У функции в JS есть контекст выполнения. Так вот, this
— и есть этот самый контекст выполнения :) стало понятнее? Вот-вот. Давайте разбираться подробнее.
Как мы можем выполнить функцию в JavaScript? Есть всего четыре способа:
- напрямую вызвав функцию:
func();
Вызвав функцию напрямую в нестрогом режиме (о котором стоит сразу забыть и никогда не использовать), this
будет равен глобальному объекту global
(или window
, если мы о браузере, но мы тут все же про Node.js больше). Если же вызвать функцию напрямую в строгом режиме, то this у этой функции будет равен undefined
. Вот так все просто.
- вызвав метод объекта:
obj.func();
Если функция — это свойство объекта, то мы называем ее методом этого объекта. В таком случае this в этой функции — это этот самый объект. Все еще просто, да?
- вызвав функцию-конструктор, создав объект, для чего у нас есть ключевое слово
new
:
function Book(name) {
this.name = name;
}
const book = new Book('Bible');
book.name === 'Bible'; // true
Вот тут уже чуть веселее. Если по-простому, то значением this здесь будет объект, который мы конструктором создали. Если чуть сложнее, то на самом деле происходит что-то вроде этого:
function Book(name) {
// this = {} (this создается как некий объект)
// return this (когда мы создаем объект конструктором, мы, собственно, и возвращаем этот самый this
}
Как-то так. Все еще не слишком сложно, если вглядеться.
Разберем последний вариант вызова функции — непрямой ее вызов. Тут мы встретим три новых метода: bind()
, call()
и apply()
.
Собственно, что мы можем с ними сделать? А сделать мы можем довольно интересную штуку — назначить контекст выполнения функции явно. Глядите:
function logNameEmailAndBirthDate(birthDate) {
console.log(`${this.name}, ${this.email}, ${birthDate}`);
}
const user1 = { name: 'Ivan', email: 'ivan@gmail.com' };
const user2 = { name: 'Petr', email: 'petr@gmail.com' };
logNameEmailAndBirthDate.call(user1, '1 Oct 1991'); // this === user1
logNameEmailAndBirthDate.apply(user2, ['3 Feb 1993']); // this === user2
Что здесь происходит? А все просто: и call()
, и apply()
первым аргументом принимают this для функции, которую мы через эти call()
, и apply()
не напрямую вызываем. То есть в первом вызове logNameEmailAndBirthDate()
(через call()
) this === user1
, во втором (через apply()
) this === user2
. Единственная разница между call()
и apply()
— первый принимает аргументы для функции через запятую, второй — массивом.
А метод bind()
делает похожую штуку, но — немного иначе. Смотрите:
function logNameAndEmail() {
console.log(`${this.name}: ${this.email}`);
}
const bob = { name: 'Bob', email: 'bob@gmail.com' };
const logNameAndEmailForBob = logNameAndEmail.bind(bob);
logNameAndEmailForBob(); // Bob: bob@gmail.com
Что здесь происходит? Мы вызываем bind()
для logNameAndEmail()
, который привязывает контекст (bob
) к logNameAndEmail()
, возвращая новую функцию (в отличие от call()
, и apply()
), связанную с этим контекстом навсегда — изменить его потом не получится.
Стрелочные функции
Отдельно стоит упомянуть стрелочные функции (const func1 = () => {}
) — у них своего контекста выполнения, своего this
попросту нет. Это работает так, будто они — результат выполнения bind()
c ближайшим по иерархии контекстом.
Это можно использовать, например, когда мы не хотим использовать bind, но хотим передать, скажем, родительский контекст выполнения в функцию.
Каррирование
Один из классных примеров можно найти в функциональном программировании — каррирование. Каррирование — это трансформация функций так, чтобы они принимали значения не так: func (a, b, c)
, а так: func(a)(b)(c)
.
Зачем это нужно? Давайте рассмотрим это на примере функции, суммирующей все свои аргументы. Вы скажете, что для этого можно сделать так:
function sumAll (...spr) {
return spr.reduce((partialSum, a) => partialSum + a, 0);
}
И — да, так можно. Но мы все же возьмем это как пример для каррирования, тем более, что он будет не единственным. Давайте напишем ту самую трансформирующую функцию curry()
, используя наши знания о методе apply()
:
function sum(a, b, c) {
return a + b + c;
}
function curry (func) {
return function curried(...args) {
if (args.length >= func.length) {
return func.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
}
}
}
};
const curriedSummer = curry(sum);
// 6, можем просто вызвать функцию частично
console.log( curriedSummer(1, 2, 3) );
// все еще 6, мы прокаррировали первый аргумент
console.log( curriedSummer(1)(2,3) );
// и тут 6, мы прокаррирровали все аргументы
console.log( curriedSummer(1)(2)(3) );
Функция curry(func)
выглядит сложновато — давайте разберем ее. Она возвращает функцию curried()
, принимающую неограниченное количество аргументов. Что она делает. Она:
- если количество переданных в
curried()
аргументов больше или равно количеству аргументов при объявлении функцииfunc()
(в примере нижеsum()
), то мы просто вызываем функциюfunc()
, используяapply()
- если же количество переданных аргументов меньше количества аргументов при объявлении функции, мы не выполним
func()
сразу: вместо этого мы вернем новую функцию, которая снова применит curried, передав предыдущие аргументы вместе с новыми. И так будет происходить до тех пор, пока аргументов будет достаточно для вызоваfunc()
, которую мы и вызовем.
Пример с суммированием надуман, конечно. Но есть известный пример с функцией для логирования. Смотрите: у нас есть вот такая вот функция:
function log(level, date, message) {
console.log(`[${date.getHours()}:${date.getMinutes()}] [${level}] ${message}`)
}
// и этим даже можно пользоваться:
log("DEBUG", new Date(), 'debug message')
// [23:33] [DEBUG] debug message
Но что, если мы применим к ней каррирование?
const curriedLog = curry(log);
// после этого мы сможем пользоваться ей, как раньше:
curriedLog("DEBUG", new Date(), 'debug message')
// [23:33] [DEBUG] debug message
// но мы можем сделать и так:
curriedLog("DEBUG")(new Date(), 'debug message')
// [23:33] [DEBUG] debug message
Пока вообще не понятно, зачем мы это все затеяли. Но смотрите, что полезного мы можем из этого вывести:
const debugLog = curriedLog("DEBUG");
debugLog(new Date(), 'debug message');
// [23:33] [DEBUG] debug message
Видите? С помощью каррирования первого аргумента мы можем для разных уровней логирования создать функции debugLog()
, errorLog()
и так далее, и нам не придется постоянно передавать level логирования в функцию! Мы можем даже пойти дальше и откаррировать второй аргумент, получив функцию типа debugLogForCurrentDate(message)
, в которую даже дату передавать не нужно! Этот пример кажется надуманным, но можно найти этому множество гораздо более полезных применений.
Итоги
Гибкость контекста выполнения функции в JS делает его очень гибким языком, позволяя делать очень многие штуки, которые в других, менее гибких языках так красиво просто не делаются. И, в общем-то, this — это не очень-то и сложно :)
Интересный пост?
Вот еще похожие:
- Событийно-ориентированная архитектура и Node.js Events
- Реактивное программирование: теория и практика
- Как и зачем писать тесты?
- Функциональное программирование. Что это и зачем?
- Профилирование Node.js-приложений