Lección 1 de 515 min
JavaScript: el lenguaje de la web
Tipos, funciones y closures, objetos y clases, asincronía con promesas, el event loop y los módulos ES.
Pulsas «Comprar» en una tienda online: el botón cambia a «Procesando…», la página sigue respondiendo mientras el servidor contesta y, al final, aparece «Pedido confirmado». Todo eso lo hace JavaScript en el navegador. Y es muy probable que el servidor que recibió el pedido también ejecute JavaScript: con Node.js, Deno o Bun, el mismo lenguaje sirve para el frontend y para el backend, y lo mismo vale para TypeScript, que verás en la siguiente lección.
En el módulo anterior viste qué es JavaScript y para qué sirve. Aquí entramos en el lenguaje en sí, concepto a concepto: cómo son sus valores, cómo recuerdan las funciones su contexto, cómo funcionan los objetos y, sobre todo, cómo consigue hacer varias cosas a la vez con un solo hilo.
Valores, tipos y funciones
Todo programa manipula valores. En JavaScript hay dos clases de valores, y entender en qué se diferencian evita buena parte de los errores de principiante.
Primitivos y objetos
Siete tipos primitivos que se copian y, para todo lo demás, objetos que se comparten.
- Primitivos:
- 7
- Objetos:
- por referencia
Los primitivos son string, number, bigint, boolean, undefined, symbol y null. Son inmutables: texto.toUpperCase() no cambia texto, sino que devuelve un texto nuevo. Y al asignarlos a otra variable, se copian.
Todo lo demás es un objeto: los objetos literales, los arrays, las funciones, las fechas. Una variable no guarda el objeto en sí, sino una referencia a él. Si dos variables apuntan al mismo objeto y cambias una propiedad desde una de ellas, la otra también lo «ve». Por eso const impide reasignar la variable, pero no modificar el objeto.
Úsalo cuando
Para razonar sobre cualquier asignación o parámetro: si pasas un objeto a una función, la función puede modificar el original.
Ten en cuenta
typeof null devuelve "object" por un error histórico que no se puede corregir sin romper webs existentes. Para copiar un objeto anidado sin compartir nada, usa structuredClone(): el spread ({ ...obj }) solo copia el primer nivel.
let a = "hola";
let b = a; // copia el valor
b = "adiós";
console.log(a); // "hola"
const carrito = { total: 10 };
const mismo = carrito; // copia la referencia
mismo.total = 99;
console.log(carrito.total); // 99: es el mismo objeto
const copia = structuredClone(carrito);
copia.total = 0;
console.log(carrito.total); // 99: la copia es independienteCoerción e igualdad estricta
JavaScript convierte tipos por su cuenta; === compara sin conversiones sorpresa.
- Recomendado:
- === y !==
Cuando mezclas tipos, JavaScript intenta convertirlos: es la coerción. "5" * 2 da 10, porque * solo trabaja con números, pero "5" + 2 da "52", porque + también une textos y lo prefiere. En un if, cualquier valor se convierte en booleano: false, 0, "", null, undefined y NaN son falsy (junto con algún caso raro como 0n); todo lo demás es truthy, incluidos "0" y [].
El operador == compara aplicando coerción, con reglas difíciles de recordar: 0 == "" es true. El operador === exige que coincidan el tipo y el valor. Con objetos, ambos comparan referencias y no contenido: {} === {} es false, porque son dos objetos distintos.
Úsalo cuando
Usa siempre === y !==. La única excepción habitual es valor == null, que detecta a la vez null y undefined.
Ten en cuenta
NaN no es igual a nada, ni siquiera a sí mismo: compruébalo con Number.isNaN(). Y convierte de forma explícita los datos de un formulario, con Number(texto), antes de operar: un campo <input> siempre devuelve texto.
"5" * 2; // 10 (el texto pasa a número)
"5" + 2; // "52" (el número pasa a texto)
0 == ""; // true: coerción sorpresa
0 === ""; // false
null == undefined; // true
null === undefined; // false
NaN === NaN; // false: usa Number.isNaN(x)
const cantidad = Number(campo.value); // conversión explícita
if (cantidad > 0) {
// …
}Funciones
Valores como cualquier otro: se guardan en variables, se pasan y se devuelven.
En JavaScript las funciones son ciudadanas de primera clase: puedes guardarlas en una variable, pasarlas como argumento (como haces con addEventListener o map) y devolverlas desde otra función. Hay tres formas habituales de escribirlas: la declaración function, la expresión asignada a una variable y la función flecha (x) => x * 2.
Las flechas son más cortas y no tienen su propio this: usan el del lugar donde se escribieron, lo que las hace ideales como callbacks dentro de métodos. Las declaraciones con function se elevan (hoisting): puedes llamarlas antes de la línea donde aparecen.
Úsalo cuando
Flechas para callbacks y funciones cortas; function para funciones con nombre en el nivel superior de un archivo.
Ten en cuenta
Si pasas un método como callback (boton.onclick = carrito.vaciar), pierde su this y fallará al ejecutarse. Envuélvelo en una flecha: () => carrito.vaciar().
// Declaración, con un parámetro por defecto
function conImpuesto(precio, tasa = 0.2) {
return precio * (1 + tasa);
}
// Flecha: corta, ideal como callback
const precios = [10, 25, 40];
const finales = precios.map((p) => conImpuesto(p));
// Una función que recibe otra función
function repetir(veces, accion) {
for (let i = 0; i < veces; i++) accion(i);
}
repetir(3, (i) => console.log(`Vuelta ${i}`));Closures
Una función recuerda las variables del lugar donde nació, aunque ese código ya terminara.
Cuando defines una función dentro de otra, la interna puede leer y modificar las variables de la externa. Lo sorprendente es que las sigue recordando después de que la externa haya terminado. Esa combinación de una función y las variables que recuerda se llama closure (en español, a veces, «clausura»).
Los closures están por todas partes: cada callback que usa una variable de fuera es uno. Sirven para crear estado privado, que nadie puede tocar desde fuera, y para fabricar funciones ya configuradas, como un contador o un formateador de moneda.
Úsalo cuando
Para encapsular estado sin necesidad de clases, para crear funciones configuradas con unos valores fijos y, sin pensarlo, en cada callback.
Ten en cuenta
Un closure mantiene vivas las variables que usa: si recuerda un array enorme o un nodo del DOM que ya no existe, ese dato no se libera de memoria. Y en los bucles usa let, que crea una variable nueva en cada vuelta, y no var, que comparte una sola.
function crearContador() {
let cuenta = 0; // nadie puede tocarla desde fuera
return () => {
cuenta++;
return cuenta;
};
}
const siguiente = crearContador();
siguiente(); // 1
siguiente(); // 2: recuerda «cuenta» entre llamadas
const otro = crearContador();
otro(); // 1: cada contador tiene su propia «cuenta»Objetos, prototipos y clases
JavaScript no copia el modelo de clases de Java: por debajo, los objetos heredan unos de otros a través de una cadena de prototipos. Las clases llegaron después, en ES2015, como una forma más cómoda de escribir lo mismo.
Objetos y prototipos
Cada objeto tiene un prototipo al que pregunta cuando no encuentra una propiedad.
Un objeto es una colección de propiedades: pares de clave y valor, en los que el valor puede ser una función (un método). Lo creas con un literal ({ nombre: "Ana" }), lees sus propiedades con obj.clave u obj["clave"] y lo desestructuras con const { nombre } = obj.
Además, cada objeto está enlazado a otro, su prototipo. Si pides una propiedad que el objeto no tiene, JavaScript la busca en su prototipo, luego en el prototipo de este, y así hasta llegar a null: es la cadena de prototipos. Por eso cualquier array tiene map o push sin que los definas: viven en Array.prototype.
Úsalo cuando
Objetos literales para agrupar datos relacionados. Rara vez tocarás prototipos a mano, pero entenderlos explica cómo funcionan las clases, instanceof y los métodos de los tipos nativos.
Ten en cuenta
No modifiques los prototipos nativos (Array.prototype.miCosa = …): afecta a todo el código de la página y puede chocar con futuras versiones del lenguaje. Para saber si una propiedad es propia y no heredada, usa Object.hasOwn(obj, "clave").
const animal = {
saludar() {
return `Hola, soy ${this.nombre}`;
},
};
// «perro» usa «animal» como prototipo
const perro = Object.create(animal);
perro.nombre = "Toby";
perro.saludar(); // "Hola, soy Toby" (heredado)
Object.hasOwn(perro, "nombre"); // true
Object.hasOwn(perro, "saludar"); // false: está en el prototipoClases
Una sintaxis cómoda para crear objetos del mismo tipo; por debajo, siguen siendo prototipos.
Una class agrupa el constructor, que prepara cada objeto nuevo, y los métodos que compartirán todos. Con new creas una instancia; con extends heredas de otra clase y con super llamas a la versión del padre. Los métodos se guardan una sola vez en el prototipo, no se copian en cada objeto.
Las clases modernas admiten campos privados con #, inaccesibles de verdad desde fuera; miembros static, que pertenecen a la clase y no a cada instancia; y get y set para propiedades calculadas.
Úsalo cuando
Cuando creas muchos objetos con el mismo comportamiento o un framework te lo pide: Angular, por ejemplo, define los componentes y los servicios como clases.
Ten en cuenta
No todo tiene que ser una clase: para agrupar datos basta un objeto literal, y para reutilizar lógica a menudo basta una función. Las jerarquías de herencia profundas se vuelven difíciles de cambiar; suele ser mejor combinar objetos pequeños.
class Cuenta {
#saldo = 0; // privado: no existe fuera de la clase
constructor(titular) {
this.titular = titular;
}
ingresar(cantidad) {
if (cantidad <= 0) throw new Error("Cantidad no válida");
this.#saldo += cantidad;
}
get saldo() { return this.#saldo; } // se lee, pero no se escribe
}
const cuenta = new Cuenta("Ana");
cuenta.ingresar(50);
cuenta.saldo; // 50
cuenta.#saldo; // SyntaxError: es privadoAsincronía y event loop
JavaScript ejecuta tu código en un solo hilo: hace una cosa cada vez. Si se quedara esperando a que responda la red, la página se congelaría. En lugar de eso, pide la operación, sigue con lo demás y retoma el trabajo cuando llega el resultado. Hay tres formas de escribir ese «retomar», y aparecieron en este orden.
Callbacks
Le pasas una función a otra para que la llame cuando termine.
Un callback es una función que entregas a otra para que la ejecute más tarde: cuando el usuario hace clic, cuando vence un temporizador o cuando termina de leerse un archivo. Es el mecanismo original de la asincronía en JavaScript y sigue siendo el de los eventos (addEventListener).
El problema llega cuando cada paso depende del anterior: los callbacks se anidan unos dentro de otros y el código crece hacia la derecha, el famoso callback hell. Además, cada nivel tiene que gestionar sus propios errores; en Node.js, la convención era que el primer argumento del callback fuera el error.
Úsalo cuando
Para eventos que ocurren muchas veces (clics, teclas, mensajes que llegan por un WebSocket) y con APIs antiguas que solo ofrecen callbacks.
Ten en cuenta
Para operaciones que terminan una sola vez, como una petición o una lectura, prefiere promesas. Muchas APIs de callbacks tienen hoy una versión con promesas, como fs/promises en Node.js.
// Eventos: un callback que se ejecuta en cada clic
boton.addEventListener("click", () => console.log("¡Clic!"));
// Pasos encadenados con callbacks: el «callback hell»
leerUsuario(id, (error, usuario) => {
if (error) return mostrarError(error);
leerPedidos(usuario, (error, pedidos) => {
if (error) return mostrarError(error);
calcularTotal(pedidos, (error, total) => {
if (error) return mostrarError(error);
console.log(total);
});
});
});Promesas
Un objeto que representa un resultado que aún no tienes, pero tendrás (o fallará).
Una promesa (Promise) empieza pendiente y acaba cumplida, con un valor, o rechazada, con un error. Con .then() dices qué hacer con el valor y con .catch(), qué hacer si falla. Como .then() devuelve otra promesa, los pasos se encadenan en vertical y un solo .catch() al final recoge el error de cualquiera de ellos.
Para coordinar varias a la vez existen Promise.all(), que espera a todas y falla en cuanto falla una; Promise.allSettled(), que espera a todas y te cuenta cómo acabó cada una; y Promise.race() y Promise.any(). fetch devuelve una promesa, igual que casi todas las APIs modernas.
Úsalo cuando
Cada vez que una operación termina una sola vez: peticiones, lecturas de archivos, consultas a la base de datos. Y Promise.all() cuando tienes varias operaciones independientes que pueden ir en paralelo.
Ten en cuenta
Una promesa rechazada que nadie captura muestra un aviso en el navegador y, en Node.js, termina el proceso por defecto. Y la operación arranca al crear la promesa: .then() no la pone en marcha, solo dice qué hacer con el resultado.
leerUsuario(id)
.then((usuario) => leerPedidos(usuario))
.then((pedidos) => calcularTotal(pedidos))
.then((total) => console.log(total))
.catch((error) => mostrarError(error)); // uno para toda la cadena
// Dos peticiones independientes, a la vez
Promise.all([fetch("/api/perfil"), fetch("/api/avisos")])
.then(([perfil, avisos]) => {
// las dos han llegado
});async/await
Promesas escritas como si el código fuera secuencial, con try/catch para los errores.
Una función marcada con async siempre devuelve una promesa, y dentro de ella await pausa esa función hasta que la promesa se resuelve, sin bloquear nada más: mientras tanto, el event loop sigue atendiendo clics y otras tareas. El código se lee de arriba abajo, como si fuera síncrono, y los errores se capturan con un try/catch normal.
No es un mecanismo distinto, sino una forma más cómoda de usar promesas: puedes hacer await de cualquier promesa y combinarlo con Promise.all(). En los módulos ES puedes usar await incluso fuera de una función, en el nivel superior del archivo (top-level await).
Úsalo cuando
Es la forma recomendada para casi todo el código asíncrono nuevo, tanto en el navegador como en Node.js.
Ten en cuenta
Dos await seguidos esperan uno detrás de otro. Si las operaciones no dependen entre sí, lánzalas a la vez con await Promise.all([...]): tardarás lo que la más lenta, no la suma de las dos.
async function mostrarTotal(id) {
try {
const usuario = await leerUsuario(id);
const pedidos = await leerPedidos(usuario);
console.log(calcularTotal(pedidos));
} catch (error) {
mostrarError(error);
}
}
// En paralelo (top-level await, dentro de un módulo ES)
const [perfil, avisos] = await Promise.all([
fetch("/api/perfil").then((r) => r.json()),
fetch("/api/avisos").then((r) => r.json()),
]);El event loop
El bucle que decide qué se ejecuta a continuación: pila, microtareas y tareas.
- Hilos para tu código:
- 1
El motor ejecuta el código en la pila de llamadas (call stack): cada función que se llama se apila y, al terminar, se desapila. Las operaciones lentas, como temporizadores, red o disco, las gestiona el entorno (el navegador o Node.js) fuera de la pila. Cuando terminan, su callback no se ejecuta en el acto: se pone en una cola a esperar su turno.
Hay dos colas principales. La de microtareas recibe los .then() de las promesas, lo que viene después de cada await y queueMicrotask(). La de tareas (o macrotareas) recibe setTimeout, los eventos del usuario y la entrada/salida. El event loop repite un ciclo: cuando la pila queda vacía, ejecuta todas las microtareas pendientes y después toma una tarea. Por eso un .then() siempre se adelanta a un setTimeout(…, 0).
Úsalo cuando
Para predecir en qué orden ocurren las cosas, entender por qué se congela una interfaz y depurar errores que dependen del orden de ejecución.
Ten en cuenta
Un bucle síncrono largo ocupa la pila y bloquea los clics y el repintado: la página «se cuelga». Trocea el trabajo pesado o llévalo a un Web Worker. Node.js tiene además sus propias fases y colas, como process.nextTick, que se ejecuta incluso antes que las promesas.
setTimeout(() => console.log("5: tarea"), 0);
async function cargar() {
console.log("2: antes del await");
await null; // lo que sigue va a la cola de microtareas
console.log("4: después del await");
}
console.log("1: inicio");
cargar();
console.log("3: el script sigue");
// Consola: 1, 2, 3, 4, 5Pruébalo
Antes de pulsar «Siguiente paso» en el simulador, intenta adivinar en qué orden saldrán los mensajes. Después, pega el mismo fragmento en la consola de las herramientas de desarrollo de tu navegador (F12) y comprueba que el orden coincide.
Interactivo
El event loop, paso a paso
Elige un fragmento y avanza paso a paso: verás qué entra en la pila, qué espera en cada cola y en qué orden aparece cada mensaje en la consola.
1console.log("A");2setTimeout(() => console.log("B"), 0);3Promise.resolve().then(() => console.log("C"));4console.log("D");Aún no se ha ejecutado nada. Pulsa «Siguiente paso» y fíjate en qué entra en cada caja.
Call stack
Lo que se ejecuta ahora (arriba, lo último que entró).
Vacía
Consola
Lo que se ha impreso, en orden.
Sin mensajes
Cola de microtareas
Promesas y queueMicrotask. Se vacía entera en cuanto la pila queda libre.
Vacía
Cola de tareas
setTimeout, eventos… Una por vuelta del event loop.
Vacía
Módulos ES
Un proyecto real tiene cientos de archivos. Los módulos permiten que cada uno declare qué ofrece y qué necesita, en lugar de compartirlo todo en un espacio global donde cualquier nombre puede chocar con otro.
import y export
Cada archivo es un módulo con su propio ámbito: exporta lo que ofrece e importa lo que usa.
Con export marcas lo que un archivo ofrece: tantas exportaciones con nombre como quieras (export function sumar…) y, como mucho, una exportación por defecto (export default). Con import las traes desde otro archivo: import { sumar } from "./mates.js". Lo que no exportas queda privado al módulo.
Los import son estáticos: van al principio del archivo y se resuelven antes de ejecutar nada. Eso permite a los bundlers saber qué código no se usa y quitarlo (tree shaking). En el navegador, los módulos se activan con <script type="module">; en Node.js, con la extensión .mjs o con "type": "module" en el package.json.
Úsalo cuando
Siempre en código nuevo, en el navegador y en Node.js. Prefiere las exportaciones con nombre: el editor las autocompleta y renombrarlas es más seguro.
Ten en cuenta
Node.js tiene además su sistema anterior, CommonJS (require y module.exports), que seguirás viendo en proyectos y paquetes antiguos. Y en el navegador, sin un bundler, un import relativo debe incluir la extensión (./mates.js): nadie la adivina por ti.
// mates.js
export const PI = 3.14159;
export function areaCirculo(radio) {
return PI * radio ** 2;
}
export default function sumar(a, b) {
return a + b;
}
// app.js
import sumar, { areaCirculo } from "./mates.js";
console.log(sumar(2, 3), areaCirculo(1));import() dinámico
Carga un módulo en el momento en que lo necesitas, y te lo entrega como una promesa.
import(), con paréntesis, se puede llamar en cualquier punto del código, incluso dentro de un if o de un manejador de eventos. Devuelve una promesa que se cumple con el módulo ya cargado, así que se combina muy bien con await.
Los bundlers convierten cada import() en un archivo aparte (un chunk) que solo se descarga cuando se ejecuta esa línea. Así, el editor de imágenes o la librería de gráficos no pesan en la carga inicial de la página: es la base del code splitting, que verás en el módulo de rendimiento.
Úsalo cuando
Para funciones pesadas que no todos los usuarios usan, para cargar código según el idioma o la configuración y para las pantallas de una aplicación que se cargan bajo demanda.
Ten en cuenta
La primera vez hay una espera mientras se descarga el módulo: muestra un indicador de carga y gestiona el error si falla la red. Si la ruta se construye con variables, el bundler no siempre puede saber qué archivos debe preparar.
const boton = document.querySelector("#exportar");
boton.addEventListener("click", async () => {
boton.textContent = "Cargando…";
// El módulo solo se descarga la primera vez que alguien pulsa
const { exportarPdf } = await import("./exportar-pdf.js");
await exportarPdf(datos);
boton.textContent = "Exportar";
});Interactivo
Tres formas de escribir código asíncrono
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Callbacks | Promesas | async/await |
|---|---|---|---|
| Cómo recibes el resultado | Una función que se llama al terminar | .then() sobre un objeto Promise | await devuelve el valor |
| Errores | A mano en cada nivel, a menudo como (error, datos) | Un .catch() al final de la cadena | try/catch de siempre |
| Pasos encadenados | Anidados, hacia la derecha | Cadena vertical de .then() | Una línea detrás de otra |
| Varias operaciones a la vez | A mano, contando cuántas han terminado | Promise.all() y similares | await Promise.all([...]) |
| Uso típico hoy | Eventos y APIs antiguas | Combinar operaciones y librerías | Casi todo el código asíncrono nuevo |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Copias la configuración con const copia = config, cambias copia.tema y descubres que también ha cambiado config.tema.
Para recordar
- Los primitivos se copian y los objetos se comparten por referencia: dos variables pueden apuntar al mismo objeto.
- Usa
===y convierte los tipos de forma explícita: la coerción automática de==y de+es una fuente clásica de errores. - Un closure es una función que recuerda las variables del lugar donde se creó; las clases son una sintaxis cómoda sobre la cadena de prototipos.
- Prefiere
async/awaity lanza en paralelo conPromise.all()lo que no dependa entre sí. - El event loop ejecuta el código síncrono, después todas las microtareas (promesas,
await) y después una tarea (setTimeout, eventos): nunca bloquees la pila.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Qué muestra
console.log("5" + 2, "5" * 2)?2.Con la función
crearContadorde la lección, ¿qué devuelve la última llamada deconst a = crearContador(); a(); a(); const b = crearContador(); b();?3.¿En qué orden se imprimen los mensajes?
setTimeout(() => console.log("T"), 0); Promise.resolve().then(() => console.log("P")); console.log("S");4.Tienes
const perfil = await leerPerfil(); const avisos = await leerAvisos();y las dos peticiones son independientes. ¿Qué cambio reduce el tiempo de espera?5.Un módulo de gráficos pesa mucho y solo se usa en una pestaña de la app. ¿Qué ganas cargándolo con
import()en lugar de con unimportestático?