El proxy como instrumento de reactividad
Cómo se convierte un objeto normal en uno reactivo interceptando get y set, qué trampas hacen falta de verdad, y las operaciones del lenguaje que un proxy no puede interceptar.
Hasta ahora cada valor reactivo era una función explícita que se llama para leer. La alternativa es hacer que un objeto normal se comporte como reactivo sin cambiar la sintaxis de acceso: estado.usuario.nombre en vez de usuario().nombre. Eso se consigue con un Proxy que intercepta cada lectura y cada escritura, y esta lección lo construye entero, incluidas las cinco trampas que hacen falta y las operaciones que se escapan.
- Implementar un objeto reactivo profundo con
Proxy. - Enumerar las trampas necesarias y qué operación cubre cada una.
- Modelar cada propiedad como una fuente independiente.
- Identificar las operaciones del lenguaje que no se pueden interceptar.
La idea
Un Proxy envuelve un objeto e intercepta las operaciones que se hacen sobre él. Si la trampa de lectura teje una arista y la de escritura propaga, el objeto se comporta como un conjunto de señales sin que nadie escriba paréntesis.
function reactivo(objetivo) {
const fuentes = new Map(); // una fuente por propiedad
const fuenteDe = (clave) => {
let f = fuentes.get(clave);
if (!f) { f = { observadores: new Set() }; fuentes.set(clave, f); }
return f;
};
return new Proxy(objetivo, {
get(obj, clave, receptor) {
seguir(fuenteDe(clave)); // la lectura teje la arista
const v = Reflect.get(obj, clave, receptor);
return typeof v === 'object' && v !== null ? reactivo(v) : v;
},
set(obj, clave, valor, receptor) {
const anterior = obj[clave];
const ok = Reflect.set(obj, clave, valor, receptor);
if (ok && !Object.is(anterior, valor)) marcar(fuenteDe(clave), SUCIO);
return ok;
},
});
}
Con eso ya funciona lo esencial.
const estado = reactivo({ usuario: { nombre: 'Ana' }, contador: 0 });
efecto(() => pintar(estado.usuario.nombre)); // teje aristas con usuario y con nombre
estado.contador = 5; // no reejecuta: distinta propiedad
estado.usuario.nombre = 'Eva'; // si reejecuta
Fíjate en la propiedad más valiosa: el grano es por propiedad. Escribir contador no afecta a quien lee nombre, aunque estén en el mismo objeto. Con una señal atómica que contuviera el objeto entero, cualquier cambio ensuciaría a todos los lectores.
Las cinco trampas necesarias
Con get y set no basta. Estas son las cinco que hacen falta para que el objeto se comporte de verdad como uno normal.
get, para leer una propiedad. Teje la arista y envuelve el valor si es un objeto.
set, para escribir. Propaga si el valor cambió. También hay que propagar en la fuente que representa la existencia de claves si la propiedad es nueva, porque alguien puede estar iterando.
has, para el operador in. Sin ella, 'nombre' in estado no teje ninguna arista.
deleteProperty, para delete. Hay que propagar en la propiedad borrada y en la de iteración.
ownKeys, para Object.keys, for...in y el operador de propagación. Necesita una fuente propia que represente el conjunto de claves.
const CLAVES = Symbol('iteracion');
const trampas = {
get(obj, clave, receptor) { /* ... */ },
set(obj, clave, valor, receptor) {
const esNueva = !Object.prototype.hasOwnProperty.call(obj, clave);
const anterior = obj[clave];
const ok = Reflect.set(obj, clave, valor, receptor);
if (!ok) return false;
if (esNueva) marcar(fuenteDe(CLAVES), SUCIO);
else if (!Object.is(anterior, valor)) marcar(fuenteDe(clave), SUCIO);
return true;
},
has(obj, clave) { seguir(fuenteDe(clave)); return Reflect.has(obj, clave); },
deleteProperty(obj, clave) {
const existia = Object.prototype.hasOwnProperty.call(obj, clave);
const ok = Reflect.deleteProperty(obj, clave);
if (ok && existia) { marcar(fuenteDe(clave), SUCIO); marcar(fuenteDe(CLAVES), SUCIO); }
return ok;
},
ownKeys(obj) { seguir(fuenteDe(CLAVES)); return Reflect.ownKeys(obj); },
};
La fuente de iteración es la que más se olvida al implementar esto, y su ausencia produce un bug muy concreto: añadir una clave nueva no reejecuta a quien estaba recorriendo el objeto.
Un array es un objeto con claves numéricas y una propiedad length que el motor de JavaScript modifica sola. Un push dispara una escritura en el índice nuevo y otra en length. Los métodos que mutan —sort, reverse, splice— hacen muchas escrituras individuales, cada una de las cuales propaga. Sin cuidado, un sort de mil elementos produce miles de propagaciones. La solución habitual es envolver esos métodos para agruparlos en un lote.
Lo que un proxy no puede interceptar
Cuatro casos, y los cuatro son limitaciones del lenguaje y no de la implementación.
Las ranuras internas de las clases nativas. Map, Set, Date, WeakMap y compañía guardan sus datos en ranuras internas a las que los métodos acceden directamente, sin pasar por propiedades. Un map.get(k) no dispara ninguna trampa. La única forma de hacerlos reactivos es sustituir los métodos por versiones instrumentadas, y eso significa que el proxy tiene que reconocer el tipo y tratarlo especialmente. Vue lo hace para Map y Set; Svelte publica clases equivalentes en svelte/reactivity.
Los campos privados de clase. Un campo declarado con almohadilla es inaccesible desde el proxy, y además cualquier método que lo use fallará si se llama sobre el proxy en lugar de sobre el objeto original, porque la comprobación de marca de campo privado no reconoce al proxy.
La identidad. El proxy no es el objeto. proxy === objetivo es falso, y eso rompe cualquier código que compare por identidad, incluidas las claves de un Map o un Set externo. Es la causa de una clase entera de bugs sutiles.
La destructuración. No es que no se pueda interceptar —de hecho dispara get— sino que la interceptación ocurre una sola vez, en el momento de desestructurar, y a partir de ahí tienes un valor plano sin ninguna conexión. Es la trampa más común de todas y tiene su propia lección en este nivel.
flowchart TB P[proxy] --> I[si intercepta] P --> N[no intercepta] I --> I1[lectura de propiedad] I --> I2[escritura de propiedad] I --> I3[operador in] I --> I4[delete] I --> I5[Object keys y for in] N --> N1[metodos de Map y Set] N --> N2[campos privados de clase] N --> N3[comparacion por identidad] style P fill:#cba6f7,color:#11111b style I fill:#a6e3a1,color:#11111b style N fill:#f38ba8,color:#11111b style I1 fill:#89b4fa,color:#11111b style I2 fill:#89b4fa,color:#11111b style I3 fill:#89b4fa,color:#11111b style I4 fill:#89b4fa,color:#11111b style I5 fill:#89b4fa,color:#11111b style N1 fill:#f9e2af,color:#11111b style N2 fill:#f9e2af,color:#11111b style N3 fill:#f9e2af,color:#11111b
Lo que hay que entender sobre este mecanismo es que no cambia nada del motor. Debajo del proxy siguen estando exactamente las mismas fuentes, las mismas aristas y la misma propagación de los ocho niveles anteriores; una por propiedad, en lugar de una por variable. El proxy es una capa de traducción de sintaxis: convierte estado.a.b en la lectura de una fuente concreta, y estado.a.b = x en su escritura. Nada más. Reconocer esto ahorra mucha confusión, porque la reactividad profunda se presenta a veces como una clase distinta de reactividad y no lo es; es la misma con azúcar sintáctico. Y de ahí sale la consecuencia que de verdad importa a la hora de elegir: todo el coste y todas las propiedades del modelo atómico siguen ahí, más el coste del proxy. Sigues teniendo una fuente por propiedad con su lista de observadores; sigues pagando el tejido de aristas en cada lectura; sigues necesitando lotes para la atomicidad. Lo único que has comprado es no escribir paréntesis, y lo único que has pagado es una llamada a una trampa en cada acceso, más la creación perezosa de proxies anidados. Si esa compra vale la pena depende enteramente de cuánto valores la ergonomía frente a la explicitud, que es una cuestión de gusto y de equipo, no técnica. Lo que no es cuestión de gusto es lo que se pierde en claridad: cuando cada acceso a propiedad puede ser una suscripción, deja de ser evidente mirando el código qué está suscrito a qué, y eso tiene un coste de depuración real que la lección 4 de este nivel desarrolla.
Quién lo usa
Vue lo usa como mecanismo central con reactive, y ofrece ref para el caso atómico. Solid lo usa en createStore, con una diferencia importante: sus proxies son de solo lectura desde fuera y las mutaciones pasan por una función de actualización, lo que le permite agrupar y controlar mejor la propagación. Svelte 5 usa proxies en $state para objetos y arrays, con $state.raw para desactivarlo. Angular y Preact Signals no lo ofrecen: son atómicos.
- Implementa el objeto reactivo con las cinco trampas y verifica que cada una teje o propaga donde debe.
- Comprueba que añadir una clave nueva reejecuta un efecto que hacía
Object.keys. - Guarda un objeto reactivo como clave de un
Mapexterno y comprueba qué pasa al buscarlo con el objeto original. - Envuelve un
Mapcon tu proxy y verifica quegetysetno disparan ninguna trampa.