JS intermedio

JS intermedio

Después de las bases (variables, operadores, funciones y estructuras de control) llega un salto de nivel en el que JavaScript deja de ser un lenguaje de scripting y empieza a comportarse como un lenguaje con reglas propias: el alcance, el orden de ejecución, la asincronía y el modelo de objetos. Este artículo repasa quince conceptos intermedios con ejemplos cortos, y explica los motivos por los que cada uno importa en el código real.

let, const y var: alcance y hoisting

var tiene alcance de función: si se declara dentro de un if, un for o un bloque cualquiera, sigue siendo visible en toda la función que lo rodea. Además, var se puede redeclarar sin error.

function ejemplo() {
  if (true) {
    var mensaje = "hola";
  }
  console.log(mensaje); // "hola", var viaja hasta el final de la función
}

var contador = 0;
var contador = 1; // permitido

let y const tienen alcance de bloque, es decir, solo existen dentro de las llaves donde se declaran. Además no se pueden redeclarar en el mismo bloque.

function ejemplo() {
  if (true) {
    let mensaje = "hola";
  }
  console.log(mensaje); // ReferenceError: mensaje no está definido
}

La diferencia práctica más importante entre var y let aparece en los bucles. Cada iteración de un for con var comparte la misma variable, mientras que con let se crea una copia nueva por vuelta. Esto cambia el resultado cuando la función se ejecuta más tarde.

// Con var, todas las funciones cierran sobre la MISMA i, que al final vale 3.
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
} // 3, 3, 3

// Con let, cada iteración tiene su propio i.
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
} // 0, 1, 2

const no significa inmutable, significa que no se puede reasignar la variable. El valor al que apunta sí se puede modificar.

const usuario = { nombre: "Ana" };
usuario.nombre = "Luis"; // permitido, modifica el objeto
usuario = { nombre: "Luis" }; // TypeError, no se puede reasignar

Regla práctica: usa const por defecto y cambia a let solo cuando la variable vaya a reasignarse.

Funciones flecha y this

Una función declarada con function crea su propio this, que depende de cómo se la llame. Una función flecha no tiene this propio: captura el this del scope donde fue escrita. Esa diferencia aparece en callbacks, métodos de objeto y temporizadores.

const usuario = {
  nombre: "Ana",
  saludarNormal() {
    return this.nombre; // "Ana", this es el objeto
  },
  saludarFlecha: () => {
    return this.nombre; // undefined o global, this no existe en flechas
  },
};

Un caso clásico es un setTimeout con un método de objeto:

const boton = {
  texto: "Enviar",
  alHacerClic() {
    setTimeout(function () {
      console.log(this.texto); // undefined, function pierde el this
    }, 100);
  },
};

Con una flecha el this se mantiene:

const boton = {
  texto: "Enviar",
  alHacerClic() {
    setTimeout(() => {
      console.log(this.texto); // "Enviar", la flecha hereda el this del método
    }, 100);
  },
};

Las funciones flecha tampoco tienen arguments (usan ...args si lo necesitan) ni propiedad prototype, y no se pueden usar con new. Son una herramienta de captura de contexto, no un reemplazo universal de function.

Destructuring y spread operator

El destructuring permite extraer valores de objetos o arrays asignándolos directamente a variables.

const punto = { x: 10, y: 20 };
const { x, y } = punto; // x = 10, y = 20

const [primero, , tercero] = [1, 2, 3]; // primero = 1, tercero = 3

Se pueden renombrar, poner valores por defecto y anidar.

const usuario = { nombre: "Ana", direccion: { ciudad: "CDMX" } };
const { nombre: nombreUsuario, direccion: { ciudad } = {} } = usuario;
// nombreUsuario = "Ana", ciudad = "CDMX"

const {edad = 30 } = {}; // edad = 30, valor por defecto

El spread operator ... hace lo contrario: expande un iterable o copia propiedades en un lugar nuevo.

const base = [1, 2];
const copia = [...base, 3]; // [1, 2, 3]

