Hola a todos!
Como recordarán, en
octubre estábamos traduciendo un artículo interesante sobre el uso de temporizadores en Javascript. Causó una gran discusión, de acuerdo con los resultados de los cuales siempre hemos querido volver a este tema y ofrecerle un análisis detallado de la programación asincrónica en este lenguaje. Estamos contentos de haber logrado encontrar material decente y publicarlo antes de fin de año. Que tengas una buena lectura!
La programación asincrónica en Javascript ha pasado por una evolución de varias etapas: desde devoluciones de llamada a promesas y más allá a generadores, y pronto a
async/await
. En cada etapa, la programación asincrónica en Javascript se simplificó un poco para aquellos que ya se habían arrodillado en este lenguaje, pero para los principiantes se volvió solo más aterrador, ya que era necesario comprender los matices de cada paradigma, dominar la aplicación de cada uno y, no menos importante, comprender, cómo funciona todo
En este artículo, decidimos recordar brevemente cómo usar las devoluciones de llamada y las promesas, dar una breve introducción a los generadores y luego ayudarlo a comprender intuitivamente cómo está organizada la programación asincrónica "bajo el capó" con generadores y async / wait. Esperamos que de esta manera pueda aplicar con confianza los diversos paradigmas exactamente donde sean apropiados.
Se supone que el lector ya ha utilizado devoluciones de llamada, promesas y generadores para la programación asincrónica, y también está bastante familiarizado con los cierres y currículum en Javascript.
Callback hellInicialmente, hubo devoluciones de llamadas. No hay E / S sincrónica en Javascript (en lo sucesivo, E / S) y el bloqueo no es compatible en absoluto. Entonces, para organizar cualquier E / S o para diferir cualquier acción, se eligió una estrategia de este tipo: el código que debía ejecutarse de forma asincrónica se pasó a la función con ejecución diferida, que se lanzó en algún lugar debajo del bucle de eventos. Una devolución de llamada no es tan mala, pero el código crece y las devoluciones de llamada generalmente generan nuevas devoluciones de llamada. El resultado es algo como esto:
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!'); }); }); }); })
Además de la piel de gallina al ver un código fractal de este tipo, hay un problema más: ahora hemos delegado el control de nuestra lógica
do*Stuff
a otras funciones (
get*UserData()
), para lo cual es posible que no tenga el código fuente, y es posible que no esté Seguro si están realizando su devolución de llamada. Genial, ¿no es así?
PromesasLas promesas invierten la inversión de control proporcionada por las devoluciones de llamada y ayudan a desentrañar una maraña de devoluciones de llamada en una cadena suave.
Ahora el ejemplo anterior se puede convertir en algo como esto:
getUserData() .then(getUserData) .then(doMoreStuff) .then(getEvenMoreUserData) .then(doEvenMoreStuff) .then(getYetMoreUserData) .then(doYetMoreStuff);
Ya no es tan feo, ¿eh?
Pero déjame !!! Veamos un ejemplo de devolución de llamada más vital (pero aún en gran medida artificial):
Entonces, seleccionamos el perfil del usuario, luego sus intereses, luego, en función de sus intereses, seleccionamos recomendaciones y, finalmente, después de haber recopilado todas las recomendaciones, mostramos la página. Tal conjunto de devoluciones de llamada, de las cuales, probablemente, puede estar orgulloso, pero, sin embargo, de alguna manera es peludo. Nada, aplique promesas aquí, y todo saldrá bien. Derecho?
Cambiemos nuestro método
fetchJson()
para que devuelva una promesa en lugar de aceptar una devolución de llamada. Una promesa se resuelve mediante un cuerpo de respuesta analizado en 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); });
Bien, verdad? ¿Qué hay de malo con este código ahora?
... ¡Uy! ..
¿No tenemos acceso al perfil o intereses en la última función de esta cadena? ¡Entonces nada funciona! Que hacer Probemos las promesas anidadas:
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); });
Sí ... ahora se ve mucho más torpe de lo que esperábamos. ¿Es debido a esas muñecas de anidación tan locas que nosotros, por último pero no menos importante, tratamos de escapar del infierno de las devoluciones de llamadas? Que hacer ahora
El código se puede peinar un poco, apoyándose en los cierres:
Sí, ahora todo es prácticamente como queríamos, pero con una peculiaridad. Observe cómo llamamos argumentos dentro de las devoluciones de llamada en las
fetchedInterests
fetchedUser
y
fetchedInterests
, en lugar de
user
e
interests
. Si es así, ¡entonces eres muy observador!
La falla de este enfoque es la siguiente: debe tener mucho cuidado de no nombrar nada en las funciones internas, así como las variables de la memoria caché que va a utilizar en su cierre. Incluso si tiene la habilidad de evitar el sombreado, hacer referencia a una variable tan alta en el cierre todavía parece bastante peligroso, y eso definitivamente no es bueno.
Generadores asincrónicosLos generadores ayudarán! Si usa generadores, entonces toda la emoción desaparece. Solo magia. La verdad es que Echa un vistazo solamente:
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); });
Eso es todo Funcionará No rompes una lágrima cuando ves lo hermosos que son los generadores, ¿te arrepientes de haber sido tan miope y comenzaste a aprender Javascript incluso antes de que aparecieran los generadores? Lo admito, tal idea una vez me visitó.
Pero ... ¿cómo funciona todo esto? ¿Realmente mágico?
¡Por supuesto! .. No. Pasamos a la exposición.
GeneradoresEn nuestro ejemplo, parece que los generadores son fáciles de usar, pero en realidad están sucediendo muchas cosas. Para obtener más información sobre los generadores asincrónicos, debe comprender mejor cómo funcionan los generadores y cómo proporcionan una ejecución asincrónica, que parece síncrona.
Como su nombre lo indica, el generador hace los valores:
function* counts(start) { yield start + 1; yield start + 2; yield start + 3; return start + 4; } const counter = counts(0); console.log(counter.next());
Es bastante simple, pero de todos modos, hablemos de lo que está sucediendo aquí:
const counter = counts();
- Inicialice el generador y guárdelo en el contador variable. El generador está en el limbo; todavía no se ha ejecutado ningún código en el cuerpo del generador.console.log(counter.next());
- Interpretación de la salida ( yield
) 1, después de lo cual 1 se devuelve como value
y se done
false
, ya que la salida no termina allíconsole.log(counter.next());
- Ahora 2!console.log(counter.next());
- Ahora 3! Terminado ¿Está todo bien? No La ejecución se detiene en el paso yield 3;
Para completar, debe llamar a next () nuevamente.console.log(counter.next());
- Ahora 4, y regresa, pero no se emite, así que ahora salimos de la función y todo está listo.console.log(counter.next());
- ¡El generador ha terminado el trabajo! No tiene nada que informar, excepto que "todo está hecho".
¡Así que descubrimos cómo funcionan los generadores! Pero espera, qué verdad tan impactante: ¡los generadores no solo pueden arrojar valores, sino también devorarlos!
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);
¡Uf, qué? Un generador consume valores, en lugar de generarlos. ¿Cómo es esto posible?
El secreto está en la
next
función. No solo devuelve valores del generador, sino que también puede devolverlos al generador. Si le dice a
next()
argumento, entonces la operación de
yield
, que el generador está esperando actualmente, en realidad da como resultado el argumento. Es por eso que el primer
counter.next(1)
registra como
undefined
. Simplemente no hay extradición que pueda resolverse.
Es como si el generador permitiera que el código de llamada (procedimiento) y el código del generador (procedimiento) se asociaran entre sí para que se transmitan valores mientras se ejecutan y esperen el uno al otro. La situación es prácticamente la misma, como si se hubiera pensado en los generadores de Javascript la posibilidad de implementar procedimientos cooperativos de ejecución competitiva, también son "corutinas". En realidad, más o menos como
co()
, ¿verdad?
Pero no nos apuremos, de lo contrario nos burlaremos de nosotros mismos. En este caso, es importante que el lector capte intuitivamente la esencia de los generadores y la programación asincrónica, y la mejor manera de hacerlo es ensamblar el generador usted mismo. No escriba una función de generador y no use la función terminada, sino que recree el interior de la función de generador usted mismo.
El dispositivo interno del generador: generamos generadoresBien, realmente no sé cómo se ven exactamente los componentes internos del generador en diferentes tiempos de ejecución de JS. Pero esto no es tan importante. Los generadores corresponden a la interfaz. Un "constructor" para crear instancias de un generador, el
next(value? : any)
método
next(value? : any)
, con el que le ordenamos al generador que continúe funcionando y le proporcione valores, otro método de
throw(error)
en caso de que
throw(error)
genere un
throw(error)
lugar de un valor, y finalmente, un método
return()
, que aún está en silencio. Si se logra el cumplimiento de la interfaz, entonces todo está bien.
Entonces, intentemos construir el generador de
counts()
mencionado anteriormente en ES5 puro, sin la
function*
palabra clave
function*
. Por ahora, puede ignorar
throw()
y pasar el valor a
next()
, ya que el método no acepta ninguna entrada. Como hacerlo
Pero en Javascript, hay otro mecanismo para pausar y reanudar la ejecución del programa: ¡cierres! ¿Te parece familiar?
function makeCounter() { var count = 1; return function () { return count++; } } var counter = makeCounter(); console.log(counter());
Si utilizó cierres antes, estoy seguro de que ya escribió algo así. La función devuelta por makeCounter puede generar una secuencia infinita de números, al igual que un generador.
Sin embargo, esta función no corresponde a la interfaz del generador y no se puede aplicar directamente en nuestro ejemplo con
counts()
, que devuelve 4 valores y sale. ¿Qué se necesita para un enfoque universal para escribir funciones tipo generador?
¡Cierres, máquinas de estado y trabajos forzados!
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());
Al ejecutar este código, verá los mismos resultados que en la versión con el generador. Bien, verdad?
Entonces, resolvimos el lado generador del generador; vamos a analizar el consumo?
De hecho, no hay muchas diferencias.
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);
Todo lo que se necesita es agregar una
input
como argumento
go
, y los valores se canalizan. Parece magia otra vez? ¿Casi como generadores?
¡Hurra! Así que recreamos el generador como proveedor y como consumidor. ¿Por qué no intentar combinar estas funciones en él? Aquí hay otro ejemplo bastante artificial de un generador:
function* adder(initialValue) { let sum = initialValue; while (true) { sum += yield sum; } }
Como todos somos especialistas en generadores, entendemos que este generador agrega el valor dado en
next(value)
a
sum
, y luego devuelve sum. Funciona exactamente como esperábamos:
const add = adder(0); console.log(add.next());
Genial ¡Ahora escribamos esta interfaz como una función 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());
Wow, hemos implementado una corutina completa.
Todavía hay algo que discutir sobre el funcionamiento de los generadores. ¿Cómo funcionan las excepciones? Con las excepciones que ocurren dentro de los generadores, todo es simple:
next()
hará que la excepción llegue a la persona que llama y el generador morirá. Pasar una excepción al generador se realiza en el método
throw()
, que omitimos anteriormente.
Enriquezcamos nuestro terminador con una nueva característica genial. Si la persona que llama pasa la excepción al generador, volverá al último valor de la suma.
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 programación - Penetración de error del generadorCamarada, ¿cómo implementamos throw ()?
Fácil! El error es solo otro valor. Podemos pasarlo a
go()
como el siguiente argumento. De hecho, se necesita cierta precaución aquí. Cuando
throw(e)
llama
throw(e)
, la
yield
funcionará como si hubiéramos escrito throw e. Esto significa que debemos verificar si hay errores en cada estado de nuestra máquina de estado y bloquear el programa si no podemos manejar el error.
Comencemos con la implementación anterior del terminador, copiado
PatrónSoluciónBoom! Hemos implementado un conjunto de corutinas que son capaces de pasar mensajes y excepciones entre sí, como un generador real.
Pero la situación está empeorando, ¿no? La implementación de la máquina de estado se aleja cada vez más de la implementación del generador. No solo eso, debido al manejo de errores, el código está lleno de basura; el código es aún más complicado debido al largo
while
que tenemos aquí. Para convertir un
while
debe "desenredarlo" en estados. Entonces, nuestro caso 1 en realidad incluye 2.5 iteraciones del
while
, ya que el
yield
rompe en el medio. Finalmente, debe agregar un código adicional para enviar las excepciones de la persona que llama y viceversa si no hay un
try/catch
en el generador para manejar esta excepción.
Lo hiciste !!! Hemos completado un análisis detallado de posibles alternativas para la implementación de generadores y, espero, ya haya entendido mejor cómo funcionan los generadores. En el residuo seco:
- Un generador puede generar valores, consumir valores o ambos.
- El estado del generador se puede pausar (estado, máquina de estado, captura?)
- La persona que llama y el generador le permiten formar un conjunto de corutina, interactuando entre sí.
- Las excepciones se envían en cualquier dirección.
Ahora que tenemos una mejor comprensión de los generadores, propongo una forma potencialmente conveniente de razonar sobre ellos: estas son construcciones sintácticas con las que puede escribir procedimientos ejecutados de manera competitiva que se transmiten valores entre sí a través de un canal que transfiere valores uno a la vez (
yield
). Esto será útil en la siguiente sección, donde produciremos una implementación de
co()
partir de la rutina.
Inversión de control de corutinaAhora que estamos capacitados para trabajar con generadores, pensemos cómo se pueden usar en la programación asincrónica. Si podemos escribir generadores como tales, esto no significa que las promesas en los generadores se resolverán automáticamente. Pero espere, los generadores no están destinados a funcionar solos. Deben interactuar con otro programa, el procedimiento principal, el que llama a
.next()
y
.throw()
.
¿Qué pasa si ponemos nuestra lógica de negocios no en el procedimiento principal, sino en el generador? Cada vez que se produce un cierto valor asincrónico, como una promesa, para la lógica de negocios, el generador dirá: "No quiero meterme con estas tonterías, despiértame cuando se resuelva", hará una pausa y emitirá una promesa al procedimiento de entrega. Procedimiento de mantenimiento: "OK, te llamaré más tarde". Después de lo cual registra una devolución de llamada con esta promesa, sale y espera hasta que sea posible desencadenar un ciclo de eventos (es decir, cuando la promesa se resuelve). Cuando esto suceda, el procedimiento anunciará "hey, es tu turno" y enviará el valor a través de
.next()
generador
.next()
. Esperará a que el generador haga su trabajo, y mientras tanto hará otras cosas asincrónicas ... y así sucesivamente. Escuchaste una triste historia sobre cómo el procedimiento sigue vivo al servicio de un generador.
Entonces, volviendo al tema principal. Ahora que sabemos cómo funcionan los generadores y las promesas, no será difícil para nosotros crear tal "procedimiento de servicio". El procedimiento de servicio en sí se ejecutará de manera competitiva como una promesa, creará una instancia y mantendrá el generador, y luego regresará al resultado final de nuestro procedimiento principal utilizando la devolución de llamada
.then()
.
A continuación, regresemos al programa co () y analicemos con más detalle.
co()
es un procedimiento de servicio que toma mano de obra esclava para que el generador solo pueda trabajar con valores síncronos. Ya parece mucho más lógico, ¿verdad?
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()Genial
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
) .
El finalJavascript , , « »
co()
, , ,
async/await
. ? .