habrahabr

Что такое «this» и с чем его едят

  • четверг, 30 января 2020 г. в 00:25:18
https://habr.com/ru/post/486048/
  • Разработка веб-сайтов
  • JavaScript
  • Программирование



Автор фото — Sebastian Herrmann.

Доброго времени суток, друзья!

Представляю Вашему вниманию перевод статьи Daniel James «What is 'this'? Why is that?».

Что такое «this» и с чем его едят


Когда я начинал изучать JavaScript, концепция this показалась мне крайне запутанной.

Введение


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

После этого у Вас возникает желание разобраться с тем, как работает this в JS. Является ли this концепцией объектно-орентированного программирования (ООП)? Является ли JS объектно-ориентированным языком программирования (ООЯП)? Если вы «погуглите» это, то получите в ответ упоминания каких-то прототипов. Что еще за прототипы? Для чего использовалось ключевое слово «new» до появления классов в JS?

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

ООП в JS


Парадигма прототипного программирования (наследования) в JS является одной из характерных черт ООП. Еще до появления классов JS был ООЯП. JS — простой язык, использующий лишь несколько вещей из ООП. Наиболее важными из них являются функции, замыкания, this, прототипы, объектные литералы и ключевое слово «new».

Инкапсуляция и повторное использование (Reusability) с помощью замыканий


Давайте создадим класс Counter (счетчик). У этого класса должны быть методы сброса и увеличения счетчика. Мы можем написать что-то вроде этого:

function Counter(initialValue = 0){
    let _count = initialValue

    return {
        reset: function(){
            _count = 0
        },
        next: function(){
            return ++_count
        }
    }
}

const myCounter = Counter()
console.log(myCounter.next()) // 1

В данном случае мы ограничились использованием функций и объектных литералов без this или new. Да, мы уже получили кое-что из ООП. У нас имеется возможность создавать новые экземпляры Counter. У каждого экземпляра Counter есть своя внутренняя переменная count. Мы реализовали инкапсуляцию и повторное использование чисто функциональным способом.

Проблема производительности


Предположим, что мы пишем программу, использующую большое количество счетчиков. У каждого счетчика будут собственные методы reset и next (Counter().reset != Counter().reset). Создание таких замыканий для каждого метода каждого экземпляра потребует колоссального объема памяти! Такая архитектура «нежизнеспособна». Поэтому нам необходимо найти способ хранить в каждом экземпляре Counter только ссылки на используемые им методы (по сути, это то, что делают все ООЯП, такие как Java).

Мы могли бы решить эту задачу следующим образом (без привлечения дополнительных языковых особенностей):

let Counter = {
    reset: function(counter){
        counter._count = 0
    },
    next: function(counter){
        return ++counter._count
    },
    new: function(initialValue = 0){
        return {
            _count: initialValue
        }
    }
}

const myCounter = Counter.new()
console.log(Counter.next(myCounter)) // 1

Данный подход решает проблему производительности, но нам пришлось пойти на серьезный компромисс, который заключается в необходимости высокой степени участия программиста в выполнении программы (в обеспечении того, чтобы код работал). Без дополнительных инструментов нам пришлось бы довольствоваться этим подходом.

This спешит на помощь


Перепишем наш пример с использованием this:

let Counter = {
    reset: function(){
        this._count = 0
    },
    next: function(){
        return ++this._count
    },
    new: function(initialValue = 0){
        return {
            _count: initialValue,

            // добавляем ссылку на каждый метод без создания новой функции
            reset: Counter.reset,
            next: Counter.next
        }
    }
}

const myCounter = Counter.new()
myCounter.next()

// надеюсь, вызов reset другого экземпляра не обнулит myCounter
(Counter.new()).reset()

console.log(myCounter.next()) // 2

Обратите внимание, что мы по-прежнему создаем простые функции reset и next (Counter.new().reset == Counter.new().reset). В предыдущем примере для того, чтобы программа работала, мы были вынуждены предоставлять совместно реализуемым методам дескриптор экземпляра. Теперь мы просто вызываем myCounter.next() и ссылаемся на экземпляр с помощью this. Но как это работает? Reset и next объявлены в объекте Counter. Откуда JS знает, на что ссылается this при вызове функции?

Вызов функций в JS


Вы прекрасно знаете, что у функций в JS есть метод call (также существует метод apply; разница между этими методами несущественна. Разница состоит в том, как мы передаем параметры: в apply в виде массива, в call через запятую — прим. пер.). Используя call, Вы решаете какое значение будет иметь this при вызове функции:

const myCounter = Counter.new()
Counter.next.call(myCounter)

В действительности это то, что делает точечная нотация за сценой, когда мы вызываем функцию. lhs.fn() идентично fn.call(lhs).

