Desarrollo web y móvil

Fundamentos de JavaScript: tipos, ámbito, closures, asincronía y el DOM

Principiante9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

JavaScript premia tener un buen modelo mental mucho más que memorizar sintaxis: cómo se convierten los valores, cómo el ámbito y los closures capturan variables, qué decide el valor de this y en qué orden ejecuta el event loop tus callbacks. Esta guía construye esos modelos y luego los aplica a módulos, clases, promesas, manejo de errores, el DOM y fetch, con la sintaxis moderna en la que hoy puedes confiar.

Lo que aprenderás

  • Distinguir los primitivos de los objetos y predecir cómo se comportan la comparación y la coerción
  • Usar let, const, closures y módulos para controlar el ámbito y evitar bugs de estado compartido
  • Explicar qué fija el valor de this y cuándo una función flecha es la opción correcta
  • Transformar datos con los métodos de arrays y objetos en lugar de bucles manuales y mutaciones
  • Razonar sobre el event loop y escribir código asíncrono con promesas y async/await
  • Manipular el DOM y llamar a API HTTP con fetch, gestionando los errores de forma deliberada

JavaScript es un lenguaje pequeño envuelto en una superficie enorme. Casi toda la confusión que arrastra la gente con él (por qué una comparación devolvió algo inesperado, por qué una variable dentro de un callback tenía el valor equivocado, por qué this se esfumó) viene de cuatro o cinco reglas de fondo, no de la sintaxis en sí. Aprende esas reglas y el resto del lenguaje, incluido el framework que estés usando, resulta mucho más fácil de entender.

Esta guía cubre ese núcleo. Da por hecho que ya sabes leer un bucle for y se centra en el modelo: cómo se comportan los valores, cómo el ámbito y los closures los capturan, qué determina this, cómo el event loop planifica tu trabajo y cómo espera la biblioteca estándar que transformes los datos. Usa sintaxis moderna en todo momento, incluidas las funcionalidades finalizadas en las ediciones recientes de ECMAScript, y termina con el DOM y las API de red, donde pasa la mayor parte del tiempo el código del navegador.

Valores: siete primitivos y todo lo demás

Hay exactamente siete tipos primitivos (string, number, boolean, undefined, null, bigint y symbol) y todo lo demás es un objeto, incluidos los arrays, las funciones, las fechas y las expresiones regulares. Los primitivos se copian por valor y son inmutables; los objetos se manejan mediante referencias, así que dos variables pueden apuntar a lo mismo.

let a = { count: 1 };
let b = a;
b.count = 2;
console.log(a.count);   // 2 — same object

let x = "hi";
let y = x;
y += "!";
console.log(x);         // "hi" — strings are immutable

number es un float de 64 bits, por eso 0.1 + 0.2 no da 0.3 y los enteros por encima de 2^53 pierden precisión; usa bigint (un literal que termina en n) para enteros grandes y para aritmética entera con importes expresados en céntimos. typeof null devuelve "object", un bug que se mantiene desde 1995 por compatibilidad.

La coerción es donde se queman los principiantes. Los operadores + y ==, y también la comprobación de veracidad, convierten tipos de forma implícita, con reglas muy enrevesadas. Dos hábitos eliminan casi todo el riesgo. Primero, compara siempre con ===; la única excepción que merece la pena mantener es value == null, una forma compacta de comprobar «null o undefined». Segundo, apréndete exactamente la lista de valores falsy (false, 0, -0, 0n, "", null, undefined, NaN), porque todo lo demás, incluidos [], {} y "0", es truthy. Cuando quieras un valor por defecto solo para null y undefined, usa el operador de coalescencia nula ?? en lugar de ||, que también reemplazaría un 0 o una cadena vacía perfectamente legítimos. La misma distinción vale para el encadenamiento opcional: user?.address?.city se cortocircuita a undefined en lugar de lanzar una excepción.

Bindings, ámbito y closures

let y const tienen ámbito de bloque y viven en una «temporal dead zone» desde el inicio del bloque hasta que se ejecuta la declaración, así que tocarlos antes de tiempo lanza un ReferenceError en lugar de devolver undefined en silencio. var tiene ámbito de función y se eleva (hoisting), lo que origina una larga lista de bugs clásicos. Usa const por defecto; impide reasignar el binding, aunque no congela el objeto al que apunta.

