Olá pessoal!
Como você deve se lembrar, em
outubro estávamos traduzindo um artigo interessante sobre o uso de temporizadores em Javascript. Isso causou uma enorme discussão, de acordo com os resultados dos quais desejamos retornar há muito tempo a este tópico e oferecer uma análise detalhada da programação assíncrona nessa linguagem. Estamos felizes por termos conseguido encontrar material decente e publicá-lo antes do final do ano. Boa leitura!
A programação assíncrona em Javascript passou por uma evolução em vários estágios: de retornos de chamada a promessas e mais a geradores e, em breve, a
async/await
. Em cada estágio, a programação assíncrona em Javascript era um pouco simplificada para aqueles que já haviam batido de joelhos nessa linguagem, mas para iniciantes isso se tornava apenas mais assustador, pois era necessário entender as nuances de cada paradigma, dominar a aplicação de cada um e, não menos importante, entender como tudo funciona.
Neste artigo, decidimos relembrar brevemente como usar retornos de chamada e promessas, fazer uma breve introdução aos geradores e ajudá-lo a entender intuitivamente exatamente como é organizada a programação assíncrona "sob o capô" com geradores e async / wait. Esperamos que, dessa maneira, você possa aplicar com confiança os vários paradigmas exatamente onde forem apropriados.
Supõe-se que o leitor já tenha usado retornos de chamada, promessas e geradores para programação assíncrona e também esteja familiarizado com fechamentos e currying em Javascript.
Inferno de retorno de chamadaInicialmente, havia retornos de chamada. Javascript não possui E / S síncrona (doravante denominada E / S) e o bloqueio não é suportado. Portanto, para organizar qualquer E / S ou adiar qualquer ação, foi escolhida uma estratégia: o código que era necessário executar de forma assíncrona foi passado para a função com execução adiada, que foi lançada em algum lugar abaixo no loop de eventos. Um retorno de chamada não é tão ruim, mas o código aumenta e os retornos geralmente geram novos retornos. O resultado é algo como isto:
getUserData(function doStuff(e, a) { getMoreUserData(function doMoreStuff(e, b) { getEvenMoreUserData(function doEvenMoreStuff(e, c) { getYetMoreUserData(function doYetMoreStuff(e, c) { console.log('Welcome to callback hell!'); }); }); }); })
Além dos arrepios ao ver um código fractal, há mais um problema: agora delegamos o controle da nossa lógica
do*Stuff
para outras funções (
get*UserData()
), às quais você pode não ter código-fonte e pode não estar. verifique se eles estão realizando seu retorno de chamada Ótimo, não é?
PromessasPromessas invertem a inversão de controle fornecida por retornos de chamada e ajudam a desvendar um emaranhado de retornos de chamada em uma cadeia suave.
Agora o exemplo anterior pode ser convertido para algo como isto:
getUserData() .then(getUserData) .then(doMoreStuff) .then(getEvenMoreUserData) .then(doEvenMoreStuff) .then(getYetMoreUserData) .then(doYetMoreStuff);
Já não é tão feio, né?
Mas deixe-me !!! Vejamos um exemplo de retorno de chamada mais vital (mas ainda em grande parte artificial):
Então, selecionamos o perfil do usuário, depois os interesses dele e, com base nos interesses dele, selecionamos recomendações e, finalmente, tendo coletado todas as recomendações, exibimos a página. Esse conjunto de retornos de chamada, dos quais, provavelmente, podemos nos orgulhar, mas, no entanto, é de alguma forma desgrenhado. Nada, aplique promessas aqui - e tudo vai dar certo. Certo?
Vamos mudar nosso método
fetchJson()
para que ele retorne uma promessa em vez de aceitar um retorno de chamada. Uma promessa é resolvida por um corpo de resposta analisado no formato JSON.
fetchJson('/api/user/self') .then(function (user) { return fetchJson('/api/user/interests?userId=' + self.id); }) .then(function (interests) { return Promise.all[interests.map(i => fetchJson('/api/recommendations?topic=' + i))]; }) .then(function (recommendations) { render(user, interests, recommendations); });
Legal né O que há de errado com esse código agora?
... Opa! ..
Não temos acesso ao perfil ou interesses na última função desta cadeia? Então, nada funciona! O que fazer? Vamos tentar as promessas aninhadas:
fetchJson('/api/user/self') .then(function (user) { return fetchJson('/api/user/interests?userId=' + self.id) .then(interests => { user: user, interests: interests }); }) .then(function (blob) { return Promise.all[blob.interests.map(i => fetchJson('/api/recommendations?topic=' + i))] .then(recommendations => { user: blob.user, interests: blob.interests, recommendations: recommendations }); }) .then(function (bigBlob) { render(bigBlob.user, bigBlob.interests, bigBlob.recommendations); });
Sim ... agora parece muito mais desajeitado do que esperávamos. É por causa de bonecas tão loucas que nós, por último, mas não menos importante, tentamos sair do inferno de retornos de chamada? O que fazer agora?
O código pode ser penteado um pouco, apoiando-se nos fechamentos:
Sim, agora tudo está praticamente do jeito que queríamos, mas com uma peculiaridade. Observe como chamamos argumentos dentro de retornos de chamada nas
fetchedInterests
e
fetchedInterests
, em vez de
user
e
interests
? Se sim, então você é muito observador!
A falha dessa abordagem é a seguinte: você precisa ter muito, muito cuidado para não nomear nada nas funções internas, bem como as variáveis do cache que você usará em seu fechamento. Mesmo se você tiver o dom de evitar sombras, a referência a uma variável tão alta no fechamento ainda parece bastante perigosa, e isso definitivamente não é bom.
Geradores assíncronosGeradores vão ajudar! Se você usa geradores, toda a emoção desaparece. Apenas mágica. A verdade é Veja apenas:
co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations); });
Isso é tudo. Isso vai funcionar. Você não chora ao ver como são bonitos os geradores, se arrepende de ter sido tão míope e ter começado a aprender Javascript antes mesmo de os geradores aparecerem? Eu admito que essa idéia já me visitou.
Mas ... como tudo isso funciona? Realmente mágico?
Claro! Nos voltamos para a exposição.
GeradoresNo nosso exemplo, parece que os geradores são fáceis de usar, mas na verdade há muita coisa acontecendo neles. Para saber mais sobre geradores assíncronos, você precisa entender melhor como os geradores funcionam e como eles fornecem execução assíncrona, o que parece síncrono.
Como o nome indica, o gerador cria os valores:
function* counts(start) { yield start + 1; yield start + 2; yield start + 3; return start + 4; } const counter = counts(0); console.log(counter.next());
É bem simples, mas de qualquer maneira, vamos falar sobre o que está acontecendo aqui:
const counter = counts();
- inicialize o gerador e salve-o no contador variável. O gerador está no limbo; nenhum código no corpo do gerador foi executado ainda.console.log(counter.next());
- Interpretação da saída ( yield
) 1, após a qual 1 é retornado como value
, e done
resulta em false
, pois a saída não termina aíconsole.log(counter.next());
- Agora 2!console.log(counter.next());
- Agora 3! Terminado. Está tudo certo? Não. A execução é interrompida no passo yield 3;
Para concluir, você precisa chamar next () novamente.console.log(counter.next());
- Agora 4, e ele retorna, mas não é emitido, então agora saímos da função e tudo está pronto.console.log(counter.next());
- O gerador terminou o trabalho! Ele não tem nada a relatar, exceto como "tudo está feito".
Então descobrimos como os geradores funcionam! Mas espere, que verdade chocante: os geradores podem não apenas transmitir valores, mas também devorá-los!
function* printer() { console.log("We are starting!"); console.log(yield); console.log(yield); console.log(yield); console.log("We are done!"); } const counter = printer(); counter.next(1);
Ufa, o que ?! Um gerador consome valores, em vez de gerá-los. Como isso é possível?
O segredo está na
next
função. Ele não apenas retorna valores do gerador, mas também pode devolvê-los ao gerador. Se você contar a
next()
argumento, a operação de
yield
, que o gerador está esperando no momento, na verdade resultará no argumento. É por isso que o primeiro
counter.next(1)
registrado como
undefined
. Simplesmente não existe extradição que possa ser resolvida.
É como se o gerador permitisse que o código de chamada (procedimento) e o código do gerador (procedimento) se associassem para que passassem valores uns para os outros à medida que eram executados e esperassem um pelo outro. A situação é praticamente a mesma, como se tivesse sido pensada para os geradores Javascript a possibilidade de implementar procedimentos competitivos executados em cooperação, eles também são "corotinas". Na verdade, praticamente como
co()
, certo?
Mas não vamos nos apressar, senão nos enganaremos. Nesse caso, é importante que o leitor compreenda intuitivamente a essência dos geradores e da programação assíncrona, e a melhor maneira de fazer isso é montar você mesmo o gerador. Não escreva uma função de gerador e não use a terminada, mas recrie você mesmo o interior da função de gerador.
O dispositivo interno do gerador - geramos geradoresOk, eu realmente não sei exatamente como são os componentes internos do gerador em diferentes tempos de execução do JS. Mas isso não é tão importante. Geradores correspondem à interface. Um “construtor” para instanciar um gerador, o
next(value? : any)
método
next(value? : any)
, com o qual ordenamos que o gerador continue trabalhando e atribui valores a ele, outro método
throw(error)
, caso um
throw(error)
gerado em vez de um valor e, finalmente, um método
return()
, que ainda está silencioso. Se a conformidade com a interface for alcançada, tudo estará bem.
Então, vamos tentar criar o gerador
counts()
acima mencionado no ES5 puro, sem a
function*
palavra-chave
function*
. Por enquanto, você pode ignorar
throw()
e passar o valor para
next()
, pois o método não aceita nenhuma entrada. Como fazer isso?
Mas em Javascript, há outro mecanismo para pausar e retomar a execução do programa: fechamentos! Parece familiar?
function makeCounter() { var count = 1; return function () { return count++; } } var counter = makeCounter(); console.log(counter());
Se você usou fechamentos antes, tenho certeza de que você já escreveu algo assim. A função retornada pelo makeCounter pode gerar uma sequência infinita de números, assim como um gerador.
No entanto, essa função não corresponde à interface do gerador e não pode ser aplicada diretamente em nosso exemplo com
counts()
, que retorna 4 valores e sai. O que é necessário para uma abordagem universal para escrever funções semelhantes a geradores?
Fechamentos, máquinas estatais e trabalho duro!
function counts(start) { let state = 0; let done = false; function go() { let result; switch (state) { case 0: result = start + 1; state = 1; break; case 1: result = start + 2; state = 2; break; case 2: result = start + 3; state = 3; break; case 3: result = start + 4; done = true; state = -1; break; default: break; } return {done: done, value: result}; } return { next: go } } const counter = counts(0); console.log(counter.next());
Ao executar este código, você verá os mesmos resultados que na versão com o gerador. Legal né
Então, separamos o lado gerador do gerador; vamos analisar o consumo?
De fato, não há muitas diferenças.
function printer(start) { let state = 0; let done = false; function go(input) { let result; switch (state) { case 0: console.log("We are starting!"); state = 1; break; case 1: console.log(input); state = 2; break; case 2: console.log(input); state = 3; break; case 3: console.log(input); console.log("We are done!"); done = true; state = -1; break; default: break; return {done: done, value: result}; } } return { next: go } } const counter = printer(); counter.next(1);
Tudo o que é necessário é adicionar
input
como argumento
go
e os valores são canalizados. Parece mágica de novo? Quase como geradores?
Viva! Por isso, recriamos o gerador como fornecedor e consumidor. Por que não tentar combinar essas funções? Aqui está outro exemplo bastante artificial de um gerador:
function* adder(initialValue) { let sum = initialValue; while (true) { sum += yield sum; } }
Como somos todos especialistas em geradores, entendemos que esse gerador adiciona o valor fornecido em
next(value)
à
sum
e depois retorna sum. Funciona exatamente como esperávamos:
const add = adder(0); console.log(add.next());
Legal. Agora vamos escrever essa interface como uma função normal!
function adder(initialValue) { let state = 'initial'; let done = false; let sum = initialValue; function go(input) { let result; switch (state) { case 'initial': result = initialValue; state = 'loop'; break; case 'loop': sum += input; result = sum; state = 'loop'; break; default: break; } return {done: done, value: result}; } return { next: go } } function runner() { const add = adder(0); console.log(add.next());
Uau, implementamos uma corrotina completa.
Ainda há algo a discutir sobre a operação de geradores. Como as exceções funcionam? Com as exceções que ocorrem dentro dos geradores, tudo é simples:
next()
fará com que a exceção chegue ao chamador e o gerador morrerá. A transmissão de uma exceção ao gerador é feita no método
throw()
, que omitimos acima.
Vamos enriquecer nosso terminador com um novo recurso interessante. Se o chamador passar a exceção para o gerador, ele retornará ao último valor da soma.
function* adder(initialValue) { let sum = initialValue; let lastSum = initialValue; let temp; while (true) { try { temp = sum; sum += yield sum; lastSum = temp; } catch (e) { sum = lastSum; } } } const add = adder(0); console.log(add.next());
Problema de Programação - Penetração de Erro do GeradorCamarada, como implementamos throw ()?
Fácil! Erro é apenas outro valor. Podemos passar para
go()
como o próximo argumento. De fato, é necessária alguma cautela aqui. Quando o
throw(e)
chamado, a
yield
funcionará como se tivéssemos escrito o throw e. Isso significa que devemos verificar se há erros em todos os estados de nossa máquina de estados e travar o programa se não conseguirmos lidar com o erro.
Vamos começar com a implementação anterior do terminador, copiada
PadrãoSoluçãoBoom! Implementamos um conjunto de corotinas capazes de transmitir mensagens e exceções entre si, como um gerador real.
Mas a situação está piorando, não é? A implementação da máquina de estado está se afastando cada vez mais da implementação do gerador. Além disso, devido ao tratamento de erros, o código está cheio de lixo; o código é ainda mais complicado devido ao longo
while
que temos aqui. Para converter um
while
você precisa "desembaraçar" em estados. Portanto, nosso caso 1 inclui, na verdade, 2,5 iterações do
while
, pois o
yield
interrompido no meio. Por fim, é necessário adicionar código extra para enviar exceções do chamador e vice-versa, se não houver
try/catch
no gerador para lidar com essa exceção.
Você conseguiu !!! Concluímos uma análise detalhada de possíveis alternativas para a implementação de geradores e, espero, você já entendeu melhor como os geradores funcionam. No resíduo seco:
- Um gerador pode gerar valores, consumir valores ou ambos.
- O estado do gerador pode ser pausado (estado, máquina de estado, captura?)
- O chamador e o gerador permitem formar um conjunto de corutin, interagindo entre si
- Exceções são encaminhadas em qualquer direção.
Agora que somos mais versados em geradores, proponho uma maneira potencialmente conveniente de raciocinar sobre eles: essas são construções sintáticas com as quais você pode escrever procedimentos executados competitivamente que passam valores entre si através de um canal que passa valores um de cada vez (
yield
). Isso será útil na próxima seção, onde produziremos uma implementação de
co()
partir da rotina.
Inversão do controle de CorutinAgora que somos hábeis no trabalho com geradores, vamos pensar em como eles podem ser usados na programação assíncrona. Se pudermos escrever geradores como tal, isso não significa que as promessas nos geradores serão resolvidas automaticamente. Mas espere, os geradores não devem funcionar sozinhos. Eles devem interagir com outro programa, o procedimento principal, o que chama
.next()
e
.throw()
.
E se colocarmos nossa lógica de negócios não no procedimento principal, mas no gerador? Sempre que um determinado valor assíncrono ocorrer, digamos, uma promessa, o gerador dirá: "Não quero mexer com essa bobagem, acorde-me quando resolver", fará uma pausa e emitirá uma promessa ao procedimento de veiculação. Procedimento de manutenção: "OK, ligo mais tarde." Após o qual ele registra um retorno de chamada com essa promessa, sai e aguarda até que seja possível disparar um ciclo de eventos (ou seja, quando a promessa é resolvida). Quando isso acontecer, o procedimento anunciará “ei, é sua vez” e enviará o valor via
.next()
gerador adormecido. Ela aguardará o gerador fazer seu trabalho e, enquanto isso, fará outras coisas assíncronas ... e assim por diante. Você ouviu uma história triste sobre como o procedimento continua a serviço de um gerador.
Então, voltando ao tópico principal. Agora que sabemos como geradores e promessas funcionam, não será difícil criar esse "procedimento de serviço". O procedimento de serviço em si será executado competitivamente como uma promessa, instanciará e manterá o gerador e, em seguida, retornará ao resultado final do nosso procedimento principal usando o retorno de chamada
.then()
.
Em seguida, vamos retornar ao programa co () e discuti-lo em mais detalhes.
co()
é um procedimento de serviço que exige trabalho escravo, de modo que o gerador possa trabalhar apenas com valores síncronos. Já parece muito mais lógico, certo?
co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations); });
, ,
co()
, .
— co()Ótimo!
co()
, , .
co()
- ,
.next()
, {done: false, value: [a Promise]}
- ( ),
.next()
, - , 4
- -
{done: true, value: ...}
, , co()
, co(), :
function deferred(val) { return new Promise((resolve, reject) => resolve(val)); } co(function* asyncAdds(initialValue) { console.log(yield deferred(initialValue + 1)); console.log(yield deferred(initialValue + 2)); console.log(yield deferred(initialValue + 3)); }); function co(generator) { return new Promise((resolve, reject) => {
, ? - 10
co()
, . , . ?
– co(), , , ,
co()
. ,
.throw()
.
function deferred(val) { return new Promise((resolve, reject) => resolve(val)); } function deferReject(e) { return new Promise((resolve, reject) => reject(e)); } co(function* asyncAdds() { console.log(yield deferred(1)); try { console.log(yield deferredError(new Error('To fail, or to not fail.'))); } catch (e) { console.log('To not fail!'); } console.log(yield deferred(3)); }); function co(generator) { return new Promise((resolve, reject) => {
. , ,
.next()
onResolve()
.
onReject()
,
.throw()
.
try/catch
, ,
try/catch
.
,
co()
! !
co()
, , , . , ?
: async/awaitco()
. - , async/await? — ! ,
async await
.
async ,
await
,
yield
.
await
,
async
.
async
- .
,
async/await
, , -
co()
async
yield
await
,
*
, .
co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations); });
:
async function () { var user = await fetchJson('/api/user/self'); var interests = await fetchJson('/api/user/interests?userId=' + self.id); var recommendations = await Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations); }();
, :
co()
. async , . async
co()
co.wrap()
.co()
( yield
) , , . async
( await
) .
Javascript , , « »
co()
, , ,
async/await
. ? Certo.