Стрелочные функции и что о них стоит помнить

c84c27132af7013fab4975569f094011.png

Идея написать статью про стрелочные функции в 2023 году выглядит не самой очевидной, но я постараюсь объяснить свою мотивацию. Я разработчик, который пришел в профессию после того, как в JavaScript появились такие инструменты как классы, async/await, стрелочные функции и так далее. В результате я воспринимаю их как данность и не всегда понимаю, какой важный вклад они внесли в современный JS. И из‑за этого непонимания в коде появляются ошибки, которых можно избежать, если оглянуться назад и изучить, какие проблемы эта технология была призвана решить в момент выхода. В этой статье я хочу разобраться: зачем появились стрелочные функции, чем они отличаются от обычных и какие особенности содержат.

Что было до стрелочных функций

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

До того, как появились стрелочные функции, в JS существовали только функции, которые можно было объявить через ключевое слово function:

function foo () {
   console.log('Hello World')
}

Главная проблема классической функции — это то, что контекст this в ней связан не с местом объявления функции, а с местом вызова (т.н. runtime binding). Звучит немного запутанно, давайте разберемся. Напишем класс Dog, у которого есть свойство name и метод eat, который принимает на вход массив из вкусняшек и по одной ест их:  

class Dog {
   constructor(name){
       this.name = name;
   }


   eat(food){
       food.forEach(function(item) {
           console.log(`${this.name} is eating ${item}`)
       });
   }
}


const bim = new Dog('Bim');
bim.eat(['bone', 'cookie'])

В таком виде вы получите ошибку.

a5f766fc484d73a64a9607c4a1959e36.png

Контекст this в классических функциях вычисляется в момент вызова. Мы ожидаем, что, если функция была объявлена внутри класса, то и this будет указывать на класс, но this обычной функции определяется в момент вызова. В данном случае анонимная функция передана как callback внутрь forEach, следовательно, она вызывается в методе массива. Контекст this внутри метода массива связать не с чем, поэтому this определяется как undefined, и код падает с ошибкой «Не могу прочитать свойство undefined». 

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

Давайте попробуем решить эту проблему так, как она решалась до выхода ES6. this, который мы хотим использовать в анонимной функции, нужно записать в переменную и использовать ее вместо this:

...
   eat(food){
       const self = this;
       food.forEach(function(item) {
           console.log(`${self.name} is eating ${item}`)
       });
   }
...

Теперь все работает так, как мы ожидали. 

На вооружении у разработчиков до появления стрелочной функции было также связывание функции с необходимым this явно. Для связывания функции с необходимым this можно использовать метод bind (а также call и apply), который есть у функций (bind буквально переводится как «связывать»):

...
   eat(food){
       food.forEach(function(item) {
           console.log(`${this.name} is eating ${item}`)
       }.bind(this));
   }
...

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

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

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

Перепишем наш пример с использованием стрелочной функции, а также залогируем this в методе и внутри стрелочной функции:

class Dog {
   constructor(name){
       this.name = name;
   }


   eat(food){
       console.log('method', this)
       food.forEach((item) => {
           console.log('arrow func', this)
           console.log(`${this.name} is eating ${item}`)
       });
   }
}


const bim = new Dog('Bim');


bim.eat(['bone', 'cookie'])

Вот что мы получим в консоли:

e03f19a5db65bd7c73a9d600453003e7.png

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

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

ВАЖНО: Приведенные выше сравнения не отражают реального положения дел, this это НЕ СВОЙСТВО и НЕ ССЫЛКА. Я использую это сравнение только для подчеркивания разницы в this стрелочной и обычной функций.

Для более глубокого погружения в то, как устроен this, советую прочитать соответствующую статью на MDN.

Особенности, о которых стоит знать

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

Стрелочную функцию лучше не использовать как метод в объектах и классах

Начнем с объекта. Если мы напишем следующий код, то он не будет работать:

const person = {
   name: 'John',
   sayName: () => {
       console.log(`Hi! Me name is ${this.name}`)
   }
}


person.sayName();

В консоли мы получим ошибку:

37becc7178bfa20453ebc88164093af4.png

А если мы напишем метод, объявленный стандартным способом, то все будет работать исправно.

const person = {
   name: 'John',
   sayName () {
       console.log(`Hi! Me name is ${this.name}`)
   }
}


person.sayName();

Консоль:

fbb214b95aa88a6f2871890a6ddfe7a8.png

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

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

Но если мы обратимся к классам, то такой ошибки мы не встретим:

class Person {
   constructor(name){
       this.name = name
   }


   sayName = () => {
       console.log(`Hi! Me name is ${this.name}`)
   }
}


const john = new Person('John')
john.sayName();

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

class Person {
   constructor(name, age){
       this.name = name
       this.age = age
   }


   sayName = () => {
       console.log(`Hi! Me name is ${this.name}`)
   }
  
   getAge () {
       console.log(this.age)
   }
  
}


const john = new Person('John')
console.log(john, 32)

Если мы посмотрим в консоль, то увидим, что метод getAge находится в прототипе, а sayName было скопировано в экземпляр как свойство:

a2d745c4a59548ed06bfad7a29ad9ae9.png

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

В стрелочной функции невозможно изменить this

Как мы узнали выше, для переопределения this в обычной функции можно использовать методы bind, apply или call. У стрелочной функции эти методы также доступны, но они не меняют this, потому что this в стрелочной функции не изменяется на всем протяжении жизненного цикла.

const sayNameGlobalArr = () => {
   console.log(`Hi! Me name is ${this.name}`)
}
class Person {
   constructor(name, age){
       this.name = name
       this.age = age
   }


   sayName () {
       sayNameGlobalArr.bind(this)()
   }
  
   getAge () {
       console.log(this.age)
   }
  
}


const john = new Person('John')
john.sayName();

Получим ошибку:

8d5619db820e12c49f86776302f2b855.png

В стрелочной функции недоступен объект arguments

В обычной функции вы можете обратиться к массивоподобному объекту arguments, который будет содержать параметры переданные в функцию.

function howManyArguments () {
   console.log(arguments.length)
}


howManyArguments("Hello", "World", "!");

Консоль:

8bcd95f15ba313cd09afc27bcfb7b347.png

В стрелочной функции доступа к переменной arguments нет, но проблема решается использованием spread оператора:

const howManyArguments = (...props) => {
   console.log(props)
   console.log(props.length)
}


howManyArguments("Hello", "World", "!");

Консоль:

c0b592186fc7cac3693ded1a7aa952a9.png

Конечно, использование spread оператора это не 100% повторение переменной arguments, но для части задач это решение годится.

Что еще можно держать в голове (хотя, скорее всего, это вам не понадобится)

  • Стрелочные функции нельзя использовать как функцию-конструктор. Попытка использовать ключевое слово new со стрелочной функцией приведет к ошибке.

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

Заключение

Итого:

  • В стрелочных функциях this сохраняет значение this окружающего контекста в момент создания.

  • Стрелочную функцию лучше не использовать как метод в объектах и классах.

  • this в стрелочной функции не изменяется на всем протяжении жизненного цикла.

  • В стрелочной функции нет доступа до переменной arguments, вместо этого можно использовать spread оператор.

  • Стрелочную функцию нельзя использовать с ключевым словом new — это означает, что она не может быть функцией-конструктором.

  • Нельзя использовать ключевое слово yield внутри стрелочной функции — это означает, что стрелочная функция не может быть функцией-генератором.

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

В завершение хочу порекомендовать бесплатный урок от моих друзей из OTUS по теме: «Управление сложным состоянием на основе XState».

© Habrahabr.ru