Идея написать статью про стрелочные функции в 2023 году выглядит не самой очевидной, но я постараюсь объяснить свою мотивацию. Я разработчик, который пришел в профессию после того, как в JavaScript появились такие инструменты как классы, async/await, стрелочные функции и т.д. В результате я воспринимаю их как данность и не всегда понимаю, какой важный вклад они внесли в современный JS. И из-за этого непонимания в коде появляются ошибки, которых можно избежать, если оглянуться назад и изучить, какие проблемы эта технология была призвана решить в момент выхода. В этой статье я хочу разобраться: зачем появились стрелочные функции, чем они отличаются от обычных и какие особенности содержат.
Что было до стрелочных функций
Стрелочные функции появились в стандарте ECMAScript 6, который вышел в 2015 году. Если мы обратимся к статьям на Хабре того периода, которые рассказывают про стрелочные функции, мы можем понять, какие проблемы эта технология должна была решить.
-
«Обзор ECMAScript 6, следующей версии JavaScript» / 4 апреля 2013
-
«Стрелочные функции (Arrow functions) в ECMAScript 6» / 24 февраля 2014
-
«Введение в стрелочные функции (arrow functions) в JavaScript ES6» / 14 окт 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».
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/719138/
Добавить комментарий