June 24, 2020

JS фундамент или как подготовиться к собеседованию

Типы данных

В JavaScript есть 7 основных типов.

  • number для любых чисел: целочисленных или чисел с плавающей точкой.
  • string для строк. Строка может содержать один или больше символов, нет отдельного символьного типа.
  • boolean для true/false.
  • null для неизвестных значений – отдельный тип, имеющий одно значение null.
  • undefined для неприсвоенных значений – отдельный тип, имеющий одно значение undefined.
  • object для более сложных структур данных.
  • symbol для уникальных идентификаторов.

Оператор typeof позволяет нам увидеть, какой тип данных сохранён в переменной.

  • Имеет две формы: typeof x или typeof(x).
  • Возвращает строку с именем типа. Например, "string".
  • Для null возвращается "object" – это ошибка в языке, на самом деле это не объект.

Тип данных Symbol ещё дополнительно погуглить

Создаются новые символы с помощью функции Symbol()

// Создаём новый символ - id
let id = Symbol();

Символы гарантированно уникальны.

let id1 = Symbol("id");
let id2 = Symbol("id");

alert(id1 == id2); // false

Символы в JavaScript имеют свои особенности, и не стоит думать о них, как о символах в Ruby или в других языках.

!!! Символы не преобразуются автоматически в строки
let id = Symbol("id");
alert(id); // TypeError: Cannot convert a Symbol value to a string

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

let id = Symbol("id");
alert(id.toString()); // Symbol(id), теперь работает

Или мы можем обратиться к свойству symbol.description, чтобы вывести только описание:

let id = Symbol("id");
alert(id.description); // id

Итого:

Символ (symbol) – примитивный тип данных, использующийся для создания уникальных идентификаторов.

Символы создаются вызовом функции Symbol(), в которую можно передать описание (имя) символа.

Даже если символы имеют одно и то же имя, это – разные символы. Если мы хотим, чтобы одноимённые символы были равны, то следует использовать глобальный реестр: вызов Symbol.for(key) возвращает (или создаёт) глобальный символ с key в качестве имени. Многократные вызовы команды Symbol.for с одним и тем же аргументом возвращают один и тот же символ.

Символы имеют два основных варианта использования:

  1. «Скрытые» свойства объектов. Если мы хотим добавить свойство в объект, который «принадлежит» другому скрипту или библиотеке, мы можем создать символ и использовать его в качестве ключа. Символьное свойство не появится в for..in, так что оно не будет нечаянно обработано вместе с другими. Также оно не будет модифицировано прямым обращением, так как другой скрипт не знает о нашем символе. Таким образом, свойство будет защищено от случайной перезаписи или использования.
    Так что, используя символьные свойства, мы можем спрятать что-то нужное нам, но что другие видеть не должны.
  2. Существует множество системных символов, используемых внутри JavaScript, доступных как Symbol.*. Мы можем использовать их, чтобы изменять встроенное поведение ряда объектов. Например, в дальнейших главах мы будем использовать Symbol.iterator для итераторов, Symbol.toPrimitive для настройки преобразования объектов в примитивы и так далее.

Контекст выполнения и стек вызовов в JavaScript

Контекст выполнения (execution context) — это, если говорить упрощённо, концепция, описывающая окружение, в котором производится выполнение кода на JavaScript. Код всегда выполняется внутри некоего контекста.

В JavaScript существует три типа контекстов выполнения:

  • Глобальный контекст выполнения
  • Контекст выполнения функции
  • Контекст eval не нужен вообще

Стек выполнения (execution stack), который ещё называют стеком вызовов (call stack), это LIFO-стек, который используется для хранения контекстов выполнения, создаваемых в ходе работы кода.

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

Стадия создания контекста выполнения

Перед выполнением JavaScript-кода создаётся контекст выполнения. В процессе его создания выполняются три действия:

  1. Определяется значение this и осуществляется привязка this (this binding).
  2. Создаётся компонент LexicalEnvironment (лексическое окружение).
  3. Создаётся компонент VariableEnvironment (окружение переменных).

Концептуально контекст выполнения можно представить так:

Привязка this