const a = { x: 1 };
const b = { ...a, y: 2 }; // { x: 1, y: 2 }

function sumar(...numeros) {
  return numeros.reduce((total, n) => total + n, 0);
}
sumar(1, 2, 3); // 6

Dos advertencias importantes: el spread es una copia superficial, por lo que los objetos anidados se comparten por referencia. Y no copia el prototipo, así que no sirve para clonar una clase.

const original = { config: { tema: "oscuro" } };
const copia = { ...original };
copia.config.tema = "claro";
console.log(original.config.tema); // "claro", comparte el objeto anidado

Template literals

Los template literals (` `) permiten interpolar expresiones, ocupar varias líneas y evitar concatenar con +`.

const nombre = "Ana";
const edad = 30;
const texto = `${nombre} tiene ${edad} años`;
// "Ana tiene 30 años"

Al cubrir varias líneas, evitan el \n espurio de la concatenación.

const carta = `Hola ${nombre},
Tu registro se completó.
Saludos.`;

También existen los tagged templates, donde una función recibe las cadenas y los valores interpolados. Se usan en librerías como las de CSS en JS o etiquetado de internacionalización, aunque no son habituales en el día a día.

Detrás de cada ${} hay una expresión normal de JavaScript, y se evalúa justo en el momento en que se evalúa el template literal. Si esa expresión es costosa o tiene efectos secundarios, conviene calcularla antes en una constante.

const usuario = { id: 7 };
const etiqueta = `${usuario.nombre} (${traerPerfil()})`; // traePerfil() sí se ejecuta

La única forma de evitarlo es que el template literal completo no llegue a evaluarse, por ejemplo porque está en la rama no elegida de un ternario.

const activo = false;
const etiqueta = activo ? `ID: ${generarId()}` : "inactivo";
// generarId() no se ejecuta, el ternario no evalúa la rama descartada

Métodos de array: map, filter, find, reduce, some y every

Estos métodos no modifican el array original, devuelven un resultado nuevo y reciben un callback por elemento.

map transforma cada elemento y devuelve un array del mismo largo.

const numeros = [1, 2, 3];
const dobles = numeros.map((n) => n * 2); // [2, 4, 6]

filter conserva los elementos que cumplen la condición.

const pares = numeros.filter((n) => n % 2 === 0); // [2]

find devuelve el primer elemento que cumple la condición, o undefined si no hay ninguno. findIndex devuelve su posición.

const primeroMayor = numeros.find((n) => n > 1); // 2

reduce acumula un resultado a lo largo de todo el array. Es el más versátil y el más fácil de usar mal: si no indicas valor inicial y el array está vacío, lanza un error.

const suma = numeros.reduce((total, n) => total + n, 0); // 6
const maximo = numeros.reduce((max, n) => (n > max ? n : max), -Infinity);

some y every devuelven booleanos y cortan en cuanto tienen respuesta.

numeros.some((n) => n > 2); // true
numeros.every((n) => n > 0); // true
numeros.every((n) => n > 1); // false

En arrays vacíos, some devuelve false y every devuelve true, porque no hay ningún elemento que contradiga la condición.

[].some((n) => n > 0); // false
[].every((n) => n > 0); // true

Cuando encadenas métodos, cada uno crea un array intermedio. Para recorridos largos o bucles dentro de bucles, un for clásico puede ser más rápido porque evita esas asignaciones.

// Encadenado: legible, crea varios arrays intermedios
const resultado = datos
  .filter((d) => d.activo)
  .map((d) => d.nombre);

// Un recorrido: menos memoria, más rápido
const resultado2 = [];
for (const d of datos) {
  if (d.activo) resultado2.push(d.nombre);
}

Objetos y optional chaining

El optional chaining ?. accede a una propiedad solo si el valor no es null ni undefined, y devuelve undefined en lugar de lanzar error. Es la forma más limpia de navegar estructuras anidadas que pueden venir incompletas de una API.

const respuesta = { datos: { usuario: { nombre: "Ana" } } };
console.log(respuesta.datos?.usuario?.nombre); // "Ana"
console.log(respuesta.datos?.perfil?.ciudad);   // undefined, sin error

Sin optional chaining, un solo dato faltante rompe toda la cadena.

// Esto lanza TypeError si "datos" no existe.
console.log(respuesta.datos.usuario.nombre);

// Esto no lanza error.
console.log(respuesta.datos?.usuario?.nombre);

También funciona con llamadas y con índices.

datos?.usuarios?.[0]?.nombre;
objeto?.metodo?.();

Una limitación: el optional chaining es de solo lectura, no se puede usar en el lado izquierdo de una asignación.

objeto?.propiedad = 1; // SyntaxError

Se combina con ?? (nullish coalescing), que devuelve el valor de la derecha solo si el de la izquierda es null o undefined. Es distinto de ||, que también reacciona a 0, "" y false.

const puerto = config.puerto ?? 3000;   // usa 3000 si es null o undefined
const puerto2 = config.puerto || 3000;  // usa 3000 también si es 0 o ""

Promesas y async/await con try/catch

Una promesa representa un valor que todavía no está disponible. Tiene tres estados: pending (en curso), fulfilled (resuelta con un valor) y rejected (fallida con un error). Encadena con then, catch y finally, y cada uno de esos métodos devuelve una promesa nueva.

const promesa = new Promise((resolve, reject) => {
  setTimeout(() => resolve("datos"), 500);
});

promesa
  .then((resultado) => console.log(resultado))
  .catch((error) => console.error(error))
  .finally(() => console.log("terminó"));

async/await es azúcar sintáctico sobre las promesas. Una función async siempre devuelve una promesa, y await pausa esa función hasta que la promesa se resuelva, sin bloquear el hilo de ejecución.

async function cargarDatos() {
  const resultado = await obtenerDatos();
  console.log(resultado);
}

try/catch captura tanto promesas rechazadas como excepciones lanzadas de forma síncrona. En código asíncrono es obligatorio el try/catch, porque un rechazo sin manejar produce un error no controlado.

async function cargarDatos() {
  try {
    const resultado = await obtenerDatos();
    return resultado;
  } catch (error) {
    console.error("Falló la carga:", error.message);
  } finally {
    ocultarCargando();
  }
}

Un detalle sobre el rendimiento: si esperas dos promesas de forma secuencial, tardas el doble. Si son independientes, dispáralas juntas con Promise.all.

// Secuencial: tarda la suma de ambas.
const usuario = await obtenerUsuario();
const pedidos = await obtenerPedidos();

// Paralelo: tarda la más lenta de las dos.
const [usuario, pedidos] = await Promise.all([
  obtenerUsuario(),
  obtenerPedidos(),
]);

La API fetch

fetch es la forma nativa de hacer peticiones HTTP. Recibe una URL y devuelve una promesa que resuelve con un objeto Response. La respuesta no trae el cuerpo: hay que leerlo con métodos como json(), text() o blob().

async function obtenerUsuarios() {
  const respuesta = await fetch("https://api.ejemplo.com/usuarios");

  if (!respuesta.ok) {
    throw new Error(`Error HTTP: ${respuesta.status}`);
  }

  return respuesta.json();
}

Hay un comportamiento que sorprende a mucha gente: fetch solo rechaza si falla la red. Un error del servidor, como un 404 o un 500, resuelve la promesa con ok en false. Por eso conviene revisar respuesta.ok a mano, como en el ejemplo.

El cuerpo se puede leer una sola vez, así que no puedes llamar a json() dos veces sobre la misma respuesta.

const respuesta = await fetch(url);
const datos = await respuesta.json();
// await respuesta.text(); // Error, el cuerpo ya se consumió

Para cancelar una petición, se usa AbortController.

const controlador = new AbortController();
const peticion = fetch(url, { signal: controlador.signal });
controlador.abort(); // cancela la petición

Y para enviar credenciales entre dominios hay que usar credentials, siempre sujeto a lo que permita la política CORS del servidor.

Closures

Un closure es la combinación de una función con el entorno lexicón donde fue definida. Permite que una función "recuerde" variables de su scope aunque ya no exista en la pila de llamadas. Es el motor detrás de var frente a let en bucles, de los contadores privados y de muchas funciones de fábrica.

function crearContador() {
  let total = 0; // vive en el closure

  return {
    incrementar() {
      total++;
      return total;
    },
    obtener() {
      return total;
    },
  };
}

const contador = crearContador();
contador.incrementar(); // 1
contador.incrementar(); // 2
contador.obtener();     // 2

Cada llamada a crearContador crea un total distinto, así que dos contadores no comparten estado.

const a = crearContador();
const b = crearContador();
a.incrementar(); // a = 1
console.log(b.obtener()); // 0, independientes

Un closure mal gestionado puede retener memoria, porque mantiene referencias a las variables de su entorno. Si esas variables son objetos grandes y el closure vive mucho tiempo, la memoria queda retenida. Cuando solo necesitas un valor aislado, conviene capturar el valor en una constante y no el objeto completo.

Hoisting y Event Loop

El hoisting es el comportamiento por el que las declaraciones se procesan antes de ejecutarse el código, pero no todas se comportan igual.

Las declaraciones de var y las de function se elevan. Las function quedan totalmente disponibles; las var quedan inicializadas como undefined.

console.log(a); // undefined, no falla
var a = 1;

saludar(); // "hola", la función ya existe
function saludar() {
  console.log("hola");
}

Las declaraciones con let y const también se elevan, pero caen en la zona muerta temporal (TDZ). Acceder a ellas antes de la línea donde se declaran lanza un ReferenceError.

console.log(b); // ReferenceError, está en la TDZ
let b = 1;

El Event Loop explica el orden en que se ejecutan las tareas asíncronas. Hay una pila de llamadas para el código síncrono, una cola de tareas (macrotareas) y una cola de microtareas. Cuando la pila está vacía, el motor ejecuta primero todas las microtareas pendientes y después pasa a la siguiente macrotarea.

setTimeout es una macrotarea. Las promesas con then y catch son microtareas. Por eso lo que está dentro de una promesa se ejecuta antes que un setTimeout con retardo cero, aunque el setTimeout se haya pedido primero.

console.log("1");

setTimeout(() => {
  console.log("2"); // macrotarea
}, 0);

Promise.resolve().then(() => {
  console.log("3"); // microtarea
});

console.log("4");
// Orden: 1, 4, 3, 2

Aunque el temporizador tenga 0 de retardo, no se ejecuta de inmediato: el retardo es un mínimo, y el callback espera a que se vacíe la pila y se procesen las microtareas.

Manipulación del DOM

El DOM es la representación del documento HTML como un árbol de objetos. querySelector y querySelectorAll buscan elementos por selectores CSS, igual que en CSS. Devuelven un elemento o una NodeList, respectivamente.

const titulo = document.querySelector("h1");
const items = document.querySelectorAll(".lista .item"); // NodeList

addEventListener registra un manejador para un evento, y se puede quitar con el mismo par de función y tipo de evento.

const boton = document.querySelector("#guardar");
function alGuardar() {
  console.log("guardado");
}
boton.addEventListener("click", alGuardar);

// boton.removeEventListener("click", alGuardar); // para quitarlo

La delegación de eventos es una técnica que consiste en poner un solo manejador en un contenedor padre en vez de uno en cada hijo. El evento sube por el árbol, así que desde el contenedor se puede saber en qué hijo se hizo clic. Es la forma más eficiente de manejar listas dinámicas.

const lista = document.querySelector("#lista");

lista.addEventListener("click", (evento) => {
  const item = evento.target.closest(".item");
  if (!item || !lista.contains(item)) return;
  console.log("Clic en:", item.dataset.id);
});

Para crear y modificar elementos conviene usar textContent en vez de innerHTML, porque el primero no interpreta etiquetas y evita inyecciones de HTML.

const li = document.createElement("li");
li.textContent = "Nuevo elemento";
li.classList.add("item");
lista.appendChild(li);

En fragmentos con muchos elementos, conviene agrupar las inserciones en un DocumentFragment y añadirlo una sola vez, porque cada appendChild directo sobre el documento puede provocar un recálculo de estilos.

LocalStorage y SessionStorage

Son dos almacenes de clave y valor que viven en el navegador. localStorage persiste entre sesiones y se comparte entre las pestañas de la misma aplicación, mientras que sessionStorage dura solo mientras la pestaña está abierta y cada una tiene su propia copia.

localStorage.setItem("tema", "oscuro");
const tema = localStorage.getItem("tema"); // "oscuro"

sessionStorage.setItem("paso", "2");

Los dos guardan solo cadenas. Para guardar un objeto hay que serializarlo y, al leerlo, analizarlo.

localStorage.setItem("usuario", JSON.stringify({ nombre: "Ana" }));
const usuario = JSON.parse(localStorage.getItem("usuario"));
// { nombre: "Ana" }

Si el valor guardado no es JSON válido, JSON.parse lanza un error, así que conviene envolver la lectura. localStorage tiene un límite de alrededor de 5 MB por origen, y al superarlo la escritura falla silenciosamente o lanza QuotaExceededError, sobre todo en modo privado. Por eso conviene envolver el uso en try/catch.

function guardarClave(clave, valor) {
  try {
    localStorage.setItem(clave, JSON.stringify(valor));
    return true;
  } catch (error) {
    console.error("No se pudo guardar:", error);
    return false;
  }
}

Las dos APIs son síncronas: bloquean el hilo principal mientras escriben. Para datos grandes, es mejor usar IndexedDB, que es asíncrono y tiene más capacidad.

Módulos: import y export

Los módulos organizan el código en archivos separados con dependencias explícitas. Un archivo se convierte en módulo al usar import o export.

Hay dos formas de exportar. La nombrada, que permite exportar varias cosas, y la por defecto, que exporta una sola cosa principal.

// calculadora.js
export function sumar(a, b) {
  return a + b;
}

export const PI = 3.14;

export default function multiplicar(a, b) {
  return a * b;
}

Al importar se puede renombrar y se puede importar solo lo que se usa.

// main.js
import multiplicar, { sumar, PI as pi } from "./calculadora.js";
import { Logger } from "./logger.js"; // solo ese miembro

sumar(1, 2); // 3
multiplicar(2, 3); // 6

El código dentro de un módulo tiene su propio scope, así que no se filtra nada al scope global. Además, los imports son estáticos: se analizan y se ejecutan antes que el resto del módulo, por lo que deben ir en el nivel superior y no dentro de funciones.

Un detalle sutil: los imports son enlaces vivos. Si un módulo cambia el valor de una variable exportada, quien la importa ve el valor nuevo sin necesidad de reimportar.

// contador.js
export let valor = 0;
export function incrementar() {
  valor++;
}

// main.js
import { valor, incrementar } from "./contador.js";
console.log(valor); // 0
incrementar();
console.log(valor); // 1, el enlace refleja el cambio

Para cargar código bajo demanda se usa import() dinámico, que devuelve una promesa.

async function cargarEditor() {
  const modulo = await import("./editor.js");
  modulo.crearEditor();
}

Esto es la base del code splitting: los módulos pesados solo se descargan cuando de verdad se necesitan.

Clases vs prototipos

Las clases son syntactic sugar sobre el sistema de prototipos, no un modelo de objetos totalmente distinto. Cualquier clase acaba escribiendo métodos en el prototipo de sus instancias. Comprender los prototipos explica lo que hace new, cómo funcionan instanceof y por qué los métodos no se duplican en cada objeto.

function Persona(nombre) {
  this.nombre = nombre;
}

Persona.prototype.saludar = function () {
  return `Hola, soy ${this.nombre}`;
};

const ana = new Persona("Ana");
ana.saludar(); // "Hola, soy Ana"

saludar está en el prototipo, no en ana. Cada objeto creado comparte la misma función.

ana.saludar === new Persona("Luis").saludar; // true, misma función
ana.hasOwnProperty("saludar"); // false, no es suya

Con class la intención queda más clara, pero por debajo sigue siendo lo mismo.

class Persona {
  constructor(nombre) {
    this.nombre = nombre;
  }

  saludar() {
    return `Hola, soy ${this.nombre}`;
  }
}

const ana = new Persona("Ana");
ana.saludar(); // "Hola, soy Ana"

Hay diferencias prácticas. Las declaraciones class se elevan pero quedan en TDZ, así que usarlas antes de definirlas lanza ReferenceError, a diferencia de las funciones. Los métodos de clase no son enumerables, algo que las funciones asignadas al prototipo sí son. Y las clases no se pueden llamar sin new.

saludar(); // ReferenceError, TDZ
class Saludador {
  saludar() {}
}

Para herencia, extends llama a super para invocar el constructor o los métodos de la clase base.

class Empleado extends Persona {
  constructor(nombre, empresa) {
    super(nombre);
    this.empresa = empresa;
  }

  presentar() {
    return `${this.saludar()} y trabajo en ${this.empresa}`;
  }
}

Los campos privados con # solo son accesibles dentro de la propia clase, algo que antes se simulaba con guiones bajos o convenciones.

class Cuenta {
  #saldo = 0;

  depositar(cantidad) {
    this.#saldo += cantidad;
  }

  verSaldo() {
    return this.#saldo;
  }
}

const cuenta = new Cuenta();
cuenta.depositar(100);
console.log(cuenta.verSaldo()); // 100
console.log(cuenta.#saldo); // SyntaxError, es privado

Truthy, Falsy y coerción: == vs ===

JavaScript evalúa cualquier valor como verdadero o falso en contextos booleanos. Solo unos pocos son falsy:

false, 0, -0, 0n, "" (cadena vacía), null, undefined y NaN.

Todo lo demás es truthy, y aquí están las trampas más comunes:

Boolean([]);      // true, un array vacío es un objeto
Boolean({});      // true, un objeto vacío es un objeto
Boolean("0");     // true, es una cadena no vacía
Boolean("false"); // true, es una cadena no vacía

Solo null y undefined representan "nada". El resto son valores válidos que resultan ser cero, vacíos o la cadena de texto "0".

La diferencia entre == y === es la coerción. El operador === compara sin transformar nada; el == convierte los operandos a un tipo común antes de comparar.

5 == "5";  // true, "5" se convierte a número
5 === "5"; // false, son tipos distintos

Una excepción útil de == es comparar contra null, porque es el único caso donde se comprueba null y undefined a la vez.

// Funciona para null y undefined.
if (dato == null) {
  console.log("sin dato");
}

// Con === hay que comprobar los dos.
if (dato === null || dato === undefined) {
  console.log("sin dato");
}

Dos casos que confunden: NaN no es igual a nada, ni siquiera a sí mismo, y en los objetos la comparación es por identidad, no por contenido.

NaN === NaN; // false
{} === {};  // false, son objetos distintos

Para comparar NaN existe Object.is, que además distingue 0 de -0.

Object.is(NaN, NaN); // true
Object.is(0, -0);    // false

La recomendación práctica es usar siempre === y !==. La coerción implícita es la causa más común de comparaciones inesperadas, y evitarla hace el código más predecible.

Resumen

Estos quince conceptos comparten una misma idea de fondo: JavaScript no hace nada implícito por capricho, tiene reglas que conviene conocer. El alcance de bloque, la captura de this, el orden de las microtareas frente a las macrotareas, la copia superficial del spread y la coerción de == son comportamientos con reglas precisas. Cuando se entienden, el código deja de depender de ensayo y error y se vuelve predecible.

© Arturo Juárez Monroy