Inicio / Artículos / La herramienta oculta de JavaScript: símbolos, WeakMaps, proxies y generadores

La herramienta oculta de JavaScript: símbolos, WeakMaps, proxies y generadores

Aprenda cómo funcionan en el fondo los símbolos, WeakMap/WeakSet, Proxy/Reflect, FinalizationRegistry y los generadores para prevenir fugas de memoria y permitir la metaprogramación a nivel de framework.

2046 palabras

Descubriendo el motor oculto detrás de tus frameworks favoritos y deteniendo las fugas de memoria de una vez por todas

La mayoría de las veces, el desarrollo en JavaScript se basa en un conjunto de herramientas básicas conocidas: const, let, funciones de flecha, map/filter, desestructuración de arrays y async/await. Este subconjunto cotidiano maneja la gran mayoría de las tareas que se realizan.

Pero la especificación ECMAScript también incluye un conjunto de herramientas menos conocidas: tipos primitivos, contenedores de datos especializados y mecanismos de metaprogramación de bajo nivel que casi nunca aparecen en los tutoriales introductorios. Los creadores de frameworks y bibliotecas recurren constantemente a ellos para evitar fugas de memoria, sortear conflictos de nombres, acceder al comportamiento interno del motor y mantener estables los grandes conjuntos de código. Una vez que los comprendes, comienzas a verlos en todas partes dentro de las dependencias que ya utilizas.

Acomodarse a estas características cambia la forma en que se piensa sobre los ciclos de vida de los objetos, los límites de la encapsulación y el rendimiento en tiempo de ejecución. A continuación se presenta un recorrido por las capacidades de las que casi con certeza ha beneficiado en código de producción sin saber que existían.

1. Símbolos: claves que nunca chocan

Symbol, añadido en ES6, es un tipo primitivo que se encuentra junto a string, number, boolean, null, undefined y bigint.

Cada llamada a Symbol() genera un valor completamente nuevo y único. Incluso dos símbolos creados con la misma descripción exacta nunca son iguales entre sí:

const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2); // false

El texto que se pasa a Symbol('id') no es más que una etiqueta opcional para la salida de depuración; no influye en la identidad del símbolo.

¿Por qué existen los símbolos en primer lugar?

Antes de que existieran los símbolos, las claves de los objetos tenían que ser cadenas de texto. Eso creaba un verdadero peligro: si se escribía una biblioteca que añadía metadatos internos a un objeto de propiedad de otra persona, era fácil dañar una propiedad que ya existía allí:

// Risky: Another script might already use `isProcessed`
user.isProcessed = true;

Los símbolos evitan esto por completo, ya que no hay forma de que otro fragmento de código genere accidentalmente el mismo símbolo:

const TRACKING_ID = Symbol('trackingId');

const user = {
  name: 'Sarah',
  role: 'Admin'
};

// Safe: Nothing can clash with this exact key
user[TRACKING_ID] = 'txn_89412';

El manto de invisibilidad

Las propiedades cuyas claves son símbolos son ignoradas por las herramientas habituales de reflexión y serialización:

  • Los bucles for...in las ignoran.
  • Object.keys(user) las excluye.
  • JSON.stringify(user) las elimina por completo.
console.log(Object.keys(user)); // ['name', 'role']
console.log(JSON.stringify(user)); // '{"name":"Sarah","role":"Admin"}'

Dicho esto, las claves de símbolos no son realmente secretas: llamar a Object.getOwnPropertySymbols(user) seguirá revelándolas. Pero para fines de iteración y serialización ordinarios, permanecen ocultas, lo que las hace ideales para flags internos, valores memorizados y metadatos a nivel de plugin que no deberían filtrarse a la API pública.

Símbolos bien conocidos: acceso a los componentes internos del lenguaje

El lenguaje también incluye un conjunto de símbolos integrados, llamados símbolos bien conocidos, que se exponen como propiedades estáticas en el objeto Symbol. Estos permiten acceder directamente a los protocolos en los que depende el propio motor.

Iteración personalizada mediante Symbol.iterator

Puedes hacer que cualquier objeto común funcione con for...of implementando este símbolo tú mismo:

const inventory = {
  items: ['Keyboard', 'Monitor', 'Desk Pad'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const item of inventory) {
  console.log(item); // Prints: Keyboard, Monitor, Desk Pad
}

Comportamiento personalizado de coerción con Symbol.toPrimitive

Esto le permite decidir exactamente qué sucede cuando su objeto se convierte en una cadena o un número:

const money = {
  amount: 250,
  currency: 'USD',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.amount} ${this.currency}`;
    return this.amount; // default
  }
};

console.log(+money + 50); // 300
console.log(`${money}`);  // "250 USD"

2. WeakMap y WeakSet: limpieza de memoria sin registro manual

Para entender por qué es importante WeakMap, ayuda primero observar cómo un Map ordinario gestiona la memoria.

Un Map regular mantiene una referencia fuerte a cada clave y valor almacenado en su interior. Eso significa que mientras el propio Map siga activo, ninguna de sus claves podrá ser recogida por el recolector de basura, incluso después de que todas las demás partes de su aplicación hayan dejado de hacer referencia a ellas.

let cache = new Map();
let element = document.querySelector('#heavy-widget');

cache.set(element, { clicks: 0 });

// Later, the DOM node is removed:
element.remove();
element = null;

// Problem: The DOM node is still stuck in memory because `cache` holds it.

Este es un origen común de fugas de memoria en el lado del cliente, especialmente en aplicaciones de página única donde los usuarios pasan de una vista a otra sin que se recargue toda la página para reiniciar la memoria.

Es aquí donde entra en juego WeakMap. Solo almacena referencias débiles a sus claves, lo que tiene dos consecuencias importantes:

  1. Las claves deben ser objetos (o, en motores modernos, símbolos no registrados); las primitivas como cadenas o números no se permiten como claves.
  2. Una vez que nada más en el programa haga referencia a un objeto clave, el recolector de basura puede recuperarlo, y la entrada correspondiente de WeakMap desaparece junto con él.
const metadataStore = new WeakMap();

function setupWidget(domNode) {
  metadataStore.set(domNode, { initializedAt: Date.now() });
}

// When domNode is removed from the DOM and its variable goes out of scope,
// the WeakMap entry is garbage-collected automatically.

Los compromisos integrados en el diseño

Dado que la recolección de basura se ejecuta en momentos impredecibles, determinados por el navegador y no por tu código, WeakMap limita deliberadamente lo que puedes hacer con él:

  • No es iterable: no hay .forEach(), ni for...of, ni .keys().
  • No existe la propiedad .size, por lo que no puedes verificar cuántas entradas existen actualmente.
  • Solo hay cuatro métodos disponibles: .get(), .set(), .has() y .delete().

Si pudieras enumerar las claves o leer el valor de tamaño, los resultados dependerían de si la recolección de basura acababa de ejecutarse, lo que haría que el comportamiento de tu programa fuera efectivamente no determinista. Al eliminar la posibilidad de iteración, la API se mantiene predecible independientemente del momento en que tenga lugar la recolección de basura.

Un patrón práctico: estado de instancia verdaderamente privado

Antes de que los campos privados nativos de las clases (#field) contaran con amplio soporte, WeakMap era la técnica estándar para dotar a las instancias de clase de un estado interno verdaderamente privado:

const privateData = new WeakMap();

class BankAccount {
  constructor(initialBalance) {
    privateData.set(this, { balance: initialBalance });
  }

  deposit(amount) {
    const data = privateData.get(this);
    data.balance += amount;
  }

  getBalance() {
    return privateData.get(this).balance;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);       // undefined (completely inaccessible)

Dado que la propia instancia (this) se utiliza como clave, una vez que la instancia deja de ser referenciada en ningún lugar, sus datos privados se eliminan automáticamente junto con ella.

3. Proxy y Reflect: metaprogramación que puedes utilizar hoy

Proxy envuelve un objeto y te permite interceptar sus operaciones más básicas: leer una propiedad, asignarle un valor, llamar a una función o eliminar una clave.

Si alguna vez has actualizado estado reactivo en Vue 3 o has visto cómo una biblioteca reactiva moderna registra cambios automáticamente, estabas interactuando con un Proxy en segundo plano.

const targetUser = { name: 'Alex', age: 28 };

const handler = {
  get(target, prop, receiver) {
    console.log(`Reading property "${prop}"`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be a valid number.');
    }
    console.log(`Setting "${prop}" to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const monitoredUser = new Proxy(targetUser, handler);

monitoredUser.age = 29; // Logs: Setting "age" to 29
console.log(monitoredUser.name); // Logs: Reading property "name" -> "Alex"
// monitoredUser.age = 'thirty'; // Throws TypeError

Puede que hayan notado el uso de Reflect.get() y Reflect.set() dentro de ese manejador Proxy. Reflect es un objeto global incorporado que contiene métodos que replican las operaciones que puede interceptar un Proxy. Cada trampa que se pueda definir en un manejador Proxy tiene un método correspondiente en Reflect que acepta los mismos argumentos. En lugar de escribir manualmente algo como target[prop] = value dentro de una trampa set —lo cual puede fallar de formas sutiles cuando intervienen prototipos o propiedades de acceso—, se llama al método correspondiente de Reflect, el cual ejecuta de forma segura el comportamiento predeterminado del motor y devuelve un valor booleano que indica si la operación tuvo éxito realmente.

4. FinalizationRegistry y WeakRef: observar la recolección de basura

Durante mucho tiempo, no existía forma en JavaScript de saber cuándo un objeto había sido realmente recuperado por el motor de recolección de basura. ES2021 llenó esa laguna con dos API de bajo nivel relacionadas, orientadas a la programación al estilo de sistemas avanzados: WeakRef y FinalizationRegistry.

Un WeakRef almacena una referencia a un objeto sin impedir que el recolector de basura lo recupere, pero aún así permite recuperar el objeto mientras siga activo:

let heavyAsset = { buffer: new ArrayBuffer(1024 * 1024 * 16) }; // 16MB buffer
const assetRef = new WeakRef(heavyAsset);

// Dereference to access
const asset = assetRef.deref();
if (asset) {
  // Asset still exists in memory
  console.log("Using cached asset");
} else {
  // Asset was collected by GC; reload it
  console.log("Asset was garbage collected");
}

FinalizationRegistry complementa esto al permitir registrar una función de callback que se ejecuta una vez que el objeto ha sido recolectado. Se utiliza típicamente para liberar recursos externos vinculados a ese objeto o para registrar información de diagnóstico:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Cleanup hook: Resource "${heldValue}" was garbage collected.`);
});

(() => {
  const temporaryWorker = { id: 'worker_404' };
  registry.register(temporaryWorker, temporaryWorker.id);
  // temporaryWorker leaves scope here
})();

Tenga precaución con estas dos APIs. El momento en que se realiza la recolección de basura varía entre los navegadores y los motores de JavaScript, y no se puede predecir ni forzar. Nunca debe vincular la lógica esencial de la aplicación —como el almacenamiento persistente del estado, la confirmación de transacciones o cualquier cosa de la que dependa su aplicación— a una función de finalización, ya que no hay garantía de cuándo, o incluso si, se ejecutará a tiempo.

5. Funciones generadoras: pausar y reanudar según sea necesario

Las funciones normales de JavaScript siguen la regla de ejecución hasta completarse: una vez llamadas, continúan ejecutándose hasta que encuentran un return o lanzan una excepción, sin detenerse en ningún momento intermedio.

Las funciones generadoras, definidas con la sintaxis function*, rompen esta regla. Pueden suspenderse a mitad de ejecución y reanudarse más tarde, utilizando la palabra clave yield para marcar los puntos de pausa.

function* idGenerator() {
  let id = 1;
  while (true) {
    yield `UID_${id++}`;
  }
}

const gen = idGenerator();
console.log(gen.next().value); // UID_1
console.log(gen.next().value); // UID_2
console.log(gen.next().value); // UID_3

Observe con atención el bucle while (true) dentro de ese generador. En una función normal, un bucle infinito como ese bloquearía inmediatamente el hilo de ejecución. Sin embargo, dentro de un generador, la ejecución se detiene en el momento en que se alcanza yield, devolviendo el control a quien lo llamó; no ocurre nada más hasta que se vuelve a invocar .next().

Esto hace que los generadores sean muy adecuados para procesar grandes cantidades de datos sin tener que almacenarlos todos en memoria. En lugar de leer medio millón de líneas de un archivo en un único array grande, un generador puede entregar cada línea relevante una a la vez, según sea necesario:

function* processLargeLogFile(lines) {
  for (const line of lines) {
    if (line.includes('ERROR')) {
      yield line.trim();
    }
  }
}

// Memory remains flat because lines are yielded one by one as consumed
for (const errorLine of processLargeLogFile(rawLines)) {
  sendToAlertService(errorLine);
}

Ninguna de estas características requiere que modifique por completo una base de código existente de inmediato. Su verdadero valor radica en saber cuándo utilizarlas en lugar de un enfoque convencional que, de lo contrario, generaría código frágil o una sobrecarga de memoria innecesaria.

La próxima vez que esté añadiendo metadatos a nodos DOM que cambian dinámicamente, utilice un WeakMap. Cuando esté diseñando una arquitectura de plugins donde el código externo necesite definir sus propias claves que no entren en conflicto, emplee un Symbol. Y cuando necesite una capa de datos reactiva que supervise los cambios automáticamente, constrúyala sobre la base de un Proxy.

Aprender a utilizar estas herramientas es lo que diferencia la escritura de código para aplicaciones de la ingeniería de software diseñado para escalar.

Lecturas relacionadas

  • Encontrar precios ocultos mediante el ingeniería inversa de las llamadas API en segundo plano — Aprenda a usar las herramientas DevTools del navegador para encontrar y replicar las solicitudes XHR, REST y GraphQL que cargan los precios sin necesidad de analizar HTML.