¿Qué Es una Closure en JavaScript?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

makeCounter se ejecuta una vez, devuelve una función, y ahí termina: su stack frame ya no existe. Aun así, cada llamada a counter() sigue leyendo y actualizando count, una variable declarada dentro de una función que ya terminó. count no vuelve a 0, y no se copia. Es la misma variable, todavía viva, tres llamadas después.

Eso es una closure: una función emparejada con una referencia a las variables que estaban en su ámbito cuando se definió. No una foto de sus valores: una referencia viva. Si algo más cambia count, el contador ve el nuevo valor.

¿Por Qué la Variable Sobrevive Después de que la Función Termina?

JavaScript resuelve los nombres de variables léxicamente, según dónde están escritas en el código, no según quién llama a la función. Para count += 1 dentro de la función interna, el motor recorre los ámbitos que la rodean, de dentro hacia fuera, hasta encontrar una declaración, y encuentra count en makeCounter. Ese enlace queda fijado donde se define la función interna, no donde se llama.

Normalmente, cuando una función termina, nada puede alcanzar sus variables locales y el recolector de basura las libera. Una closure es la excepción. La función devuelta mantiene una referencia al ámbito en el que se creó, así que ese ámbito sigue vivo mientras la función exista. Llama a makeCounter() otra vez y obtienes un segundo contador con su propio count en su propio ámbito:

1
2
3
4
5
const a = makeCounter();
const b = makeCounter();
console.log(a()); // 1
console.log(a()); // 2
console.log(b()); // 1 — ámbito separado, count separado

Cada función que creas en JavaScript cierra sobre su ámbito circundante, la uses o no. Normalmente nada sobrevive a la llamada que la creó, así que nunca lo notas. Importa cuando devuelves la función interna, la pasas a otro lugar, o la guardas: sigue funcionando incluso cuando, de otro modo, el ámbito del padre ya habría desaparecido.

¿Cómo se Crean Variables Privadas con una Closure?

El contador ya muestra el uso principal: datos que solo la función devuelta puede tocar. Nada fuera de makeCounter puede leer o modificar count. No hay ninguna propiedad para eso ni nada que inspeccionar desde la consola. Obtienes el mismo ocultamiento de datos que un campo privado de una clase, construido a partir de una sola llamada a función:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function createAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — no es alcanzable

Los tres métodos cierran sobre la misma balance, así que la comparten. El código externo solo puede alcanzarla a través de los métodos que decidiste exponer. Este es el patrón de módulo: envolver estado privado y una interfaz pública en una sola llamada a función, devolviendo solo la interfaz.

¿Cómo se Memoiza una Función con una Closure?

Cierra sobre un Map y tienes un wrapper de memoización, con una cache compartida entre todas las llamadas a la función devuelta:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function memoize(fn) {
  const cache = new Map();
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

function slowSquare(n) {
  for (let i = 0; i < 1e8; i++) {} // finge que esto es costoso
  return n * n;
}

const fastSquare = memoize(slowSquare);
fastSquare(5); // ejecuta el loop, guarda 25 en caché
fastSquare(5); // devuelve 25 desde la caché, sin loop

Ambos ejemplos siguen la misma forma: llamas a una función que prepara algún estado, y a cambio obtienes una o más funciones que siguen usándolo. La caché aquí nunca elimina nada, así que no envuelvas así una función que recibe entradas distintas sin límite. Los decoradores de Python se basan en el mismo mecanismo: un decorador cierra sobre la función que envuelve y devuelve un sustituto que todavía puede llamarla.

¿Por Qué var en un Loop Rompe las Closures?

Este es el error con closures que casi todo el mundo comete alguna vez:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const callbacks = [];
for (var i = 0; i < 3; i++) {
  callbacks.push(function () {
    console.log(i);
  });
}
callbacks.forEach((cb) => cb());
// 3
// 3
// 3

Querías 0 1 2. Obtuviste 3 3 3. var tiene ámbito de función, no de bloque, así que existe exactamente una i para todo el loop y las tres callbacks cierran sobre ella. Para cuando cualquiera de ellas se ejecuta, el loop ya terminó e i es 3.

Decláralo con let y el bug desaparece:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const callbacks = [];
for (let i = 0; i < 3; i++) {
  callbacks.push(function () {
    console.log(i);
  });
}
callbacks.forEach((cb) => cb());
// 0
// 1
// 2

let tiene ámbito de bloque, y un loop for con let en su cabecera crea un binding independiente para cada iteración. Tres i distintas. Cada callback cierra sobre la de su propia iteración.

Antes de let, la solución era una IIFE que forzaba un nuevo ámbito por iteración pasando i como parámetro:

1
2
3
4
5
6
7
for (var i = 0; i < 3; i++) {
  (function (j) {
    callbacks.push(function () {
      console.log(j);
    });
  })(i);
}

Todavía la vas a encontrar en código antiguo. No la escribas hoy: pon let en la cabecera del loop.

¿Pueden las Closures Causar Fugas de Memoria?

Sí. Una closure mantiene el entorno léxico real donde viven sus variables, durante todo el tiempo en que la propia closure sea alcanzable. Si la enganchas a algo de larga vida, como un event listener del DOM, un array a nivel de módulo, o una caché sin eviction, todo lo que ese entorno carga se queda también en memoria.

El caso común: un listener en un nodo del DOM cierra sobre un objeto grande que el handler apenas usa. El nodo se elimina de la página, el listener nunca se quita, y tanto el nodo como el objeto siguen vivos porque la closure todavía los referencia. Llama a removeEventListener cuando el elemento desaparezca. Y mantén las closures acotadas: si un callback necesita un solo campo, cierra sobre ese campo, no sobre el objeto entero.

El segundo costo es más silencioso. Un método definido dentro de un constructor, en lugar de en el prototype, se recrea con su propia closure en cada instancia, en vez de compartirse entre todas. Si la función interna no captura nada de la externa, no la anides.

¿Cuándo Deberías Usar una Closure?

Probablemente ya las has usado. Un debounce o throttle en JavaScript devuelve un wrapper que cierra sobre un timeoutId que persiste entre llamadas, y así es como cancela la llamada pendiente anterior. Cualquier callback que le pases a setTimeout o a un manejador de promise que lea una variable de la función que la programó funciona igual: el event loop la ejecuta mucho después de que esa función haya terminado.

Usa una closure a propósito cuando necesites estado que persista entre llamadas pero que no deba exponerse como propiedad: un contador, una caché, un temporizador de debounce, una función configurada una vez y llamada más adelante. Para una utilidad sin estado que solo transforma sus argumentos, sáltate el wrapper. No hay nada sobre lo que cerrar, y anidar añade un ámbito del que tienes que ocuparte sin ninguna ventaja.

Artículos relacionados