Inicio / Artículos / Comprendiendo los proxies de JavaScript: trampas, Reflect y patrones reactivos

Comprendiendo los proxies de JavaScript: trampas, Reflect y patrones reactivos

Aprenda cómo los objetos Proxy y Reflect de JavaScript interceptan el acceso a las propiedades para potenciar la validación, las propiedades virtuales y los frameworks reactivos.

1096 palabras

Cada vez que escribes obj.property o le asignas un valor con obj.property = value, estás haciendo uso de operaciones que JavaScript ejecuta en silencio, sin una forma integrada para observar o modificar lo que ocurre en el fondo. El objeto Proxy elimina por completo esa suposición. Te permite envolver un objeto objetivo e interceptar estas operaciones fundamentales, como leer, escribir o eliminar, antes de que tengan efecto. Ese mecanismo de interceptación resulta ser el verdadero motor detrás de muchas de las cosas que parecen “magia” en las bibliotecas y herramientas modernas de JavaScript.

Un envoltorio que puede interceptar cualquier cosa

Un Proxy se sitúa entre tu código y el objeto que envuelve, y un conjunto de funciones de captura deciden qué ocurre realmente para cada tipo de operación que se realiza sobre él.

const config = { retries: 3, timeout: 5000 };

const guardedConfig = new Proxy(config, {
  set(target, key, value) {
    if (key === "retries" && (typeof value !== "number" || value < 0)) {
      throw new TypeError("retries must be a non-negative number");
    }
    target[key] = value;
    return true;
  },
});

guardedConfig.retries = 5;   // works fine
guardedConfig.retries = -1;  // throws TypeError, caught before it ever reaches the object

Escribir guardedConfig.retries = 5 no se parece en nada a una asignación de propiedad ordinaria. Ese es precisamente el objetivo del diseño: la intercepción permanece invisible en el lugar donde se realiza la llamada. Se pueden añadir validaciones, registros o otros efectos secundarios sobre lo que parece ser un acceso simple a una propiedad, sin tener que realizar cambios en el código que realmente utiliza el objeto.

El mecanismo detrás de los frameworks reactivos

function reactive(target, onChange) {
  return new Proxy(target, {
    get(obj, key) {
      return obj[key];
    },
    set(obj, key, value) {
      const changed = obj[key] !== value;
      obj[key] = value;
      if (changed) onChange(key, value);
      return true;
    },
  });
}

const state = reactive({ count: 0 }, (key, value) => {
  console.log(`${key} changed to ${value}, re-rendering...`);
});

state.count = 1; // "count changed to 1, re-rendering..." — no explicit call needed

Escribir state.count = 1 suena como una asignación completamente normal, ya que sintácticamente lo es. Lo que le da sentido es la trampa de set, la cual convierte esa línea aparentemente sencilla en un evento al que el resto del sistema puede responder. Esta es la verdadera base en la que se apoyan los frameworks reactivos. No existe un tipo de dato “observable” especial al que debas recurrir en todas partes; se trata simplemente de objetos comunes envueltos en un Proxy que detecta silenciosamente cada escritura realizada en ellos.

Propiedades virtuales: valores que existen solo cuando se solicitan

Una trampa de get puede devolver algo que en realidad nunca se almacenó en el objetivo subyacente, lo que significa que puedes exponer valores calculados o derivados como si fueran propiedades ordinarias.

const product = { name: "Desk Lamp", priceCents: 3499 };

const productView = new Proxy(product, {
  get(target, key) {
    if (key === "priceDollars") {
      return (target.priceCents / 100).toFixed(2);
    }
    return target[key];
  },
});

console.log(productView.priceDollars); // "34.99" — computed on read, not stored anywhere

No existe ningún campo priceDollars en el objeto product. Este se genera dinámicamente cada vez que se accede a él, dentro de la trampa get. Esto es significativamente diferente de un getter que se escribiría con get dentro de una clase u objeto literal, ya que una trampa Proxy puede interceptar el acceso a claves que nunca se declararon con antelación. Esto es útil, por ejemplo, cuando se desea envolver una respuesta de API con campos calculados adicionales sin modificar el contenido original.

Por qué Reflect suele aparecer junto a Proxy

Hay una sutileza que sorprende a quienes escriben por primera vez una trampa que necesita recurrir al comportamiento predeterminado. Hacerlo de la manera ingenua puede llevar fácilmente a errores sutiles.

const handler = {
  get(target, key) {
    console.log(`reading ${key}`);
    return target[key]; // works, but loses some edge-case correctness
  },
};

Para objetos simples y normales, este enfoque suele funcionar bien, pero la forma más segura y correcta de hacer que una operación siga su comportamiento predeterminado es a través de Reflect. Este proporciona una función para cada caso especial que replica exactamente la operación, preservando el enlace correcto de this y manejando adecuadamente situaciones más complejas, como los getters heredados.

const handler = {
  get(target, key, receiver) {
    console.log(`reading ${key}`);
    return Reflect.get(target, key, receiver);
  },
};

Llamar a Reflect.get(target, key, receiver) reproduce exactamente lo que habría hecho un acceso normal a una propiedad, incluyendo casos límite relacionados con prototipos y getters que dependen de this, situaciones en las que target[key] puede producir silenciosamente un resultado incorrecto. En el código práctico de Proxy, la convención es casi siempre llamar al método correspondiente de Reflect desde dentro de cada trampa, a menos que se esté modificando intencionalmente el comportamiento por defecto, ya que esta es la versión garantizada para coincidir con lo que haría normalmente un acceso simple a una propiedad.

Trampas que restringen en lugar de extender

Las trampas no solo son útiles para añadir comportamientos, sino que también pueden eliminarlos con la misma facilidad. Una trampa has controla lo que devuelve el operador in, y una trampa deleteProperty puede impedir por completo la eliminación.

const secureRecord = new Proxy(
  { id: 1, ssn: "123-45-6789" },
  {
    get(target, key) {
      if (key === "ssn") throw new Error("Direct access to ssn is not allowed");
      return Reflect.get(target, key);
    },
    deleteProperty() {
      throw new Error("Deleting fields is not allowed on this record");
    },
  }
);

console.log(secureRecord.id); // 1
console.log(secureRecord.ssn); // throws
delete secureRecord.id; // throws

Este tipo de protección difiere de manera importante de un campo de clase privado: puede aplicarse a cualquier objeto, sin necesidad de que ese objeto haya sido definido inicialmente como una clase, y permite establecer reglas específicas por clave que un simple límite #private no puede expresar.

Una idea, muchas trampas

Cada trampa en realidad responde a la misma pregunta: ¿qué debería ocurrir en el instante en que el código intenta realizar esta operación cotidiana sobre este objeto? Leer un valor, escribir uno, eliminarlo, verificar si existe una clave; todas estas son acciones normalmente invisibles y automáticas, y Proxy es lo que convierte cada una en un momento que se puede observar o redirigir. Los sistemas de estado reactivos, los envoltorios de validación, las capas de control de acceso y los campos calculados virtuales: ninguno de estos son trucos aislados provenientes de fuentes diferentes. Todos se basan en el mismo mecanismo subyacente, simplemente dirigidos hacia una trampa distinta.

Lecturas relacionadas