В глобальном контексте выполнения this содержит ссылку на глобальный объект (как уже было сказано, в браузере это объект window).

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

Лексическое окружение

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

В лексическом окружении имеется два компонента:

  1. Запись окружения. Это место, где хранятся объявления переменных и функций.
  2. Ссылка на внешнее окружение. Наличие такой ссылки говорит о том, что у лексического окружения есть доступ к родительскому лексическому окружению (области видимости).

Существует два типа лексических окружений:

  1. Глобальное окружение (или глобальный контекст выполнения) — это лексическое окружение, у которого нет внешнего окружения.
  2. Окружение функции, в котором, в записи окружения, хранятся переменные, объявленные пользователем. Ссылка на внешнее окружение может указывать как на глобальный объект, так и на внешнюю по отношении к рассматриваемой функции функцию.

Существует два типа записей окружения:

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

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

Лексическое окружение можно представить в виде следующего псевдокода:

Окружение переменных

Окружение переменных (Variable Environment) — это тоже лексическое окружение, запись окружения которого хранит привязки, созданные посредством команд объявления переменных (VariableStatement) в текущем контексте выполнения.

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

В ES6 существует одно различие между компонентами LexicalEnvironment и VariableEnvironment. Оно заключается в том, что первое используется для хранения объявлений функций и переменных, объявленных с помощью ключевых слов let и const, а второе — только для хранения привязок переменных, объявленных с использованием ключевого слова var.

Рассмотрим примеры, иллюстрирующие то, что мы только что обсудили:

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

Стадия выполнения кода

Это, пожалуй, самая простая часть данного материала. На этой стадии выполняется присвоение значений переменным и осуществляется выполнение кода.

Обратите внимание на то, что если в процессе выполнения кода JS-движок не сможет найти в месте объявления значение переменной, объявленной с помощью ключевого слова let, он присвоит этой переменной значение undefined.

Event loop

Почти все уже слышали о V8 Engine как о концепции, и большинство людей знают, что JavaScript является однопоточным или использует колбек очередь.

Движок JavaScript

Вот очень упрощенное представление о том, как выглядит движок V8:

Механизм состоит из двух основных компонентов:

  • Memory Heap - здесь происходит выделение памяти;
  • Call Stack - именно там находятся кадры стека при выполнении кода.

The Runtime

Практически каждый разработчик JavaScript использует браузерные API (например, «setTimeout»). Однако, эти API не предоставляются движком.

Так откуда они?

Оказывается, реальность немного сложнее.

Итак, у нас есть движок, веб-API, который предоставляется браузерами. А еще, у нас есть event loop и коллбек очередь (callback queue).

Стек вызовов

JavaScript - это однопоточный язык программирования, что означает, что он имеет один стек вызовов. Поэтому он может делать одну вещь за раз.

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

Каждая запись в стеке вызовов называется кадром стека (Stack Frame).

«Blowing the stack » - происходит, когда вы достигаете максимального размера стека вызовов. Это может произойти при использовании рекурсии без тщательного тестирования кода.

Посмотрите на пример:

function foo() {
  foo();
}

Чтобы выполнить тяжелый код, не блокируя пользовательский интерфейс и не заставляя браузер виснуть нужно использовать асинхронные колбеки.

Примеры работы с Event Loop

На небольшом примере мы рассмотрим, как работает Event Loop:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

После выполнения этот код выведет:

// foo
// bar
// baz

В принципе, как и ожидалось.

Давайте подробно разберём, как этот код обрабатывается через Event Loop. Когда код выполняется, первым вызывается foo(), внутри foo() первой вызывается bar(), а затем baz().

В этот момент стек вызовов выглядит так:

Цикл обработки событий на каждой итерации проверяет, есть ли в стеке вызовы, и если да, выполняет их:

Этот процесс продолжается до тех пор, пока стек не станет пустым.

Порядок выполнения функций

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

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

setTimeout(() => {}, 0);

Рассмотрим следующий пример:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo =() => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()

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

// foo
// baz
// bar

Когда этот код выполняется, сначала вызывается foo(). Внутри foo() мы сначала вызываем setTimeout, передавая bar в качестве аргумента, а временным интервалом указываем 0, чтобы вызов произошёл настолько быстро, насколько это возможно. Затем мы вызываем baz().

