Сравнение методов асинхронного программирования JavaScript

Некоторое время назад я написал статью о обещаниях JavaScript и Node.js. В этой статье я сравниваю обещания встроенного JavaScript, которые были представлены в ES6, с наблюдаемыми, которые предоставляются тегом RxJS .

Основное внимание уделяется выявлению различий и сходств обещаний и наблюдаемых. Цель состоит в том, чтобы упростить понимание наблюдаемых, если вы уже знаете обещания (или наоборот). По этой причине я не рассматриваю в этой статье операторы RxJS, потому что не существует ничего, что можно было бы сравнить с этими операторами для обещаний.

Обратите внимание, что эта статья основана на RxJS 6, новейшей версии RxJS, выпущенной 24 апреля 2018.

СОДЕРЖАНИЕ

Асинхронное программирование на JavaScript

Обещания против наблюдаемых

Асинхронное программирование в JavaScript

Прежде всего, давайте вспомним, что такое обещания и наблюдаемые: обработка асинхронного выполнения. В JavaScript есть разные способы создания асинхронного кода. Наиболее важные из них:

Кратко представим каждого из них.

Обратные вызовы

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

Главный недостаток этого подхода возникает, когда у вас есть несколько связанных асинхронных задач, что требует от вас определения функций обратного вызова внутри функций обратного вызова в функциях обратного вызова ... Это называется ад обратного вызова.

Обещания

Обещания были введены в ES6 (2015), чтобы обеспечить более читаемый асинхронный код, чем это возможно с обратными вызовами.

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

На практике это выглядит так:

const promise = asyncFunc();
promise.then(result => {
    console.log(result);
});

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

Асинхронный / Ожидание

Async / await был введен в ES8 (2017). Этот метод действительно должен быть указан в разделе promises, потому что это всего лишь синтаксический сахар для работы с обещаниями. Однако это синтаксический сахар, на который действительно стоит обратить внимание.

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

Кроме того, сама функция async также возвращает обещание, которое разрешается после завершения выполнения тела функции.

Давайте посмотрим, как это выглядит на практике, на следующем примере:

function asyncTask(i) {
    return new Promise(resolve => resolve(i + 1));
}
async function runAsyncTasks() {
    const res1 = await asyncTask(0);
    const res2 = await asyncTask(res1);
    const res3 = await asyncTask(res2);
    return "Everything done"
}
runAsyncTasks().then(result => console.log(result));

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

С другой стороны, функция runAsyncTasks объявлена ​​async, поэтому ключевое слово await можно использовать в ее теле. Эта функция вызывает asyncTask три раза, и каждый раз аргумент должен быть результатом предыдущего вызова asyncTask (т.е. мы связываем три асинхронные задачи).

Первое ключевое слово await приводит к остановке выполнения runAsyncTasks до тех пор, пока не будет выполнено обещание, возвращаемое asyncTask(0). Затем все выражение await asyncTask(0) оценивается как результирующее значение разрешенного обещания и присваивается res1. В этот момент вызывается asyncTask(res1), и второе ключевое слово await заставляет выполнение runAsyncTasks снова останавливаться до тех пор, пока обещание, возвращаемое asyncTask(res1), не будет выполнено. Это продолжается до тех пор, пока не будут выполнены все операторы в теле функции runAsyncTasks.

Как уже упоминалось, функция async возвращает само обещание, которое разрешается с помощью возвращаемого значения функции после завершения выполнения тела функции. Другими словами, функция async сама по себе является асинхронной задачей (которая обычно управляет выполнением других асинхронных задач). Это можно увидеть в последней строке, где мы вызываем функцию then в возвращенном обещании, чтобы распечатать возвращаемое значение функции async.

Если вы добавите операторы журнала после каждого оператора присваивания в функции runAsyncTasks, то результат будет выглядеть следующим образом:

1
2
3
Everything done

Я утверждал, что async / await - это просто синтаксический сахар для обещаний. Если это правда, то мы должны иметь возможность реализовать приведенный выше пример с чистыми обещаниями. Да, можем, и вот как это будет выглядеть:

function asyncTask(i) {
    return new Promise(resolve => resolve(i + 1));
}
function runAsyncTasks() {
    return asyncTask(0)
        .then(res1 => { return asyncTask(res1); })
        .then(res2 => { return asyncTask(res2); })
        .then(res3 => { return "Everything done"; });
}
runAsyncTasks().then(result => console.log(result));

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