Таким образом, this — это специальный идентификатор, который устанавливается при вызове функции.

Начинаются проблемы


Скажем, Вы хотите создать счетчик и увеличивать его значение каждую секунду. Вот как это можно сделать:

const myCounter = Counter.new()
setInterval(myCounter.next, 1000)

// некоторое время спустя
console.log(`Why is ${myCounter.next()} still 0?`) // Почему myCounter.next() все еще равняется 0?

Вы видите здесь ошибку? Когда setInterval запускается, значение this равняется undefined, поэтому ничего не происходит. Эту проблему можно решить так:

const myCounter = Counter.new()
setInterval(function(){
    myCounter.next()
}, 1000)

Немного о bind


Существует другой способ решения данной проблемы:

function bindThis(fn, _this){
    return function(...args){
        return fn.call(_this, ...args)
    }
}

const myCounter = Counter.new()
setInterval(bindThis(myCounter.next, myCounter), 1000)

Используя «фабричную» функцию bindThis, мы можем быть уверены, что Counter.next всегда вызывает myCounter в качестве this, независимо от того, как вызывается новая функция. На самом деле мы не изменяем функцию Counter.next. JS имеет встроенный метод bind. Поэтому мы можем переписать пример выше так: setInterval(myCounter.next.bind(myCounter), 1000).

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


На данный момент у нас есть неплохой класс Counter, но он по-прежнему немного «кривой». Речь идет о следующий строчках:

// ...
reset: Counter.reset,
next: Counter.next,
// ...

Нам нужен лучший способ делиться методами класса с его экземплярами. С этой задачей отлично справляются прототипы. Если Вы обратитесь к свойству функции или объекта, которой не существует, JS будет искать данное свойство в прототипе этой функции или объекта (затем в прототипе прототипа и так до Object.prototype, находящегося на вершине цепочки прототипов — прим. пер.). Вы можете определить прототип объекта с помощью Object.setPrototypeOf. Давайте перепишем наш класс Counter, используя прототипы:

let Counter = {
    reset: function(){
        this._count = 0
    },
    next: function(){
        return ++this._count
    },
    new: function(initialValue = 0){
        this._count = initialValue
    }
}

function newInstanceOf(klass, ...args){ // в данном случае мы употребляем слово "klass", поскольку "class" является зарезервированным
    const instance = {}
    Object.setPrototypeOf(instance, klass)
    instance.new(...args)
    return instance
}

const myCounter = newInstanceOf(Counter)
console.log(myCounter.next()) // 1

Ключевое слово «new»


Использование setPrototypeOf очень похоже на то, как работает оператор «new». Отличие заключается в том, что new будет использовать прототип конструктора переданной функции. Поэтому, вместо создания объекта для наших методов, мы передаем их в прототип конструктора функции:

function Counter(initialValue = 0){
    this._count = initialValue
}
Counter.prototype.reset = function(){
    this._count = 0
}
Counter.prototype.next = function(){
    return ++this._count
}

const myCounter = new Counter()
console.log(`${myCounter.next()}`) // 1

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

Ключевое слово «class»


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

class Counter {
    reset(){
        this._count = 0
    }
    next(){
        return ++this._count
    }
    constructor(initialValue = 0){
        this._count = initialValue
    }
}

const myCounter = new Counter()
console.log(`${myCounter.next()}`) // 1

Ключевое слово «class» ничего особенно под «катом» не делает. Вы можете думать о нем, как о синтаксическом сахаре, обертке «прототипного» подхода. Если Вы запустите транспилятор, ориентированный на ES3, Вы получите что-то вроде этого:

var Counter = /** @class **/ (function(){
    function Counter(initialValue){
        if(initialValue === void 0) { initialValue = 0 }
        this._count = initialValue
    }
    Counter.prototype.reset = function(){
        this._count = 0
    }
    Counter.prototype.next = function(){
        ++this._count
    }
    return Counter
}());

var myCounter = new Counter()
console.log(myCounter.next())

Обратите внимание, что транспилятор сгенерировал код, практически идентичный предыдущему примеру.

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


Если Вы пишите код на JS последние 5 лет, Вы можете удивиться тому, что я упоминаю стрелочные функции. Мой совет: всегда используйте стрелочные функции, пока Вам действительно не потребуется обычная функция. Так получилось, что определение конструктора и методов класса — это как раз тот случай, когда мы должны использовать обычные функции. Одной из особенностей стрелочных функций является обфускация.

This в стрелочных функциях


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

const myArrowFunction = () => {
    this.doSomething()
}

Можно переписать так:
const _this = this
const myRegularFunction = function(){
    _this.doSomething()
}

Благодарю за внимание. Всех благ.