На этом этапе стек вызовов выглядит следующим образом:

Порядок вызова функций в этом случае будет выглядеть так:

Далее мы рассмотрим, почему так происходит.

Очередь сообщений (The Message Queue)

Когда вызывается setTimeout, браузер или Node.js запускают таймер. Когда время таймера истекает (в нашем случае это произойдёт немедленно, так как мы указали 0 в качестве временного интервала), наша callback-функция будет помещена в очередь сообщений.

В очередь сообщений также помещаются события, инициируемые пользователем (клик, нажатие клавиш на клавиатуре, движение мышки, сетевые запросы, такие как fetch), а также события, генерируемые DOM, например onLoad.

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

Нам не нужно ждать, пока такие функции, как setTimeout, fetch или другие выполняют свою работу, поскольку они предоставляются браузером и живут в своих потоках. Например, если вы установите время ожидания setTimeout равным 2 секундам, вам не придётся ждать 2 секунды — ожидание происходит в отдельном потоке.

Очередь заданий (ES6 Job Queue)

ECMAScript 2015 представил концепцию очереди заданий, которая используется в Promises (также представлена в ES6/ES2015). Это способ выполнить результат асинхронной функции как можно скорее, а не помещать его в конец стека вызовов. Обещания, которые разрешаются до завершения текущей функции, будут выполняться сразу после текущей функции.

Это своего рода VIP-очередь, обработка которой имеет приоритет по отношению к обычной очереди.

Пример:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) => 
    resolve('shold be right after baz, before bar')
  ).then(resolve => console.log(resolve))
}

foo()

Результат:

foo
baz
shold be right after baz, before bar
bar

В этом большая разница между Promises (а также async/await, который построен на Promises) и привычными асинхронными функциями через setTimeout() или другие API-платформы.

Надеемся, статья поможет вам разобраться с работой Event Loop в JavaScript, включая работу с потоками, очередями событий и API браузера. Для наглядности мы рассмотрели несколько примеров и то, как их можно оптимизировать с точки зрения производительности.

call, apply and bind

Методы call и apply буквально идентичны друг другу и зачастую используются в JavaScript для того, чтобы заимствовать методы и выставлять значения this.

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

Пример с методом call:

Пример с apply:

Пример bind:

Замыкания

Что такое замыкание?

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

Что такое лексическое окружение?

Понятие «лексическое окружение» или «статическое окружение» в JavaScript относится к возможности доступа к переменным, функциям и объектам на основе их физического расположения в исходном коде. Рассмотрим пример:

let a = 'global'
  function outer() {
    let b = 'outer'
    fucntion inner() {
      let c = 'inner'
      console.log(c)
      console.log(b)
      console.log(a)
    }
    console.log(a)
    console.log(b)
    inner()
  }
outer()
console.log(a)

Обратите внимание на то, что функция inner() окружена лексическим окружением функции outer(), которая, в свою очередь, окружена глобальной областью видимости. Именно поэтому функция inner() может получить доступ к переменным, объявленным в функции outer() и в глобальной области видимости.

Практические примеры замыканий

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

Привет 1:

function person() {
  let name = 'Peter'

  return function displayName() {
    console.log(name)
  }
}

let peter = person();
peter(); // Peter

Здесь мы вызываем функцию person(), которая возвращает внутреннюю функцию displayName(), и сохраняем эту функцию в переменной peter. Когда мы, после этого, вызываем функцию peter() (соответствующая переменная, на самом деле, хранит ссылку на функцию displayName()), в консоль выводится имя Peter.

При этом в функции displayName() нет переменной с именем name, поэтому мы можем сделать вывод о том, что эта функция может каким-то образом получать доступ к переменной, объявленной во внешней по отношению к ней функции, person(), даже после того, как эта функция отработала. Возможно это так из-за того, что функция displayName(), на самом деле, является замыканием.

function getCounter() {
  let counter = 0
  return function() {
    return counter++
  }
}

let count = getCounter();
console.log(count()) // 0
console.log(count()) // 1
console.log(count()) // 2

