Стрелочные функции в JavaScript: зачем они нужны, как с ними обращаться, когда ими стоит пользоваться, а когда — нет +20


Одной из самых заметных новшеств современного JavaScript стало появление стрелочных функций (arrow function), которые иногда называют «толстыми» стрелочными функциями (fat arrow function). При объявлении таких функций используют особую комбинацию символов — =>.

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

image

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

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

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

Особенности стрелочных функций в JavaScript


Стрелочные функции в JavaScript — это нечто вроде лямбда-функций в Python и блоков в Ruby.

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

Поговорим об этом подробнее.

?Синтаксис стрелочных функций


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

(argument1, argument2, ... argumentN) => {
  // тело функции
}

Список аргументов функции находится в круглых скобках, после него следует стрелка, составленная из символов = и >, а дальше идёт тело функции в фигурных скобках.

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

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

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

const add = (a, b) => a + b;

Вот ещё один вариант сокращённой записи функции, применяемый в том случае, когда функция имеет лишь один аргумент.

const getFirst = array => array[0];

Как видите, тут опущены круглые скобки, обрамляющие список аргументов. Кроме того, тело функции, которое и в этом примере представлено одной командой, так же записано без скобок. Позже мы ещё поговорим о преимуществах подобных конструкций.

?Возврат объектов и сокращённая запись стрелочных функций


При работе со стрелочными функциями используются и некоторые более сложные синтаксические конструкции, о которых полезно знать.

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

(name, description) => {name: name, description: description};

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

Для того чтобы указать системе на то, что мы имеем в виду именно объектный литерал, нужно заключить его в круглые скобки:

(name, description) => ({name: name, description: description});

?Стрелочные функции и включающий их контекст выполнения


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

На практике это означает, что они наследуют сущности this и arguments от родительской функции.

Например, сравним две функции, представленные в следующем коде. Одна и них обычная, вторая — стрелочная.

const test = {
  name: 'test object',
  createAnonFunction: function() {
    return function() {
      console.log(this.name);
      console.log(arguments);
    };
  },

  createArrowFunction: function() {
    return () => {
      console.log(this.name);
      console.log(arguments);
    };
  }
};

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

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

> const anon = test.createAnonFunction('hello', 'world');
> const arrow = test.createArrowFunction('hello', 'world');

> anon();
undefined
{}
> arrow();
test object
{ '0': 'hello', '1': 'world' }

У анонимной функции есть собственный контекст, поэтому, когда её вызывают, при обращении к test.name не будет выдано значение свойства name объекта, а при обращении к arguments не будет выведен список аргументов функции, которая использовалась для создания и возврата исследуемой функции.

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

Ситуации, в которых стрелочные функции улучшают код


?Обработка списков значений


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

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

const words = ['hello', 'WORLD', 'Whatever'];
const downcasedWords = words.map(word => word.toLowerCase());

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

const names = objects.map(object => object.name);

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

this.examples.forEach(example => {
  this.runExample(example);
});

?Промисы и цепочки промисов


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

Так, промисы значительно упрощают работу с асинхронным кодом. При этом, даже если вы предпочитаете использовать конструкцию async/await, то без понимания промисов вам не обойтись, так как эта конструкция основана на них.

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

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

this.doSomethingAsync().then((result) => {
  this.storeResult(result);
});

?Трансформация объектов


Ещё один распространённый пример использования стрелочных функций заключается в инкапсуляции трансформаций объектов.

Например, в Vue.js существует общепринятый паттерн включения фрагментов хранилища Vuex напрямую в компонент Vue с использованием mapState.

Эта операция включает в себя объявления набора «преобразователей», которые выбирают из исходного полного состояния именно то, что нужно для конкретного компонента.

Такие вот простые преобразования — идеальное место для использования стрелочных функций. Например:

export default {
  computed: {
    ...mapState({
      results: state => state.results,
      users: state => state.users,
    });
  }
}

