Идея написать статью про стрелочные функции в 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'])

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

Контекст 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'])

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

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

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

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

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

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

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

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

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

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


person.sayName();

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

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

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


person.sayName();

Консоль:

Дело в том, что объект не предоставляет свой собственный контекст, к которому стрелочная функция могла бы привязаться в момент создания, а вот метод объекта ведет себя, как обычная функция — он вычисляет 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 было скопировано в экземпляр как свойство:

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

В стрелочной функции невозможно изменить 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();

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

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

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

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


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

Консоль:

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

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


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

Консоль:

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

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

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

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

Заключение

Итого:

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

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

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

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

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

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

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

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

Комментарии (11)


  1. DmitryKazakov8
    00.00.0000 00:00
    +3

    Самое главное забыли - в ряде случаев (когда они появились - практически во всех браузерах так было) у стрелочной функции нет name, `const fn = () => false`, fn.name undefined|''. То есть в стек-трейсе будет anonymous, и если код минифицирован, найти причину бага очень сложно. Но в современных браузерах name назначается по имени константы. Это все еще приходится держать в голове. В определенных случаях и современные браузеры не назначат - `const fn = (() => () => { return false })()`.

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

    Еще натыкался на то, что стрелочные методы классов в определенных версиях транспиляторов не декорируются, так как на выходе код совершенно другой, чем у нестрелочного метода.

    Фича в целом отличная, но об ограничениях нужно помнить


    1. daneelzam Автор
      00.00.0000 00:00

      Спасибо за уточнение! Действительно пропустил эти моменты.


    1. wisead
      00.00.0000 00:00
      +1

      Ну вообщето ссылаться на имя константы можно и до объявления стрелочной функции, в теле другой функции, если эта функция будет вызвана уже после объявления стрелочной. Temporary dead zone.


  1. shsv382
    00.00.0000 00:00
    +1

    Спасибо за статью! Освежил в памяти эти моменты!


  1. I1I
    00.00.0000 00:00
    +1

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

    потом читаю

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

    Простите, но я не понял. Именно в классах стрелки избавляют от необходимости bind или self, но лучше стрелки не использовать в классах?


    1. daneelzam Автор
      00.00.0000 00:00

      Здравствуйте!

      Действительно звучит как противоречие, сейчас попробую уточнить. Дело в том, что при объявлении метода (класса или объекта) действительно лучше не использовать стрелочную функцию, НО вот если внутри метода нужно по тем или иным причинам объявить функцию (чаще всего это callback), то в этом случае лучше использовать стрелочную функцию.


  1. Denis1205
    00.00.0000 00:00
    +1

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


  1. malferov
    00.00.0000 00:00

    Лучше не использовать стрелочную функцию при объявлении метода или не лучше. Когда нужен контекст объекта/класса, используем стрелочную, не нужен — не используем.

    Как можно давать общую рекомендацию?!


  1. demimurych
    00.00.0000 00:00
    +2

    В данном случае this равен undefined, потому что это происходит внутри класса, в котором строгий режим включен автоматически.

    Не автоматически. Class Definition работает только в strict mode. Нет ни единой возможности использовать Class Definition вне strict mode.

     

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

    Еще можно было бы вспомнить о том, что в Array.prototype.forEach можно передавать this явным образом.

     

    Контекст this в классических функциях вычисляется в момент вызова.

    Правильнее сказать, что this устанавливается только в одном случае - когда вычисление MemberExpression приводит к Property Accessors вычисление которого приводит к Reference Record где [[Base]] будет установлен в обьект, а [[ReferencedName]] в identifier name нашей функции. Что позднее приведет к связыванию this с [[Base]]

    Говоря простым языком - существует только один случай неявного связывания this - это случай вызова regular function в дот нотации: obj.someFunc() или obj['someFunc'] где this будет связан с obj (той ссылкой что идет перед точкой)

    В случае arrow function - вне зависимости от способов вызова с this никаких операций не будет произведено вообще

    В случае вызова regular function не в дот нотации - this будет установлен либо в global object либо в undefined в зависимости от текущего strict mode.

     

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

    Тут какая то каша. Если Вы про расход ресурсов на создание bind function то bind создает exotic object с минимумом property-s. Такая JSBoundFunction в V8 на 4 байта меньше чем ArrowFunction. Какой расход памяти должна была оптимизировать ArrowFunction я не вполне понял. ArrowFunction глубоко по барабану что твориться вокруг нее.

     

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

    В стрелочных функциях this никак не устроен. Если происходит evaluation arrow function то согласно спецификации вычисление this завершается на второй строчке:
    If thisMode is lexical, return unused // thisMode === lexical это признак стрелочной функции.

     

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

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

     

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

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

       

    Стрелочную функцию лучше не использовать как метод в объектах и классах
    Начнем с объекта. Если мы напишем следующий код, то он не будет работать:
    [...]
    В консоли мы получим ошибку:
    [...]
    А если мы напишем метод, объявленный стандартным способом, то все будет работать исправно.
    [...]

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

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

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

    function myObj(name) {
      this.name = name;
      this.sayName = () => {
           console.log(`Hi! Me name is ${this.name}`)
       }  
    }
    const john = new myObj('John')
    john.sayName();

    Догадываетесь почему вдруг метод sayName заявленный как arrowFunction стал прекрасно работать с this? Потому, что благодаря конструктору, мы создали банальное замыкание в результате чего this стал прекрасно доступным в arrow.

       

    Дело в том, что у класса, в отличие от объекта в JS, есть собственный контекст,

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

     

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

    Я предлагаю Вам выполнить домашнее задание: найти минимум три способа определение метода при помощи arrow function таким образом, чтобы этот метод стал частью прототипа, а не частью самого созданного класса/обьекта.


    1. daneelzam Автор
      00.00.0000 00:00

      Здравствуйте!

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


  1. stanzzza
    00.00.0000 00:00

    var a = 5;
    
    class Obj {
      constructor() {
        this.a = 10;
      }
      
      method() {
        return () => console.log(this.a);
      }
    }
    
    const obj = new Obj()
    
    const obj2 = {
      a: 20,
      method: obj.method
    }
    
    obj2.method()(); // 20

    Никуда контекст стрелочной функции не привязывается, а вычисляется по цепочке лексических окружений, зафиксированной при объявлении. При вызове obj2.method() метод получит в контекст объект obj2, а стрелочная функция ссылаясь на внешнюю область видимости найдет этот контекст