Тут, как и в предыдущем примере, мы храним ссылку на анонимную внутреннюю функцию, возвращённую функцией getCounter(), в переменной count. Так как функция count() представляет собой замыкание, она может обращаться к переменной counter функции getCount() даже после того, как функция getCounter() завершила работу.

Обратите внимание на то, что значение переменной counter не сбрасывается в 0 при каждом вызове функции count(). Может показаться, что оно должно сбрасываться в 0, как могло бы быть при вызове обычной функции, но этого не происходит.

Всё работает именно так из-за того, что при каждом вызове функции count() для неё создаётся новая область видимости, но существует лишь одна область видимости для функции getCounter(). Так как переменная counter объявлена в области видимости функции getCounter(), её значение между вызовами функции count() сохраняется, не сбрасываясь в 0.

Как работают замыкания?

Для того чтобы понять замыкания, нам нужно разобраться с двумя важнейшими концепциями JavaScript. Это — контекст выполнения (Execution Context) и лексическое окружение (Lexical Environment).

Контент выполнения

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

В некий момент времени может выполняться код лишь в одном контексте выполнения (JavaScript — однопоточный язык программирования). Управление этими процессами ведётся с использованием так называемого стека вызовов (Call Stack).

Стек вызовов — это структура данных, устроенная по принципу LIFO (Last In, First Out — последним вошёл, первым вышел). Новые элементы можно помещать только в верхнюю часть стека, и только из неё же элементы можно изымать.

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

Рассмотрим следующий пример для того, чтобы лучше разобраться в том, что такое контекст выполнения и стек вызовов:

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

Стек вызовов этого кода выглядит так:

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

Лексическое окружение

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

Лексическое окружение — это структура данных, которая хранит сведения о соответствии идентификаторов и переменных. Здесь «идентификатор» — это имя переменной или функции, а «переменная» — это ссылка на объект (сюда входят и функции) или значение примитивного типа.

Лексическое окружение содержит два компонента:

  • Запись окружения (environment record) — место, где хранятся объявления переменных и функций.
  • Ссылка на внешнее окружение (reference to the outer environment) — ссылка, позволяющая обращаться к внешнему (родительскому) лексическому окружению. Это — самый важный компонент, с которым нужно разобраться для того, чтобы понять замыкания.

Концептуально лексическое окружение выглядит так:

lexicalEnvironment = {
  environmentRecord: {
    <identifier> : <value>,
    <identifier> : <value>
  }
  // Ссылка на родительскую лексическую среду
  outer: <Reference to the parent lexical environment>
}

Взглянем на следующий фрагмент кода:

let a = 'Hello World!';
function first() {
  let b = 25;
  console.log('Inside first function');
}
first();
console.log('Inside global execution context');

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

globalLexicalEnvironment = {
  environmentRecord: {
    a     : 'Hello World!',
    // ссылка на объект функции
    first : <reference to function object>
  },
  outer: null
}

Обратите внимание на то, что ссылка на внешнее лексическое окружение (outer) установлена в значение null, так как у глобальной области видимости нет внешнего лексического окружения.

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

functionLexicalEnvironment = {
  environmentRecord: {
    b : 25
  },
  // глобальная лексическая среда
  outer: <globalLexicalEnvironment>
}

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

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

Подробный разбор примеров работы с замыканиями

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

Привер 1:

Взгляните на данный фрагмент кода:

function person() {
  let name = 'Peter'
  
  return function displayName() {
    console.log(name)
  }
}

let peter = person()
peter() // Peter

Когда выполняется функция person(), JS-движок создаёт новый контекст выполнения и новое лексическое окружение для этой функции. Завершая работу, функция возвращает функцию displayName(), в переменную peter записывается ссылка на эту функцию.

Её лексическое окружение будет выглядеть так:

personLexicalEnvironment = {
  environmentRecord: {
    name : 'Peter',
    displayName: <displayName function reference>
  }
  outer: <globalLexicalEnvironment>
}

Когда функция person() завершает работу, её контекст выполнения извлекается из стека. Но её лексическое окружение остаётся в памяти, так как ссылка на него есть в лексическом окружении её внутренней функции displayName(). В результате переменные, объявленные в этом лексическом окружении, остаются доступными.