Un closure es una función junto con el ámbito en el que se creó. Como la función interna conserva una referencia viva a las variables externas (no una copia), los closures dan a JavaScript estado privado:

function createCounter(start = 0) {
  let value = start;                       // captured, not copied
  return {
    increment: () => ++value,
    read: () => value,
  };
}

const counter = createCounter(10);
counter.increment();
console.log(counter.read());               // 11

Nada desde fuera puede alcanzar value. Este patrón sostiene el estado de los módulos, la memoización, los manejadores de eventos que recuerdan su contexto y cualquier función «factory» que vayas a leer. La trampa histórica (un bucle que creaba manejadores que compartían todos el mismo var i) desaparece con let, que crea un binding nuevo en cada iteración.

Funciones y qué decide this

Las funciones son valores: puedes pasarlas, devolverlas y guardarlas. El this de una función normal no depende de dónde se escribió, sino de cómo se llama. Si se llama como obj.method(), this es obj. Si se llama sola, this es undefined en modo estricto, y el código de los módulos siempre lo es. Si se llama con call, apply o bind, es lo que tú le hayas pasado. Si se llama con new, es el objeto recién creado.

Las funciones flecha no tienen this propio; capturan de forma permanente el del ámbito que las rodea. Es justo lo que quieres para los callbacks y justo lo que no quieres para los métodos de objeto que necesitan un receptor.

const timer = {
  seconds: 0,
  startBroken() {
    setInterval(function () { this.seconds++; }, 1000);   // this is not timer
  },
  startWorking() {
    setInterval(() => { this.seconds++; }, 1000);         // arrow keeps timer
  },
};

Los parámetros por defecto, los parámetros rest (...args) y la sintaxis spread cubren casi todo aquello para lo que se usaba el antiguo objeto arguments y, a diferencia de él, funcionan en funciones flecha y son arrays de verdad.

Objetos, desestructuración y actualizaciones inmutables

Los literales de objeto admiten propiedades abreviadas, claves calculadas y métodos. La desestructuración extrae valores, admite valores por defecto y renombrado, y el spread copia:

const settings = { theme: "dark", locale: "en", retries: 3 };

const { theme, retries = 1, missing = "fallback" } = settings;
const [first, ...rest] = ["a", "b", "c"];

const updated = { ...settings, theme: "light" };          // new object, old untouched

function connect({ host, port = 5432, ssl = true } = {}) { /* ... */ }

El spread hace una copia superficial: los objetos anidados siguen compartidos. Para clonar en profundidad datos simples, structuredClone(value) ya viene integrado y maneja ciclos, Maps, Sets y arrays tipados, cosa que JSON.parse(JSON.stringify(x)) no hace. Preferir objetos nuevos a la mutación in situ es lo que hace funcionar la detección de cambios en los frameworks modernos y lo que, en general, hace que el estado sea fácil de seguir.

Hay dos tipos de colección que merecen más uso del que reciben. Map acepta claves de cualquier tipo, conserva el orden de inserción e informa directamente de su tamaño, lo que lo hace mejor que un objeto normal para un diccionario con claves dinámicas. Set ofrece comprobaciones de pertenencia baratas y deduplicación: [...new Set(items)] es la forma idiomática de obtener valores únicos.

Los arrays y los métodos que sustituyen a los bucles

Los métodos de los arrays expresan la intención, se encadenan con limpieza y evitan los errores de desfase de índice.

MétodoDevuelveUso típico
maparray nuevo, misma longitudTransformar cada elemento
filterarray nuevo, más cortoQuedarse con los elementos que cumplen
reduceun único valorReducir a un total, un objeto o una agrupación
find / findIndexelemento o índicePrimera coincidencia
some / everybooleanoComprobaciones de existencia y universales
flatMaparray nuevo aplanadoMapear y aplanar un nivel
sortel mismo array, mutadoOrdenar: pasa un comparador
toSorted / toReversed / witharray nuevoEquivalentes sin mutación
const orders = [
  { id: 1, region: "north", total: 120 },
  { id: 2, region: "south", total: 80 },
  { id: 3, region: "north", total: 45 },
];

const northTotal = orders
  .filter((o) => o.region === "north")
  .reduce((sum, o) => sum + o.total, 0);              // 165

const byRegion = Object.groupBy(orders, (o) => o.region);
const highestFirst = orders.toSorted((a, b) => b.total - a.total);