Ситуации, в которых не следует использовать стрелочные функции


?Методы объектов


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

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

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

class Counter {
  counter = 0;

  handleClick = () => {
    this.counter++;
  }
}

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

Однако у такого подхода масса минусов, которым посвящён этот материал.

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

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

class Counter {
  counter = 0;

  handleClick() {
    this.counter++;
  }

  constructor() {
    this.handleClick = this.handleClick.bind(this);
  }
}

?Длинные цепочки вызовов


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

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

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

{anonymous}()
{anonymous}()
{anonymous}()
{anonymous}()
{anonymous}()

?Функции с динамическим контекстом


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

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

Вот некоторые вещи, о которых нужно помнить, рассматривая возможность использования стрелочных функций:

  • Обработчики событий вызываются с this, привязанным к атрибуту события currentTarget.
  • Если вы всё ещё пользуетесь jQuery, учитывайте, что большинство методов jQuery привязывают this к выбранному элементу DOM.
  • Если вы пользуетесь Vue.js, то методы и вычисляемые функции обычно привязывают this к компоненту Vue.

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

Итоги


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

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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (26):

  1. morsic
    /#19333222

    Не упомянули про генераторы — пока что синтаксис =>* только пропозал.

  2. sinneren
    /#19333648 / -1

    стрелочные функции позволяют писать более чистый и понятный код

    вот только всё наоборот.

  3. KevlarBeaver
    /#19333708

    Очень хотел встретить слово «замыкание» в статье. Но нет.

  4. EvilGenius18
    /#19333858 / +3

    const getFirst = array => array[0];

    Почему во многих статьях используются совершенно нелогичные примеры? Создавать функцию для получения первого элемента списка? Серьезно?

    const getFirst = array => array[0];
    getFirst(a)

    вместо:

    a[0]


    Почему бы не использовать реальные примеры из реальных программ, чтобы даже новички видели преимущество тех или иных методов, и могли сразу применять «правильные» паттерны в разработке, вместо того, чтобы писать программы, состоящие из подобных примеров, и потом их исправлять, после того, как эти же более опытные разработчики (пищущие подобные статьи) укажут им на то, что их код «не оптимален»?

    • melodyn
      /#19334248

      Более того, на массивах тогда уж логичнее делать примеры деструктуризации


      [first, last] = [1, 2];

      А для стрелочных функций просто приводить пример реализации на старом синтаксисе и новом или, например, реверс строки в одну строку:


      const reverse = (str, i = 0) => (i >= str.length) ? '' : `${reverse(str, i + 1)}${str[i]}`;

    • FixIt7
      /#19338990 / -1

      На мой взгляд, сложные примеры «из жизни» перегружают материал, и становится сложно донести основную идею.

      const reverse = (str, i = 0) => (i >= str.length) ? '' : `${reverse(str, i + 1)}${str[i]}`;


      Я изучаю JS. И я разбирался в этом примере из «риал прожект» дольше, чем читал статью, и всё равно не понял почему не написать так:

      const reverse = (str, i = 0) => (i >= str.length) ? '' : `${reverse(str, i)}`;


      Чтобы понять, придётся провести целое иследование в интернете.

      • mayorovp
        /#19339114

        Если написать так как вы написали — будет бесконечная рекурсия. Я вот другого не понимаю: зачем тут вообще интерполяция?


        const reverse = (str, i = 0) => (i >= str.length) ? '' : reverse(str, i + 1) + str[i];

        Правда, в таком виде оно все еще непонятно, как и любая замена цикла рекурсией или рекурсии циклом.

        • melodyn
          /#19339674

          зачем тут вообще интерполяция?

          Чтобы гарантированно работать со строкой. В случае вызова reverse('123') без большого опыта работы с JS трудно предугадать как он себя поведёт и не вернёт ли 6, так как идёт конкатенация через плюс. Проверил, что в вашей реализации конкатенация отрабатывает как надо, но лучше перебдеть, чем недобдеть :)

    • Druu
      /#19341042 / +1

      Ну представьте, что вам надо сделать arr.map(x => x[0]);

      • mayorovp
        /#19341352

        Вот именно эту строчку и было бы неплохо увидеть в статье.

  5. Zenitchik
    /#19334862 / +1

    Когда я был маленьким, всего этого нельзя было прочитать на developer.mozilla.org.
    Но сейчас-то нахрена учебники на Хабр переписывать?

    • Juma
      /#19335296

      Я пологаю для истории. Пройдет много времени и спецификация языка поменяется, странички на developer.mozilla.org обновятся. А эта статья так и останется на хабре. Можно будет зайти сюда, почитать, по ностальгировать. Вспомнить времена когда JS был еще тёплым, ламповым.

      • pterodaktil
        /#19338992

        Все какие-либо структурные изменения уходят в deprecated метку, все равно для саппорта будут оставлять старые реализации

    • Vadem
      /#19339582

      Пока такие статьи плюсуют их будут здесь постить.

  6. Senyaak
    /#19337090

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

    • mayorovp
      /#19337404

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

      • Senyaak
        /#19340044

        Было такое что после обновления версии транспайлера — перестал работать код — причина оказалась в неправильная интерпретация замыканий в методе функции — не помню какой транспайлер использовался — но было)

  7. UncleJey
    /#19337890

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

  8. unel
    /#19338776

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

    Проблемы, обсуждаемые по приведённой вами ссылке как раз вызваны тем, что метод прописывается в инстанс объекта (каждый раз при создании), а не в его прототип. А такой "болезни" подвержен и ваш пример с


    this.handleClick = this.handleClick.bind(this);

    Ну и про производительность там тоже в комментариях намекнули, что какие-то у него странные тесты, что стрелочная функция вышла медленней функции после .bind, они примерно одинаковые по производительности. Автор потом конечно приводит ссылку на тест, но по ней отдаётся 404 =(

    • mayorovp
      /#19338812

      Нет, вариант с bind этим проблемам не подвержен, потому что создает два разных метода: один обычный и остается в прототипе, а второй — привязанный и хранится в объекте.

      • unel
        /#19343456

        Хм, да, тут вы правы, что-то я не подумал об этом.

  9. Apathetic
    /#19340818

    Шел 2018 год.

  10. bingo347
    /#19342306 / -1

    Каждый раз, когда я вижу подобный код:

    class Counter {
      counter = 0;
    
      handleClick() {
        this.counter++;
      }
    
      constructor() {
        this.handleClick = this.handleClick.bind(this);
      }
    }
    
    мне хочется оторвать руки написавшему это…

    Ну во-первых, не стандарт. Какой мне плагин к бабелю нужно подрубить, чтоб это заработало? Проще поправить:
    class Counter {
      constructor(elem) {
        this.counter = 0;
        this.handleClick = this.handleClick.bind(this);
        // очевидно нужно еще событие навесить, чтоб заработало
        elem.addEventListener('click', this.handleClick);
      }
    
      handleClick() {
        this.counter++;
      }
    }
    
    теперь заработало… вот только я таких счетчиков решил 500 штук повесить на странице, и… забью память на 500 штук однотипных функций, единственная роль которых запомнить контекст для вызова метода. Уж не лучше тогда было стрелочник в конструкторе повесить:
    class Counter {
      constructor(elem) {
        this.counter = 0;
        this.handleClick = () => this.counter++;
        elem.addEventListener('click', this.handleClick);
      }
    }
    
    уже лучше, вот только в памяти по прежнему 500 функций… Нехорошо.
    Почему бы не реализовать интерфейс EventListener и использовать его?
    class Counter {
      constructor(elem) {
        this.counter = 0;
        elem.addEventListener('click', this);
      }
    
      handleEvent() {
        this.counter++;
      }
    }
    
    Теперь идеально, на все 500 объектов в памяти одна единственная функция в прототипе.