Когда вызывается функция peter() (соответствующая переменная хранит ссылку на функцию displayName()), JS-движок создаёт для этой функции новый контекст выполнения и новое лексическое окружение. Это лексическое окружение будет выглядеть так:

displayNameLexicalEnvironment = {
  environmentRecord: {}
  outer: <personLexicalEnvironment>
}

В функции displayName() нет переменных, поэтому её запись окружения будет пустой. В процессе выполнения этой функции JS-движок попытается найти переменную name в лексическом окружении функции.

Так как в лексическом окружении функции displayName() искомое найти не удаётся, поиск продолжится во внешнем лексическом окружении, то есть, в лексическом окружении функции person(), которое всё ещё находится в памяти. Там движок находит нужную переменную и выводит её значение в консоль.

Пример 2:

function getCounter() {
  let counter = 0
  return function() {
    return counter++
  }
}

let count = getCounter();
console.log(count()) // 0
console.log(count()) // 1
console.log(count()) // 2

Лексическое окружение функции getCounter() будет выглядеть так:

getCounterLexicalEnvironment = {
  environmentRecord: {
    counter: 0,
    <anonymous function> : <reference to function>
  },
  outer: <globalLexicalEnvironment>
}

Эта функция возвращает анонимную функцию, которая назначается переменной count.

Когда выполняется функция count(), её лексическое окружение выглядит так:

countLexicalEnvironment = {
  environmentRecord: {}
  outer: <getCountLexicalEnvironment>
}

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

Движок находит переменную, выводит её в консоль и инкрементирует переменную counter, хранящуюся в лексическом окружении функции getCounter().

В результате лексическое окружение функции getCounter() после первого вызова функции count() будет выглядеть так:

getCounterLexicalEnvironment = {
  environmentRecord: {
    counter: 1,
    <anonymous function> : <reference to function>
  }
  outer: <globalLexicalEnvironment>
}

При каждом следующем вызове функции count() JavaScript-движок создаёт новое лексическое окружение для этой функции и инкрементирует переменную counter, что приводит к изменениям в лексическом окружении функции getCounter().

Итоги

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

Promise

Объект Promise (промис) используется для отложенных и асинхронных вычислений.

Итак, почему я должен изучать промисы, снова?

До появления обещаний разработчики JavaScript использовали функции обратного вызова. setTimeout, XMLHttpRequest, да и в основном все браузерные асинхронные функции основаны на коллбэках.

Чтобы продемонстрировать проблему с функциями обратного вызова, давайте сделаем некоторые анимации без HTML и CSS.

Допустим, мы хотим хотим сделать следующее:

  • запустить некоторый код
  • подождать одну секунду
  • запустить другой код
  • подождать еще секунду
  • затем запустить еще один код

Такой шаблон часто используется в CSS3 анимации. Давайте реализуем его с помощью нашего верного друга setTimeout. Код будет выглядеть примерно так:

runAnimation(0);
setTimeout(function() {
  runAnimation(1);
  setTimeout(function() {
    runAnimation(2);
  }, 1000)
}, 1000)

Выглядит ужасно, не правда ли? А представьте себе на минуту, что вам нужно сделать 10 шагов, а не 3 — какую пирамиду из отступов вам придется построить. Это настолько плохо, что люди даже придумали специальное название — callback hell. И такие пирамиды из функций обратного вызова появляются везде — в обработке HTTP запросов, при работе с базой данных, при анимации, при реализации взаимодействия между процессами, и в других местах. Но:

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

Но же что они обещают?

Возможно, самый простой способ разобраться с тем, как работают обещания — сравнить их с коллбэками. Есть четыре основных отличия:

Коллбэки являются функциями, обещания являются объектами

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

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

Коллбэки передаются в качестве аргументов, обещания возвращаются

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

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

Коллбэки обрабатывают успешное или неуспешное завершение, обещания ничего не обрабатывают

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

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

Коллбэки могут обрабатывать несколько событий, обещания связаны только с одним событием

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

Обещания могут представлять только одно событие — они обратывают либо успешное его завершение, либо неуспешное только один раз.

Имея это в виду, давайте рассмотрим обещания более детально.