sort muta el array y, sin comparador, compara los elementos como cadenas: por eso [10, 9, 1].sort() da [1, 10, 9]. Los métodos más recientes toSorted, toReversed, toSpliced y with devuelven copias, que suele ser lo que querías. Object.groupBy y Map.groupBy eliminan el reduce escrito a mano más habitual.

Módulos y clases

Los módulos ES son la unidad de código estándar. Cada archivo tiene su propio ámbito, los imports se resuelven estáticamente y todo se ejecuta en modo estricto:

// money.js
export const CURRENCY = "EUR";
export function format(cents) {
  return new Intl.NumberFormat("fr-FR", { style: "currency", currency: CURRENCY })
    .format(cents / 100);
}
export default class Money { /* ... */ }
// checkout.js
import Money, { format, CURRENCY } from "./money.js";
const lazy = await import("./heavy-chart.js");   // dynamic, code-split

Prefiere los exports con nombre: se encuentran con grep, se refactorizan fácilmente y dan mejor autocompletado que un export por defecto, al que cada módulo importador le acaba poniendo el nombre local que le parece. El import() dinámico devuelve una promesa y es el mecanismo que hay detrás de las rutas con carga diferida.

Las clases son sintaxis sobre la cadena de prototipos, con campos privados reales, prefijados con #, inaccesibles desde fuera:

class Account {
  #balance = 0;
  static from(cents) { return new Account(cents); }
  constructor(cents = 0) { this.#balance = cents; }
  get balance() { return this.#balance; }
  deposit(cents) {
    if (cents <= 0) throw new RangeError("deposit must be positive");
    this.#balance += cents;
    return this;
  }
}

La composición con funciones y objetos simples sigue siendo una alternativa perfectamente válida; las clases se ganan su sitio cuando importan la identidad y un estado mutable encapsulado.

El event loop, las promesas y async/await

JavaScript ejecuta tu código en un único hilo con una cola. El trabajo síncrono se ejecuta hasta terminar; todo lo asíncrono (un temporizador, una respuesta de red, un clic) se planifica y se atiende después. Los callbacks de las promesas van a la cola de microtareas, que se vacía por completo tras la tarea actual y antes del siguiente temporizador o paso de renderizado, así que un .then() siempre se ejecuta antes que un setTimeout(..., 0) registrado con anterioridad. Bloquear el hilo con un bucle largo congela el renderizado y la entrada del usuario; lleva ese trabajo a un Web Worker.

Una promesa es un marcador de posición para un valor que está pendiente (pending), cumplido (fulfilled) o rechazado (rejected). async/await es sintaxis sobre las promesas: una función async siempre devuelve una promesa, y await la suspende hasta que la promesa esperada se cumple o se rechaza.

async function loadDashboard(userId) {
  // Independent work — start both, then wait once.
  const [profile, orders] = await Promise.all([
    fetchJson(`/api/users/${userId}`),
    fetchJson(`/api/users/${userId}/orders`),
  ]);
  return { profile, orders };
}

Los combinadores importan: Promise.all se rechaza en cuanto se rechaza cualquiera de sus entradas, Promise.allSettled espera a todas e informa de cada resultado, Promise.any se resuelve con el primer éxito y Promise.race adopta el resultado de la primera que termine, que es la base de un timeout. Hacer await dentro de un bucle serializa peticiones que podrían haberse ejecutado en paralelo, y es la ralentización evitable más común del código asíncrono.

Los errores en las funciones async son excepciones normales, así que try/catch/finally funciona, y un throw dentro de una de ellas se convierte en un rechazo. Lanza objetos Error en lugar de cadenas para conservar la stack trace, crea subclases de Error para los fallos de dominio y usa la opción cause para conservar el original cuando lo envuelvas. Un rechazo sin manejar es un defecto real: añade un catch o deja que un manejador de nivel superior lo registre.

El DOM, los eventos y fetch

El DOM es el modelo de objetos del documento que construye el navegador. Consúltalo con querySelector y querySelectorAll, crea nodos en lugar de concatenar cadenas de HTML y recuerda que asignar a innerHTML datos que no son de confianza es exactamente como se produce el cross-site scripting: textContent es la opción segura por defecto.

const list = document.querySelector("#orders");

const row = document.createElement("li");
row.textContent = `Order ${order.id}`;
row.dataset.orderId = order.id;
list.append(row);

// One delegated listener instead of one per row.
list.addEventListener("click", (event) => {
  const item = event.target.closest("li[data-order-id]");
  if (item) openOrder(item.dataset.orderId);
});

Los eventos se propagan hacia arriba (bubbling) desde el elemento de destino por sus ancestros, y eso es lo que hace posible la delegación: engancha un único manejador a un padre estable y seguirá funcionando para las filas que se añadan después. event.preventDefault() detiene la acción por defecto del navegador; stopPropagation() corta la propagación y conviene usarlo con moderación, porque rompe otros listeners. Cuando un componente añade listeners a objetos de larga vida, guarda la señal de un AbortController para poder quitarlos todos de una vez.

fetch devuelve una promesa que solo se rechaza ante un fallo de red: un HTTP 404 o 500 se resuelve igualmente, algo que sorprende a todo el mundo al menos una vez:

async function fetchJson(url, options = {}) {
  const response = await fetch(url, {
    headers: { Accept: "application/json" },
    ...options,
  });
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText} for ${url}`);
  }
  return response.json();
}

Comprueba siempre response.ok y decide qué significa cada estado para quien llama: la referencia de códigos de estado HTTP explica cuáles merece la pena reintentar. Usa AbortController para cancelar las peticiones en curso cuando un componente se desmonta o cambia el texto de un buscador. Cuando depuras un payload a mano, el formateador de JSON y el codificador/decodificador de URL son más rápidos que abrir la consola, y las ideas de esta guía se trasladan tal cual a un framework: consulta los fundamentos de Angular para ver cómo aparecen las mismas primitivas detrás de un modelo de componentes.

Puntos clave

  • Los siete primitivos se copian por valor y todo lo demás es una referencia; usa ===, memoriza la lista de valores falsy y prefiere ?? y ?. al código defensivo repetitivo.
  • const y let tienen ámbito de bloque; los closures capturan bindings vivos, que es como el lenguaje ofrece estado privado.
  • El this de una función normal depende del punto de llamada, mientras que una función flecha lo captura léxicamente: esa única regla explica la mayoría de las sorpresas con los callbacks.
  • Transforma datos con map, filter, reduce y los métodos más recientes sin mutación toSorted/with, y copia con spread sin olvidar que la copia es superficial.
  • El event loop vacía las microtareas antes que los temporizadores; usa async/await para la lógica secuencial y Promise.all para el trabajo independiente en lugar de hacer await dentro de un bucle.
  • En el navegador, prefiere textContent a innerHTML, delega los eventos en un ancestro estable y comprueba siempre response.ok, porque fetch no se rechaza ante errores HTTP.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debería usar var alguna vez?
En código nuevo, no. var tiene ámbito de función y se eleva (hoisting) como un binding ya inicializado, así que se escapa de los bloques y permite redeclararlo sin avisar. let y const tienen ámbito de bloque y permanecen en la temporal dead zone hasta que se ejecuta la declaración, lo que convierte toda una familia de bugs sutiles en un error inmediato. Usa const por defecto, pasa a let solo cuando de verdad reasignes y deja var para leer código antiguo.
¿Qué diferencia hay entre == y ===?
La igualdad estricta compara tipo y valor sin ninguna conversión. La igualdad débil aplica antes el algoritmo de comparación abstracta, de modo que una cadena puede ser igual a un número y null es igual a undefined. Las reglas de conversión son tan extensas que pocos las recuerdan bien bajo presión, así que usa === en todas partes. El único modismo que merece la pena conservar es value == null, que comprueba null o undefined en una sola comparación.
¿Por qué this se vuelve undefined dentro de mi callback?
En una función normal, this lo decide la forma en que se llama a la función, no el lugar donde se escribió. Pasar un método como callback lo separa de su receptor, así que this pasa a ser undefined en modo estricto y en el código de los módulos. Define el callback como función flecha, que captura this léxicamente del ámbito que la rodea, o vincula el método explícitamente con bind al pasarlo.
¿Cuándo usar async/await en lugar de .then()?
Prefiere async/await para la lógica secuencial: se lee de arriba abajo, funciona con un try/catch normal y mantiene stack traces útiles. Recurre a los combinadores de promesas cuando el flujo no es secuencial: Promise.all para ejecutar en paralelo trabajo independiente, Promise.allSettled cuando necesitas cada resultado aunque haya fallos, Promise.race para los timeouts. Hacer await dentro de un bucle que podría haber sido paralelo es un error de rendimiento habitual.