Единственное, что изменилось, - это функция runAsyncTasks. Теперь это обычная функция (не async), и она использует then для цепочки обещаний, возвращаемых asyncTask (вместо await).

Я думаю, излишне говорить, что версия async / await намного читабельнее и легче для понимания, чем версия обещания. Фактически, главное новшество async / await - это возможность писать асинхронный код с обещаниями, которые «выглядят как» синхронный код.

Наблюдаемые RxJS

Начнем с того, что RxJS - это реализация JavaScript проекта ReactiveX. Проект ReactiveX направлен на предоставление API для асинхронного программирования для разных языков программирования.

Фундаментальной парадигмой ReactiveX является банда четырех шаблон наблюдателя (ReactiveX даже расширяет шаблон наблюдателя с помощью уведомлений о завершении и ошибках). Следовательно, центральной абстракцией всех реализаций ReactiveX является наблюдаемое. Подробнее об основных концепциях ReactiveX вы можете прочитать здесь.

ReactiveX API реализован на различных языках программирования. Как уже упоминалось, RxJS - это реализация ReactiveX на JavaScript. Кроме того, существуют, например, RxJava (Java), RxKotlin (Kotlin), Rx.rb (Ruby), Реализации RxPY (Python), RxSwift (Swift), Rx.NET (C #) и многие другие (см. Обзор здесь).

Это означает, что если вы понимаете наблюдаемые в RxJS, то вы также понимаете наблюдаемые в RxJava или Rx.NET или в любой другой реализации, и вы можете использовать эти библиотеки без необходимости изучать новые концепции.

Итак, теперь мы знаем, что такое RxJS, но что такое наблюдаемое? Давайте попробуем охарактеризовать его по двум измерениям и сравнить с другими известными абстракциями. Возможны следующие параметры: синхронность / асинхронность и одно значение / несколько значений.

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

  • Выдает кратные значения
  • Передает свои значения асинхронно («push»)

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

  • Выдает одно значение
  • Передает свое значение асинхронно («push»)

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

  • Испускает несколько значений
  • Излучает свои значения синхронно («вытягивает»)

Обратите внимание, что с s ynchronous / pull и asynchronous / push я имею в виду следующее: синхронный / pull означает, что клиентский код запрашивает значение из абстракции и блоков, пока это значение не будет возвращено. Асинхронный / push означает, что абстракция уведомляет клиентский код о том, что создается новое значение, а клиентский код обрабатывает это уведомление.

Если расположить эти абстракции по размерам графически, мы получим следующую картинку (взято из ReactiveX):

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

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

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

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

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

Теперь давайте посмотрим на итерацию. Во многих языках программирования мы можем создать итерацию из структуры данных коллекции, такой как массив. Итерируемый объект обычно имеет метод next, который возвращает следующее непрочитанное значение из коллекции. Затем вызывающий код может повторно вызывать next, чтобы прочитать все значения коллекции. Каждый вызов next по сути является синхронной блокирующей операцией get, как описано выше (вызывающий код неоднократно извлекает значения).

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

Значения, которые испускает наблюдаемый объект, могут быть любыми: элементы массива, результат HTTP-запроса (нормально, если наблюдаемый испускает только одно значение, это не всегда должно быть несколько значений), события ввода пользователя, такие как щелчки мыши и т. д. Это делает наблюдаемые объекты очень гибкими. Более того, поскольку наблюдаемый объект может также генерировать только одно значение, наблюдаемый объект может делать все, что может сделать обещание, но обратное неверно.

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

Например, есть оператор map, который мы могли бы настроить следующим образом: map(value => 2 * value), а затем мы можем применить этот оператор к наблюдаемому. Эффект состоит в том, что каждое значение, которое испускает наблюдаемый объект, умножается на два, прежде чем оно будет передано в вызывающий код.

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

Ниже приведен короткий пример кода, показывающий создание и использование наблюдаемого объекта RxJS (синтаксис для создания и использования наблюдаемых объектов будет объяснен в следующем разделе):

// Creation
const observable = new Observable(observer => {
  for (let i = 0; i < 3; i++) {
    observer.next(i);
  }
});
// Usage
observable.subscribe(value => console.log(value));

На этом мы завершаем обзор методов асинхронного программирования в JavaScript. Мы видели, что есть устаревшие обратные вызовы, обещания, которые можно использовать для асинхронного получения одного значения, async / await. , который является синтаксическим сахаром для обещаний, и RxJS наблюдаемыми, который можно использовать для асинхронного получения потоков значений.

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

Обещания против наблюдаемых

В этом разделе мы сравниваем обещания и наблюдаемые вместе и подчеркиваем их различия и сходства.

Обратите внимание: если вы хотите запустить следующие примеры кода, которые включают наблюдаемые объекты, вам необходимо установить и импортировать библиотеку RxJS.

Вы можете установить RxJS следующим образом:

npm install --save rxjs

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

import { Observable } from 'rxjs';

Однако, если вы используете Node.js, вам нужно выполнить импорт другим способом, как показано ниже (поскольку Node.js еще не поддерживает оператор import):

const { Observable } = require('rxjs');

Эти операторы импорта опущены во всех следующих фрагментах кода.

Творчество

Давайте посмотрим, как создать обещание и как создать наблюдаемую. Чтобы не усложнять задачу, мы сначала пренебрегаем ошибками и рассматриваем только «успешное» выполнение обещаний и наблюдаемых. Мы представим ошибки в следующем подразделе.

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

Типичный вариант использования - обещания / наблюдаемые создаются функциями API и возвращаются пользователю API. Затем пользователь API использует эти обещания / наблюдаемые объекты. Итак, если вы используете API, вы обычно просто используете promises / observables, тогда как если вы являетесь автором API, вам также необходимо создавать promises / observables.

Далее мы сначала рассмотрим создание обещаний / наблюдаемых, а в следующем подразделе рассмотрим их использование.

Обещания:

new Promise(executorFunc);
function executorFunc(resolve) {
    // Some code...
    resolve(value);
}

Чтобы создать обещание, вы вызываете конструктор Promise, передавая ему так называемую функцию-исполнитель в качестве аргумента. Функция исполнителя вызывается системой при создании обещания и передается в качестве аргумента специальной функции resolve (вы можете назвать этот аргумент как хотите, просто помните, что первый аргумент исполнителя function - это функция разрешения, и вы должны использовать ее как таковую ).

Когда вы вызываете функцию resolve в теле функции-исполнителя, обещание переводится в состояние выполнено, а значение, которое вы передаете в качестве аргумента функции resolve, «испускается» (обещание разрешено) .

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

Наблюдаемые:

new Observable(subscriberFunc);
function subscriberFunc(observer) {
    // Some code...
    observer.next(value);
}

Чтобы создать наблюдаемое, вы вызываете конструктор Observable, передавая ему так называемую функцию подписчика в качестве аргумента. Функция подписчика вызывается системой всякий раз, когда новый подписчик подписывается на наблюдаемое. Функция подписчика получает в качестве аргумента объект наблюдателя. У этого объекта есть метод next, который при вызове испускает значение, которое вы передаете ему в качестве аргумента из наблюдаемого.

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

Создание (с обработкой ошибок)

Приведенные выше примеры еще не показали всех возможностей обещаний и наблюдаемых. Ошибки могут возникать во время выполнения обещания / наблюдаемого, и оба метода предоставляют средства для указания таких ошибок коду, который их «использует».

Следующее дополняет приведенные выше объяснения возможностями обработки ошибок.

Обещания:

new Promise(executorFunc);
function executorFunc(resolve, reject) {
    // Some code...
    resolve(value);
    // Some code...
    reject(error);
}

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

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

Наблюдаемые:

new Observable(subscriberFunc);
function subscriberFunc(observer) {
    // Some code...
    observer.next(value);
    // Some code...
    observer.error(error);
}

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

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

next и error - это еще не вся правда. У объекта наблюдателя, который передается в функцию подписчика, есть еще один метод: complete. Его использование показано в следующем:

new Observable(subscriberFunc);
function subscriberFunc(observer) {
    // Some code...
    observer.next(value);
    // If there is an error...
    observer.error(error);
    // If all successful...
    observer.complete();
}

Метод complete должен вызываться, когда наблюдаемое успешно «завершается». Завершение означает, что больше нет работы, то есть все значения переданы. Как и метод error, метод complete завершает выполнение функции подписчика, что означает, что метод complete может быть вызван не более одного раза в течение времени существования наблюдаемого объекта.

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

использование

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

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

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

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

  • const promise = new Promise(/*...*/);
  • const observable = new Observable(/*...*/)

Обещания:

promise.then(onFulfilled);
function onFulfilled(value) {
    // Do something with value...
}

Учитывая объект обещания, мы вызываем then метод этого объекта и передаем ему функцию onFulfilled в качестве аргумента. Функция onFulfilled принимает единственный аргумент. Этот аргумент является результирующим значением обещания, то есть значением, которое было передано функции resolve внутри обещания.

Наблюдаемые:

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

Вариант 1:

observable.subscribe(nextFunc);
function nextFunc(value) {
    // Do something with value...
}

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

Другими словами, всякий раз, когда внутренняя функция-подписчик наблюдаемого объекта вызывает метод next, ваша функция next вызывается со значением, которое передается в next (таким образом, передавая значение из наблюдаемого объекта вашей функции-обработчику).

Вариант 2:

observable.subscribe({
    next: nextFunc
});
function nextFunc(value) {
    // Do something with value...
    console.log(value);
}

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

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

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

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

new Observable(subscriberFunc);
function subscriberFunc(observer) {
    // Some code...
    observer.next(value);
}

Аргумент observer функции подписчика соответствует непосредственно объекту, который мы передаем в subscribe выше (фактически, объект, переданный в subscribe, сначала преобразуется из типа Observer в Subscriber перед передачей в функцию подписчика, а Subscriber реализует интерфейс Observer).

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

Какой из этих двух вариантов использовать - дело вкуса и стиля написания кода. Просто обратите внимание, что если вы используете вариант 2, ключ свойства объекта для функции next должен в обязательном порядке называться next. Это продиктовано Observer интерфейсом, который этот объект должен реализовать.

Использование (с обработкой ошибок)

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

Как для обещаний, так и для наблюдаемых ошибка может возникать в двух случаях:

  1. Обещание или наблюдаемая реализация вызывает функцию reject или метод error соответственно (см. Выше).
  2. Обещание или наблюдаемая реализация выдает ошибку с ключевым словом throw.

Давайте посмотрим, как мы можем обрабатывать такие типы ошибок как для обещаний, так и для наблюдаемых.

Обещания:

На самом деле есть два способа обработать ошибку, вызванную обещанием. Первый использует второй аргумент метода then, а второй использует цепочку методов. Далее мы представим их обе.

Вариант 1 (второй аргумент then):

promise.then(onFulfilled, onRejected);
function onFulfilled(value) {
    // Do something with value...
}
function onRejected(error) {
    // Do something with error...
}

Метод then обещания принимает второй аргумент функции, которым является функция onRejected. Эта функция вызывается, когда функция-исполнитель обещания вызывает функцию reject или когда функция-исполнитель обещания выдает ошибку с ключевым словом throw.

Предоставление функции onRejected позволяет обрабатывать такие ошибки. Если вы его не предоставите, ошибки все равно могут возникать, но они не обрабатываются вашим кодом.

Вариант 2 (объединение методов):

Второй вариант использует связанный метод catch и выглядит следующим образом:

promise.then(onFulfilled).catch(onRejected);
function onFulfilled(value) {
    // Do something with value...
}
function onRejected(error) {
    // Do something with error...
}

То есть вместо того, чтобы предоставить методу then функции onFulfilled и onRejected, мы предоставляем then только метод onFulfilled, затем вызываем метод catch обещания, возвращенного then, и передать функцию onRejected этому catch методу. Обратите внимание, что в этом случае обещание, которое мы вызываем catch (и которое возвращается then), совпадает с исходным обещанием.

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

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

Еще один момент, который следует знать, это то, что на самом деле в catch нет ничего особенного. Фактически, метод catch - это просто синтаксический сахар для определенного вызова метода then. В частности, вызов catch с функцией onRejected в качестве единственного аргумента эквивалентен вызову then с undefined первым аргументом и onRejected в качестве второго аргумента.

Таким образом, следующие два утверждения эквивалентны:

promise.then(onFulfilled).catch(onRejected);
promise.then(onFulfilled).then(undefined, onRejected);

Итак, мы можем концептуально сократить цепочки из then и catch до чистых цепочек then, что иногда упрощает рассуждение о них.

Наблюдаемые:

Как уже упоминалось в последнем подразделе, есть два способа вызвать subscribe метод наблюдаемого. Один использует объект (реализующий Observer) в качестве аргумента, а другой использует функции в качестве аргументов.

Далее мы представим оба этих стиля.

Вариант 1 (аргументы функции):

observable.subscribe(nextFunc, errorFunc);
function nextFunc(value) {
    // Do something with value...
}
function errorFunc(error) {
    // Do something with error...
}

Единственное отличие от случая без обработки ошибок в предыдущем подразделе состоит в том, что мы передаем второй аргумент функции методу subscribe. Этот второй аргумент - функция error, которая вызывается всякий раз, когда функция-подписчик наблюдаемого объекта вызывает метод error переданного аргумента наблюдателя или выдает ошибку с throw.

Вариант 2 (аргумент объекта):

observable.subscribe({
    next: nextFunc,
    error: errorFunc
});
function nextFunc(value) {
    // Do something with value...
}
function errorFunc(error) {
    // Do something with error...
}

Единственным отличием от случая без обработки ошибок здесь снова является дополнительное свойство error в объекте, который мы передаем методу subscribe. Значением этого свойства является функция обработчика ошибок.

Фактически существует третья функция, которую можно передать методу subscribe: complete (мы уже упоминали об этом в предыдущем подразделе). Эту функцию можно передать в качестве третьего аргумента в subscribe (вариант 1) или в качестве дополнительного свойства с именем complete в объекте наблюдателя, который передается в subscribe (вариант 2).

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

  1. С аргументами функции: одна, две или три функции.
  2. С аргументом объекта: объект, содержащий необязательные свойства функции next, error и complete.

Создание и использование: пример

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

Вы можете запустить эти примеры на любом движке JavaScript. Для наблюдаемого примера просто не забудьте сначала установить библиотеку RxJS и добавить соответствующий оператор import или require в начало файла исходного кода, как объяснено во введении этого раздела.

Обещания:

// Creation
const promise = new Promise(executorFunc);
function executorFunc(resolve, reject) {
    const value = Math.random();
    if (value <= 1/3.0)
        resolve(value);
    else if (value <= 2/3.0)
        reject("Value <= 2/3 (reject)");
    else
        throw "Value > 2/3 (throw)"
}
// Usage
promise.then(onFulfilled).catch(onRejected);
function onFulfilled(value) {
    console.log("Got value: " + value);
}
function onRejected(error) {
    console.log("Caught error: " + error);
}

Этот код создает обещание, которое генерирует случайное число от 0 до 1. Если число меньше или равно 1/3, обещание разрешается с этим значением (значение «испущено»). Если число больше 1/3, но меньше или равно 2/3, то обещание отклоняется. Наконец, если число больше 2/3, выдается ошибка с ключевым словом JavaScript throw.

Есть три возможных выхода этой программы:

Результат 1:

Got value: 0.2109261758959049

Результат 2:

Caught error: Value <= 2/3 (reject)

Результат 3:

Caught error: Value > 2/3 (throw)

Выход 1 происходит, когда обещание выполняется регулярно (с помощью функции resolve). Это вызывает выполнение функции обработчика onFulfilled с разрешенным значением.

Выход 2 происходит, когда обещание явно отклоняется (с помощью функции reject). Это вызывает выполнение функции обработчика onRejected.

Наконец, вывод 3 возникает, когда при выполнении обещания возникает ошибка. Как и в случае явного отклонения обещания, это вызывает выполнение функции обработчика onRejected.

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

// Creation
const promise = new Promise((resolve, reject) => {
    const value = Math.random();
    if (value <= 1/3.0)
        resolve(value);
    else if (value <= 2/3.0)
        reject("Value <= 2/3 (reject)");
    else
        throw "Value > 2/3 (throw)"
});
// Usage
promise
    .then(value => console.log("Got value: " + value))
    .catch(error => console.log("Caught error: " + error));

Теперь давайте реализуем тот же пример с наблюдаемыми.

Наблюдаемые:

// Creation
const observable = new Observable(subscriberFunc);
function subscriberFunc(observer) {
    const value = Math.random();
    if (value <= 1/3.0)
        observer.next(value);
    else if (value <= 2/3.0)
        observer.error("Value <= 2/3 (error)");
    else
        throw "Value > 2/3 (throw)"
    observer.complete();
}
// Usage
observable.subscribe(nextFunc, errorFunc, completeFunc);
function nextFunc(value) {
    console.log("Got value: " + value);
}
function errorFunc(error) {
    console.log("Caught error: " + error);
}
function completeFunc() {
    console.log("Completed");
}

Это тот же пример, что и для обещаний выше. Если случайное значение меньше или равно 1/3, наблюдаемый испускает значение с помощью метода next переданного объекта-наблюдателя. Если значение больше 1/3, но меньше или равно 2/3, указывает на ошибку метода error объекта-наблюдателя. Наконец, если значение больше 2/3, возникает ошибка с ключевым словом throw. В конце функции подписчика вызывается метод complete объекта наблюдателя.

Эта программа также имеет три возможных выхода:

Результат 1:

Got value: 0.24198168409429077
Completed

Результат 2:

Caught error: Value <= 2/3 (error)

Результат 3:

Caught error: Value > 2/3 (throw)

Выход 1 возникает, когда из наблюдаемого испускается обычное значение. Это вызывает выполнение функции обработчика nextFunc. Поскольку функция подписчика наблюдаемого объекта также вызывает complete в самом конце своего тела, функция-обработчик completeFunc также выполняется.

Выход 2 происходит, когда наблюдаемый вызывает error метод объекта-наблюдателя. Это вызывает выполнение функции обработчика errorFunc. Обратите внимание, что это также приводит к прерыванию выполнения функции подписчика наблюдаемого объекта. Следовательно, метод complete в конце тела функции подписчика не вызывается, а это означает, что функция-обработчик completeFunc никогда не выполняется. Вы можете видеть это, потому что нет выходной строки Completed, как на выходе 1.

Выход 3 происходит, если функция подписчика наблюдаемого выдает ошибку с ключевым словом throw. Он имеет тот же эффект, что и вызов метода error, а именно: выполняется функция обработчика errorFunc и выполнение функции подписчика наблюдаемого объекта прерывается (метод complete не вызывается).

Как и в случае с примером обещания, мы можем переписать этот пример в эквивалентной более краткой записи:

// Creation
const observable = new Observable(observer => {
    const value = Math.random();
    if (value <= 1/3.0)
        observer.next(value);
    else if (value <= 2/3.0)
        observer.error("Value <= 2/3 (error)");
    else
        throw "Value > 2/3 (throw)"
    observer.complete();
});
// Usage
observable.subscribe({
    next(value) { console.log("Got value: " + value) },
    error(err) { console.log("Caught error: " + err) },
    complete() { console.log("Completed"); }
});

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

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

Одно значение против нескольких значений

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

Обещания:

const promise = new Promise(resolve => {
    resolve(1);
    resolve(2);
    resolve(3);
});
promise.then(result => console.log(result));

Это печатает:

1

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

Наблюдаемые:

const observable = new Observable(observer => {
    observer.next(1);
    observer.next(2);
    observer.next(3);
});
observable.subscribe(result => console.log(result));

Это печатает:

1
2
3

Каждый вызов observer.next в функции подписчика вступает в силу и вызывает передачу значения и выполнение функции обработчика.

Нетерпеливый против ленивого

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

Обещания:

const promise = new Promise(resolve => {
    console.log("- Executing");
    resolve();
});
console.log("- Subscribing");
promise.then(() => console.log("- Handling result"));

Это печатает:

- Executing
- Subscribing
- Handling result

Как видите, функция исполнителя уже выполняется до того, как кто-либо подписался на обещание.

Функция исполнителя будет выполнена даже в том случае, если никто не подписался на обещание. Вы можете убедиться в этом, если закомментировали последние две строки: результат будет по-прежнему — Executing.

Наблюдаемые:

const observable = new Observable(observer => {
    console.log("- Executing");
    observer.next();
});
console.log("- Subscribing");
observable.subscribe(() => console.log("- Handling result"));

Это печатает:

- Subscribing
- Executing
- Handling result

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

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

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

Без возможности отмены или отмены

  • После того, как вы «подписались» на обещание с then, функция-обработчик, которую вы передаете then, будет вызываться, несмотря ни на что. Вы не можете сказать обещанию отменить вызов функции обработчика результатов после того, как выполнение обещания было запущено.
  • После подписки на наблюдаемый объект с subscribe, вы можете отменить эту подписку в любое время, вызвав метод unsubscribe объекта Subscription, возвращенного subscribe. В этом случае функция-обработчик, которую вы передали subscribe, больше не будет вызываться.

Обещания:

const promise = new Promise(resolve => {
    setTimeout(() => {
        console.log("Async task done");
        resolve();
    }, 2000);
});
promise.then(() => console.log("Handler"));
// Oops, can't prevent handler from being executed anymore.

Это печатает (через 2 секунды):

Async task done
Handler

После того, как мы вызвали then, мы не сможем предотвратить вызов функции-обработчика, которую мы передали then (даже если бы у нас было 2 секунды для этого). Итак, через 2 секунды, когда обещание выполнено, обработчик выполняется.

Наблюдаемые:

const observable = new Observable(observer => {
    setTimeout(() => {
        console.log("Async task done");
        observer.next();
    }, 2000);
});
subscription = observable.subscribe(() => console.log("Handler"));
subscription.unsubscribe();

Это печатает (через 2 секунды):

Async task done

Мы подписываемся на наблюдаемый объект, регистрируя с ним функцию-обработчик, но сразу после этого снова отказываемся от подписки на наблюдаемый объект. Эффект заключается в том, что через 2 секунды, когда наблюдаемый объект выдаст свое значение, наша функция-обработчик не вызывается.

Обратите внимание, что Async task done все еще печатается. Отказ от подписки сам по себе не означает, что любая асинхронная задача, которую выполняет наблюдаемый объект, прерывается. Отказ от подписки просто означает, что вызовы observer.next (а также observer.error и observer.complete) в функции подписчика не инициируют вызовы ваших функций-обработчиков. Но все остальное работает так, как будто вы не позвонили бы unsubscribe.

Многоадресная рассылка против одноадресной

  • Функция-исполнитель обещания выполняется ровно один раз (при создании обещания). Это означает, что все вызовы then для данного объекта обещания просто «касаются» текущего выполнения функции исполнителя и, в конце концов, получают копию значения результата. Следовательно, промисы выполняют многоадресную рассылку, поскольку для нескольких «подписчиков» используется одно и то же значение выполнения и результата.
  • Функция подписчика наблюдаемого выполняется при каждом вызове subscribe этого наблюдаемого. Следовательно, наблюдаемые объекты выполняют одноадресную рассылку, поскольку для каждого подписчика существует отдельное значение выполнения и результата.

Обещания:

const promise = new Promise(resolve => {
    console.log("Executing...");
    resolve(Math.random());
});
promise.then(result => console.log(result));
promise.then(result => console.log(result));

Это печатает (например):

Executing...
0.1951561731912439
0.1951561731912439

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

Наблюдаемые:

const observable = new Observable(observer => {
    console.log("Executing...");
    observer.next(Math.random());
});
observable.subscribe(result => console.log(result));
observable.subscribe(result => console.log(result));

Это печатает (например):

Executing...
0.5884515904517829
Executing...
0.7974144930327094

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

Асинхронные обработчики и синхронные обработчики

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

Обещания:

console.log("- Creating promise");
const promise = new Promise(resolve => {
    console.log("- Promise running");
    resolve(1);
});
console.log("- Registering handler");
promise.then(result => console.log("- Handling result: " + result));
console.log("- Exiting main");

Это напечатает следующую последовательность выходных сообщений:

- Creating promise
- Promise running
- Registering handler
- Exiting main
- Handling result: 1

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

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

Наблюдаемые:

console.log("- Creating observable");
const observable = new Observable(observer => {
    console.log("- Observable running");
    observer.next(1);
});
console.log("- Registering handler");
observable.subscribe(v => console.log("- Handling result: " + v));
console.log("- Exiting main");

Это напечатает следующую последовательность выходных сообщений:

- Creating observable
- Registering handler
- Observable running
- Handling result: 1
- Exiting main

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

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

Заключение

В этой статье мы впервые представили различные методы асинхронного программирования на JavaScript, наиболее важными из которых являются:

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

использованная литература