Usamos métodos de navegador en ejemplos aquí.
Para demostrar el uso de devoluciones de llamada, promesas y otros conceptos abstractos, usaremos algunos métodos del navegador: específicamente, cargar scripts y realizar manipulaciones simples de documentos.
Si no está familiarizado con estos métodos y su uso en los ejemplos le resulta confuso, es posible que desee leer algunos capítulos de la siguiente parte del tutorial.
Aunque intentaremos dejar las cosas claras de todos modos. No habrá nada realmente complejo en cuanto al navegador.
Los entornos host de JavaScript proporcionan muchas funciones que le permiten programar acciones asincrónicas . Es decir, acciones que iniciamos ahora, pero terminan más tarde.
Por ejemplo, una de esas funciones es la función setTimeout
.
Hay otros ejemplos del mundo real de acciones asincrónicas, por ejemplo, cargar scripts y módulos (los cubriremos en capítulos posteriores).
Eche un vistazo a la función loadScript(src)
, que carga un script con el src
dado:
función cargarScript(src) { // crea una etiqueta <script> y la agrega a la página // esto hace que el script con el src dado comience a cargarse y se ejecute cuando esté completo let script = document.createElement('script'); script.src = src; documento.head.append(guion); }
Inserta en el documento una etiqueta nueva, creada dinámicamente, <script src="…">
con el src
dado. El navegador comienza a cargarlo automáticamente y se ejecuta cuando se completa.
Podemos usar esta función así:
// carga y ejecuta el script en la ruta indicada loadScript('/mi/script.js');
El script se ejecuta "asincrónicamente", ya que comienza a cargarse ahora, pero se ejecuta más tarde, cuando la función ya ha finalizado.
Si hay algún código debajo de loadScript(…)
, no espera hasta que finalice la carga del script.
loadScript('/mi/script.js'); // el código debajo de loadScript // no espera a que finalice la carga del script //...
Digamos que necesitamos usar el nuevo script tan pronto como se cargue. Declara nuevas funciones y queremos ejecutarlas.
Pero si hacemos eso inmediatamente después de la llamada loadScript(…)
, no funcionaría:
loadScript('/mi/script.js'); // el script tiene "función nuevaFunción() {…}" nuevaFunción(); // ¡no existe tal función!
Naturalmente, el navegador probablemente no tuvo tiempo de cargar el script. A partir de ahora, la función loadScript
no proporciona una forma de realizar un seguimiento de la finalización de la carga. El script se carga y finalmente se ejecuta, eso es todo. Pero nos gustaría saber cuándo sucede, para usar nuevas funciones y variables de ese script.
Agreguemos una función callback
como segundo argumento a loadScript
que debería ejecutarse cuando se carga el script:
función loadScript(src, devolución de llamada) { let script = document.createElement('script'); script.src = src; script.onload = () => devolución de llamada(script); documento.head.append(guion); }
El evento onload
se describe en el artículo Carga de recursos: onload y onerror, básicamente ejecuta una función después de cargar y ejecutar el script.
Ahora, si queremos llamar nuevas funciones desde el script, debemos escribirlo en la devolución de llamada:
loadScript('/mi/script.js', función() { // la devolución de llamada se ejecuta después de cargar el script nuevaFunción(); // así que ahora funciona ... });
Esa es la idea: el segundo argumento es una función (normalmente anónima) que se ejecuta cuando se completa la acción.
Aquí hay un ejemplo ejecutable con un script real:
función loadScript(src, devolución de llamada) { let script = document.createElement('script'); script.src = src; script.onload = () => devolución de llamada(script); documento.head.append(guion); } loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => { alert(`Genial, el script ${script.src} está cargado`); alerta( _ ); // _ es una función declarada en el script cargado });
Esto se denomina estilo de programación asincrónica “basado en devolución de llamada”. Una función que hace algo de forma asincrónica debe proporcionar un argumento callback
donde ejecutamos la función una vez completada.
Aquí lo hicimos en loadScript
, pero por supuesto es un enfoque general.
¿Cómo podemos cargar dos scripts secuencialmente: el primero y luego el segundo?
La solución natural sería poner la segunda llamada loadScript
dentro de la devolución de llamada, así:
loadScript('/mi/script.js', función(script) { alert(`Genial, el ${script.src} está cargado, carguemos uno más`); loadScript('/mi/script2.js', función(script) { alert(`Genial, el segundo script está cargado`); }); });
Una vez que se completa el loadScript
externo, la devolución de llamada inicia el interno.
¿Y si queremos un guión más…?
loadScript('/mi/script.js', función(script) { loadScript('/mi/script2.js', función(script) { loadScript('/mi/script3.js', función(script) { // ...continuar después de cargar todos los scripts }); }); });
Entonces, cada nueva acción está dentro de una devolución de llamada. Eso está bien para algunas acciones, pero no para muchas, por lo que pronto veremos otras variantes.
En los ejemplos anteriores no consideramos errores. ¿Qué pasa si falla la carga del script? Nuestra devolución de llamada debería poder reaccionar ante eso.
Aquí hay una versión mejorada de loadScript
que rastrea los errores de carga:
función loadScript(src, devolución de llamada) { let script = document.createElement('script'); script.src = src; script.onload = () => devolución de llamada (nulo, script); script.onerror = () => callback(new Error(`Error de carga de script para ${src}`)); documento.head.append(guión); }
Llama callback(null, script)
para una carga exitosa y callback(error)
en caso contrario.
El uso:
loadScript('/mi/script.js', función(error, script) { si (error) { // manejar el error } demás { // script cargado exitosamente } });
Una vez más, la receta que utilizamos para loadScript
es bastante común. Se llama estilo de “devolución de llamada de error primero”.
La convención es:
El primer argumento de la callback
está reservado para un error si ocurre. Luego se llama callback(err)
.
El segundo argumento (y los siguientes si es necesario) son para un resultado exitoso. Luego se llama callback(null, result1, result2…)
.
Por lo tanto, la función callback
única se utiliza tanto para informar errores como para devolver resultados.
A primera vista, parece un enfoque viable para la codificación asincrónica. Y efectivamente lo es. Para una o quizás dos llamadas anidadas, se ve bien.
Pero para múltiples acciones asincrónicas que se suceden una tras otra, tendremos un código como este:
loadScript('1.js', función(error, script) { si (error) { manejarError(error); } demás { //... loadScript('2.js', función(error, script) { si (error) { manejarError(error); } demás { //... loadScript('3.js', función(error, script) { si (error) { manejarError(error); } demás { // ...continuar después de cargar todos los scripts (*) } }); } }); } });
En el código anterior:
Cargamos 1.js
, luego si no hay error…
Cargamos 2.js
, luego si no hay error…
Cargamos 3.js
y, si no hay ningún error, hacemos otra cosa (*)
.
A medida que las llamadas se vuelven más anidadas, el código se vuelve más profundo y cada vez más difícil de administrar, especialmente si tenemos código real en lugar de ...
que puede incluir más bucles, declaraciones condicionales, etc.
A esto a veces se le llama "infierno de devolución de llamadas" o "pirámide de la perdición".
La "pirámide" de llamadas anidadas crece hacia la derecha con cada acción asincrónica. Pronto todo se sale de control.
Entonces esta forma de codificar no es muy buena.
Podemos intentar aliviar el problema haciendo que cada acción sea una función independiente, como esta:
loadScript('1.js', paso1); función paso1 (error, secuencia de comandos) { si (error) { manejarError(error); } demás { //... loadScript('2.js', paso2); } } función paso 2 (error, secuencia de comandos) { si (error) { manejarError(error); } demás { //... loadScript('3.js', paso3); } } función paso 3 (error, secuencia de comandos) { si (error) { manejarError(error); } demás { // ...continuar después de cargar todos los scripts (*) } }
¿Ver? Hace lo mismo y ahora no hay un anidamiento profundo porque convertimos cada acción en una función de nivel superior separada.
Funciona, pero el código parece una hoja de cálculo destrozada. Es difícil de leer y probablemente hayas notado que es necesario saltar entre las piezas mientras lo lees. Eso es un inconveniente, especialmente si el lector no está familiarizado con el código y no sabe dónde saltar.
Además, las funciones denominadas step*
son todas de un solo uso, se crean únicamente para evitar la "pirámide de la perdición". Nadie los va a reutilizar fuera de la cadena de acción. Así que aquí hay un poco de espacio de nombres saturado.
Nos gustaría tener algo mejor.
Afortunadamente, existen otras formas de evitar este tipo de pirámides. Una de las mejores maneras es utilizar “promesas”, que se describen en el próximo capítulo.