Четыре функции, которые вам нужно знать

new Promise(fn)

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

// Создание экземпляра обещания, который ничего не делает.
// Не волнуйтесь, мы рассмотрим этот момент подробнее.
promise = new Promise(function() {});

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

Для того, чтобы сделать это, функция, которую вы передаете в конструктор, может принимать два параметра, которые сами являются функциями — resolve и reject. Вызов resolve(value) пометит обещание как успешно завершенное и вызовет обработчик успешного завершения. Вызов reject(error) вызовет обработчик неуспешного завершения. Нельзя вызывать обе эти функции одновременно. Функции resolve и reject обе принимают один аргумент, который содержит в себе данные о событии.

Применим это к нашему примеру с анимацией. Приведенный выше пример использует функцию setTimeout, которая принимает коллбэк, — вместо этого мы хотим вернуть обещание. Конструктор new Promise позволяет нам это сделать:

/* Возвращаем обещание, которое резолвится через определенный промежуток времени */
function delay(interval) {
  return new Promise(function(resolve) {
    setTimeout(resolve, interval);
  }
}

var oneSecondDelay = delay(1000);

Отлично, теперь у нас есть обещание, которое резолвится через секунду. Я знаю, вам, вероятно, не терпится узнать, как сделать что-то по прошествии секунды — мы вернемся к этому позже, когда будем рассматривать вторую функцию promise.then.

Функция, которую мы передаем в new Promise в приведенном примере, принимает только параметр resolve, мы опустили параметр reject. Это потому, что setTimeout выполняется всегда и, таким образом, нет сценария, где мы он мог бы завершить неуспешно.

Допустим, мы хотим проверить, поддерживается ли определенная анимация браузером, и если анимация не поддерживается, узнать об этом заранее, а не после таймаута. Если функция isAnimationSupported(step) будет проверять поддерку, мы можем реализовать это с помощью reject:

function animationTimeout(step, interval) {
  new Promise(function(resolve, reject) {
    if (isAnimationSupported(step)) {
      setTimeout(resolve, interval);
    } else {
      reject('animation not supported');
    }
  }
}

var firstKeyframe = animationTimeout(1, 1000);

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

Чтобы лучше понять это, можно представить, что содержимое каждой функции, которую вы передаете в конструктор Promise, оборачивается в try/catch, например, так:

var promise = new Promise(function(resolve, reject) {
  try {
    // your code
  } catch(e) {
    reject(e)
  }
}

Так. Теперь вы понимаете, как создать обещание. Но когда оно у нас есть, как добавить обработчики событий успеха/неудачи? Для этого мы используем метод then.

promise.then(onResolve, onReject)

Метод promise.then(onResolve, onReject) позволяет назначить обработчики событий обещания. В зависимости от аргументов, вы можете обработать событие успешного завершения, отказ, или оба:

// Только обработчик успеха
promise.then(function(details) {
  // handle success
}

// Только обработчик отказа
promise.then(null, function(error) {
  // handle failure
}

// Обработчики успеха и отказа
promise.then(
  function(details) { /* handle success */ },
  function(error) { /* handle failure */ }
)

Не пытайтесь обработать ошибки, возникающие в функции onResolve, в функции onError в том же вызове then. Это не работает.

// Это вызовет слезы и ужас
promise.then(
  function() {
    throw new Error('tears')
  },
  function(error) {
    // Не будет вызван
    console.log(error)
  }
)

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

promise.then всегда возвращает обещание

Это работает с числами, строками и другими типами:

delay(1000)
  .then(function() {
    return 5;
  })
  .then(function(value) {
    console.log(value) //5
  })

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

delay(1000)
  .then(function() {
    console.log('1 second elapsed')
    return delay(1000)
  })
  .then(function() {
    console.log('2 seconds elapsed')
  })

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

До этого все было относительно просто, но есть несколько сложных моментов. Например:

Обработчик reject в функции promise.then возвращает успешно завершенное обещание.

Тот факт, что обработчики отказа возвращают по умолчанию успешное обещание, заставил меня помучаться, когда я только изучал эту тему — я не позволю этому случиться с вами. Вот пример того, за чем стоит следить:

new Promise(function(resolve, reject) {
  reject(' :( ');
})
  .then(null, function() {
    // Handle the rejected promise
    return 'some description of :(';
  })
  .then(
    function(data) { console.log('resolved: '+data); },
    function(error) { console.error('rejected: '+error); }
  );

Что выведется в консоли? Проверьте ответ, наведя курсор мыши над полем ниже:

resolved: some description of :(

Если вы хотите обработать ошибку, возникающую в reject, убедитесь, что не просто возвращаете значение, а возвращаете отклоненное обещание. Т.е. вместо:

return 'some description of :('

Используйте волшебный прием, возвращающий отклоненное обещание с заданным значением:

// Я расскажу об этом позже
return Promise.reject({anything: 'anything'});

Кроме того, вы можете бросить исключение, при этом поможет тот факт, что

promise.then возвращает исключения в отклоненное обещание

Это означает, что вы можете в обработчике (успеха или неудачи) вернуть отклоненное обещание, сделав следующее:

throw new Error('some description of :(')

Имейте в виду, что, как и в функции, переданной в new Promise, любое исключение, брошенное в обработчиках, переданных в promise.then, будет возвращено в отклоненное обещание — вместо того, чтобы отобразиться в консоли, как вы могли бы ожидать. Из-за этого важно убедиться, что вы закончите всю цепочку только обработчиком reject — в противном случае вы можете потратить часы, пытаясь понять, где же ошибка (другое решение вы можете посмотреть в статье Are JavaScript Promises swallowing your errors? ).

Пример, демонстрирующий это решение:

delay(1000)
  .then(function() {
    throw new Error("oh no.");
  })
  .then(null, function(error) {
    console.error(error);
  })

promise.catch(onReject)

Здесь все просто. promise.catch(handler) — это эквивалент promise.then(null, handler).

Нет, если серьезно, это не все, что он делает.

Один из паттернов — добавлять catch в конце каждой цепочки обещаний. Давайте вернемся к примеру с анимацией для демонстрации.

Допустим, у нас есть три шага анимации, с секундным отставанием между ними. Каждый шаг может бросить исключение, — например, из-за отсутствия поддержки браузером — после каждого then мы добавим блок catch, в котором сделаем нужные изменения, но без анимации.

Можете ли вы написать это, используя функцию delay, при условии, что на каждом шаге анимации можно назвать функцию runAnimation(number), а в качестве резерва можно вызвать runBackup(number)? Проверять нужно каждый шаг в отдельности, а не все, на случай, если бразуер все же может выполнить какие-то из шагов. Для проверки ответа наведите курсор на блок ниже.

try {
  runAnimation(0)
}
catch(e) {
  runBackup(0)
}

delay(1000)
  .then(function() {
    runAnimation(1)
    return delay(1000);
  })
  .catch(function() {
    runBackup(1)
  })
  .then(function() {
    runAnimation(2)
  })
  .catch(function() {
    runBackup(2)
  })

На сколько ваше решение похоже на мое? Если у вас есть вопросы, почему я сделал именно так, оставляйте комментарии!

В приведенном выше примере интересно сходство между блоком try/catch и обещаниями. Некоторые люди думают об общаниях как об отложенных блоках try/catch — я так не делаю, но думаю, это не повредит.

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

Promise.all([promise1, promise2, …])

Функция Promise.all действительно удивительна. То, что доставляло столько боли при реализации на коллбэках, что я даже не решился привести пример, с ее помощью сделать очень просто.

Что она делает? Она возвращает обещание, которое успешно, если все аргументы успешны, и отклонен, когда любой из его аргументов отклонен. В случае успеха результирующее обещание содержит массив результатов каждого обещания, а в случае неудачи — ошибку первого неуспешного обещания.

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

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

Или можете использовать Promise.all.

Promise.all([
  parallelAnimation1()
  parallelAnimation2()
]).then(function() {
    finalAnimation()
})

Просто, не правда ли?

Другие случаи для использования Promise.all — загрузка нескольких HTTP-запросов одновременно, запуск нескольких процессов одновременно, или несколько одновременных запросов к базе данных. С Promise.all сделать это все легко.

Async/Awayt

soon