Руководство по обещаниям JavaScript


Это руководство я написал для какой-то группы в Facebook в середине 2017 года и нашел в своих архивах. Несмотря на свой возраст, он все еще достаточно актуален.

Можно сказать, что на данный момент Promises являются наиболее «стандартным» способом работы с асинхронностью в JS. Для тех, кто работает с javascript, их знание просто необходимо.

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

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

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

Предварительные определения

Функция, которая возвращает информацию в «синхронном режиме»:

try {
  var cinco = somar(2, 3)
  console.log(cinco)
} catch(err) {
  //erro na soma
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция, которая возвращает информацию ОЦЕНИВАЕМЫМ способом через шаблон CALLBACK.

somar(2, 3, (err, cinco) => {
  if (err) {
    //erro na soma
    return
  }
  console.log(cinco)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция, которая возвращает информацию в ОЦЕНИВАЕМОМ виде через шаблон PROMISE.

somar(2, 3).then( cinco => {
  console.log(cinco)
}).catch( err => {
  //erro na soma
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Презентация

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

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

Звериный пример ада обратного вызова:

const soma = (a, b, callback) => callback( null, a + b )
//somar 1 + 2 + 3 + 4 + 5
function somarTudo( callback ) {
  soma(1, 2, (err, r) => {
    soma( r, 3, (err, r) => {
      soma( r, 4, (err, r) => {
        soma( r, 5, (err, r) => {
          callback(null, r)
        })
      })
    })
  })
}
somarTudo( (err, result) => console.log(result) )
Войдите в полноэкранный режим Выход из полноэкранного режима

Тот же пример с использованием обещаний:

const soma = (a, b) => Promise.resolve(a + b)
function somarTudo() {
  return soma(1, 2)
    .then( r => soma(r, 3) )
    .then( r => soma(r, 4) )
    .then( r => soma(r, 5) )
}
somarTudo().then( result => console.log(result) )
Войдите в полноэкранный режим Выход из полноэкранного режима

Видите, что обещания все еще далеки от «идеального мира». Назовем «идеальным миром» код, читабельность которого четко идентифицирует бизнес-правило с минимумом «беспорядка». Например, если вы напишете то же самое правило javascript в PHP-коде, то PHP-код, возможно, будет проще, с меньшим количеством строк. Это происходит потому, что код PHP является «синхронным»: он проще, потому что выполняет меньшее количество действий. В итоге асинхронность привносит в код дополнительный груз грязи, то есть дополнительные строки, которые относятся не к бизнес-правилу, а к техническим деталям.

По сравнению с синхронным кодом код с обещаниями немного «грязный».

По сравнению с кодом с обратным вызовом, код с обещаниями все еще грязный, но немного меньше.

Одним из направлений развития паттерна promises является использование async/await, который еще больше абстрагирует асинхронную обработку в языке, очень похожем на синхронный код. (Поищите на github библиотеку «tj/co», которая является одним из корней async/await в javascript.) Но прежде чем использовать async/await, интересно сначала понять, как использовать promises.

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

Объект обещания (класс)

Объект обещания инкапсулирует

  • некоторая информация
  • состояние (ожидание/завершение/неудача)
  • действие для получения этой информации

Вы никогда не будете обращаться к состояниям и информации непосредственно через свойства, а всегда через методы .then() и

конструктор

Конструктор используется для преобразования кода обратного вызова/события в код обещания.

ЕСЛИ ТО, ЧТО ВЫ ХОТИТЕ СДЕЛАТЬ
НЕ СОВЕРШЕНСТВУЙТЕ ОТЗЫВ -> ПРЕДСТАВЬТЕ, ТОГДА ВЫ НЕ БУДЕТЕ ИСПОЛЬЗОВАТЬ ЭТОГО КОНСТРУКТОРА.

В примере ниже мы преобразуем $.ajax в формат Promise (*)

(*) только для примера. На самом деле jQuery уже раскрывает интерфейс, похожий на обещание, в этом методе

var ajaxPromise = function(url) {
    return new Promise( function CallbackDoContrutor(completar,falhar) {
        $.ajax({{
            url : url , 
            success : function(data) {
                return completar(data)
            } , 
            error : function(jqXhr, status, error) {
                return falhar(error)
            }
        }})
    }
})

ajaxPromise('some-url').then( function(response) {
    fazerCoisas(response)
}).catch( function(err) {
    //exibir erro na tela
})
Войдите в полноэкранный режим Выход из полноэкранного режима

complete и fail, полученные там CallbackFromConstructor, являются функциями, которые должны быть вызваны, чтобы указать, что обещание было «завершено»
или «не удалось». Они принимают один (и только один) параметр, который передается на следующий шаг в цепочке (then или catch).
Будьте осторожны и не звоните им более одного раза. Я лично придерживаюсь практики всегда использовать их вместе с return.

(Примечание) Обратите внимание, что возврат из CallbackFromConstructor ни для чего не используется. Только complete и fail.

(Рекомендация) Держите функции конвертера Callback -> Promise отдельно от вашего бизнес-правила, предпочтительно определяйте их как чистые функции.

Promise.resolve( [значение] )

Запускает цепочку обещаний с заданным начальным значением. Инкапсулирует значение в Promise.

Сделать это можно точно так же:

var imitaçãoDoResolve = function( value ) {
    return new Promise( function(completar) {
        completar(value)
    })
}
imitaçãoDoResolve(3).then( function(result) {
    console.log(result) // exibe 3
})
Promise.resolve(3).then( function(result) {
    console.log(result) // exibe 3
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Promise.reject( [значение] )

Запускает цепочку обещаний с неудачным статусом.

То же самое, что и Promise.resolve, только на этот раз вместо complete вызывается fail.

ПРИМЕЧАНИЕ

Цепочка обещаний ВСЕГДА рождается из конструктора, Promise.resolve или Promise.reject.

Promise#then( sequenceFn , [failFn] )

(иллюстративные скобки. Пожалуйста, не обращайте внимания, если что-то не понятно)

class Promise {
    then<K,V>( sequenceFn : (prevResult?) => Promise<K>|K , failFn? : (prevResult?) => Promise<V>|V ) 
        : Promise<K>|Promise<V>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

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

Если предыдущий шаг успешен, выполняется sequenceFn(), в противном случае выполняется failFn().

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

Promise.resolve('legal').then(function(prev){
    return prev + ' mesmo'
}).then(function(prev) {
    console.log(prev) // legal mesmo
})
Войдите в полноэкранный режим Выход из полноэкранного режима

ПАУЗА: Теперь, когда вы знаете, что второй аргумент (failFn) существует, вы можете игнорировать его. Вы никогда не будете его использовать, потому что существует Promise#catch.

Обратный вызов sequenceFn сильно отличается от обратного вызова конструктора, который мы видели выше.
Для передачи данных на следующие шаги последовательности мы используем функцию RETURN (или throw).
Возврат функции может быть любым значением или ДРУГИМ обещанием.

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

numeroEntreZeroE100.then( function(numero) {
    if (numero < 50) return numero
    return ajaxPromise(ENDERECO + '?numero=' + numero)
}).then( function(resultado) {
    mostraNaTela(resultado)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

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

Promise#catch( fn )

Когда вы передаете throw внутри sequenceFn или когда возврат от этого является Promise fail,
поток пересылается к следующему .catch в последовательности.

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

Когда в последовательности Promises происходит сбой, ошибка прочесывает всю последовательность, пока не найдет первый .catch(). Чтобы ошибка не была «проглочена», обязательно передайте ее остальным частям цепочки, используя throw еще раз. Использование return внутри .catch() приведет к тому, что статус изменится на «success», а строка попадет в файл

Promise.resolve()
    .then( etapa1 )
    .then( etapa2 )  //falhou aqui
    .then( etapa3 )  // pulou este
    .catch( function(err) { // caiu aqui
        console.log(err)
        // não engolir o erro
        throw err
    })
    .then( etapa4 )
    .catch( catch2 )
Войдите в полноэкранный режим Выход из полноэкранного режима

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

Promise.all( entries )

(иллюстративные скобки. Пожалуйста, не обращайте внимания, если что-то не понятно)

class Promise {
    static all( promises : Promise<T>[] ) : Promise<T[]>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Получает Promise[] в качестве входных данных.

Promise.all перейдет к следующему шагу в цепочке только после завершения всех своих entries или при первой неудаче.

Следующий шаг получает в качестве результата массив с ответами entries.

var clientesP = [264,735,335,999 277].map( function(id) {
    return lerDoBanco({
        tabela : 'cliente' ,
        id : id
    })
}) // tipo Promise<Cliente>[]
Promise.all(clientesP).then( function(clientes) {
    // clientes tem tipo Cliente[]
    mostrar(clientes)
}).catch( function(err) {
    mostrarErro(err.message)
    //repassar o erro, caso contrário seguirá a cadeia como se não houvesse erro
    throw err
}) //...
Войдите в полноэкранный режим Выход из полноэкранного режима

Как правило, вы будете использовать Promise.all и в сочетании с Array#map для создания «параллельных» потоков. (*)

           |
        [1,2,3]
  Array#map + Promise
    /      |      
 [ p(1),  p(2),   p(3)]
          |      /
      Promise.all
           |
    [r(1),r(2),r(3)]
Войдите в полноэкранный режим Выход из полноэкранного режима

(*) Параллель в кавычках. Если вы понимаете цикл событий, вы знаете, о чем я говорю.

Образцы, примеры

Базовые массивы#map и массивы#reduce

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

Например, если в начале у меня есть список с 15 идентификаторами, а в конце у меня будет 15 ~whatever~, то, конечно, это преобразование может быть выполнено с помощью map (за исключением случаев, когда эти 15 идентификаторов имеют взаимозависимости/нелинейности).

number[15] => Array#map => Promise<Cliente>[15] => Promise.all => Promise<Cliente[15]> => Promise#then => Cliente[15]
Войдите в полноэкранный режим Выход из полноэкранного режима

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

number[15] => Array#reduce => string
Войдите в полноэкранный режим Выход из полноэкранного режима

Асинхронные параллельные обещания

Вы создаете массив обещаний, затем используете Promise.all, чтобы проверить, все ли они готовы, и собираете данные.

Если источником данных для обещаний является массив, вы, конечно же, будете использовать Array#map.

var clientesP = [264,735,335,999 277].map( function(id) {
    return lerDoBanco({
        tabela : 'cliente' ,
        id : id
    })
}) // tipo Promise<Cliente>[]
Promise.all(clientesP).then( function(clientes) {
    // clientes tem tipo Cliente[]
    mostrar(clientes)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Асинхронные запросы в последовательности

Это непростой вопрос. Из массива вы используете array#reduce, запущенный с помощью Promise.resolve().
для создания строки с .then(...) для каждого элемента в массиве.

До

chamada(1)().then( chamada(2) ).then( chamada(3) ).then( chamada(4) )
Войдите в полноэкранный режим Выход из полноэкранного режима

Равняется

Promise.resolve().then( chamada(1) ).then( chamada(2) ).then( chamada(3) ).then( chamada(4) )
Войдите в полноэкранный режим Выход из полноэкранного режима

Равняется

[1,2,3,4].reduce( function(chain,currentItem) {
    return chain.then( chamada(currentItem) )
}, Promise.resolve())
Войдите в полноэкранный режим Выход из полноэкранного режима

Еще один пример:

var estados = ['SP', 'RJ', 'MG']
var $listaDados = $('.lista')
return estados.reduce( (sequencia, estado) => {
    return sequencia.then(() => {
        return ajaxRequest('/dadosEstado/' + estado)
    }).then( dados => {
        $listaDados.append( TEMPLATE(dados) )
        return
    })
} , Promise.resolve())
Войдите в полноэкранный режим Выход из полноэкранного режима

Гнездование

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

// -- todas as funções personalizadas aqui retornam promises
obterCliente().then( function(cliente) {
    // escopo 1
    return obterCidadeDoCliente(cliente).then( function(cidade) {
        // escopo 2
        cliente.cidade = cidade
        return obterTemplate(cliente).then( function(template) {
            // escopo 3
            return '<div>' + template + '</div>'
        })
    })
}).then( function(templateClienteComDiv) {
    //recebe o resultado do escopo 3
    mostrar(templateClienteComDiv)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Оптимизация

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

obterCliente().then( function(cliente) {
    //este bloco aqui ainda depende de "cliente" da closure, então não dá pra cortar identação dele
    return obterCidadeDoCliente(cliente).then( function(cidade) {
        cliente.cidade = cidade
        return cliente
    })
})
.then( obterTemplate ) //"escopo 2"
.then( function(template) { //"escopo 3"
    return '<div>' + template + '</div>'
}).then( function(templateClienteComDiv) {
    mostrar(templateClienteComDiv)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

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

var cliente
obterCliente().then( function(_cliente) {
    cliente = _cliente
    return obterCidadeDoCliente(cliente)
})
.then( function(cidade) { //"escopo 1"
    cliente.cidade = cidade
    return cliente
})
.then( obterTemplate ) //"escopo 2"
.then( function(template) { //"escopo 3"
    return '<div>' + template + '</div>'
}).then( function(templateClienteComDiv) {
    mostrar(templateClienteComDiv)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Показывать ошибки с 1 уловом

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

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

Еще одна распространенная ошибка — забыть использовать return внутри .then(). Не отданное возвращение — это потерянная нить, будьте осторожны.

efetuarRequisicao('https://...') // volta HTTP 400 "O cliente solicitado não foi encontrado"
    .then( logicaDoApp ) //pula aqui
    .catch( function mostrarErro(err) {
        popup(err.message, err.code, err.title)
    }) // mostra na tela "O cliente solicitado não foi encontrado"
Войдите в полноэкранный режим Выход из полноэкранного режима

Побег из этого

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

function Greeter(message) {
    this.greeting = message;
}
Greeter.prototype.greet = function () {
    return "Hello, " + this.greeting;
};

var greeter = new Greeter("world");
Promise.resolve()
    .then( greeter.greet ) // hello, undefined!
    .then(function (resp) {
        console.log(resp);
    });
Войдите в полноэкранный режим Выход из полноэкранного режима

Решение

    .then( () => greeter.greet() )
Войдите в полноэкранный режим Выход из полноэкранного режима

async / await (проект)

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

  • Недоступно в браузерах старше 2017 года.

  • Если добавить к функции имя async, она всегда будет возвращать обещание и принимать
    украшение await;

  • При использовании await вместо того, чтобы писать fn().then( x => ... ), вы пишете var x = await fn().
    «promise unwrap transform» было бы хорошим описательным названием для этого;

  • Использование async/await, прежде всего, позволяет избежать необходимости перемещать общие переменные между
    несколько блоков .then в более высокую область видимости.

  • Если вы забудете поставить await в var x = fn(), x теперь будет Promise, а не возвращаемое значение
    этим.

Лучше всего показать на примере:

Обещание:

api.post('/password-reset-request', (req, res, next) => {
    let _bytes = uuid(), _userName, _userId
    //variávies criadas pra "linearizar" a sequência de promises
    Promise.resolve().then(() => {
        $checkParams(req.body, 'email')
        return connection
        .execute(
            `SELECT u.id AS userId , u.name AS userName, ev.token AS token
                FROM 
                users u LEFT JOIN event_tokens ev ON u.id = ev.user
                WHERE u.email = ?`, 
            [req.body.email])
    })
    .then(([rows]) => {
        if (!rows.length) throw 'SKIP'
        if (rows[0].token) throw Error('Já existe um pedido pendente para este usuário.')
        _userName = rows[0].userName
        _userId = rows[0].userId    
        return connection
        .execute('INSERT INTO event_tokens(user, event, token) VALUES (?, ?, ?)',
            [_userId, 'password-reset-confirm', _bytes])
    }).then(() => {
        return mailer({
            email : req.body.email ,
            subject : 'Redefinição de senha' ,
            content : PWD_RESET_CONTENT(_userName, _bytes)
        })
    })
    .catch( err => {
        if (err === 'SKIP') return
        throw err
    })
    .then(() => {
        res.status(200).send({ token : _bytes })
    })
    .catch(next)
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Async/await

api.post('/password-reset-request', async (req, res, next) => {
    try {
        //aqui não precisamos "jogar variáveis pra cima"
        $checkParams(req.body, 'email')
        var bytes = uuid()             
        var [rows] = await connection.execute(
            `SELECT u.id AS userId , u.name AS userName, ev.token AS token
                FROM 
                users u LEFT JOIN event_tokens ev ON u.id = ev.user
                WHERE u.email = ?`, 
            [req.body.email]
        )
        if (!rows.length) throw 'SKIP'
        if (rows[0].token) throw Error('Já existe um pedido pendente para este usuário.')
        var { userName, userId } = rows[0]
        await connection.execute('INSERT INTO event_tokens(user, event, token) VALUES (?, ?, ?)',
            [userId, 'password-reset-confirm', _bytes])
        await mailer({
            email : req.body.email ,
            subject : 'Redefinição de senha' ,
            content : PWD_RESET_CONTENT(userName, bytes)
        })        
        return res.status(200).send({ token : bytes })
    }
    catch (err) {
        if (err === 'SKIP') return res.status(200).send({ token : bytes })
        next(err)
    }
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Окончание

Надеюсь, я вам помог! Ваше здоровье!

Оцените статью
devanswers.ru
Добавить комментарий