wandres.dev
REACTIVIDAD PROFUNDA · Proxies y el estado anidado

El coste de la indirección

Qué se paga de verdad por cada acceso a través de un proxy: la llamada a la trampa, la desoptimización del motor de JavaScript, la memoria de las fuentes por propiedad y el efecto sobre las formas ocultas.

⏱ 16 min

Un proxy convierte una lectura de propiedad —una de las operaciones más optimizadas que existen en un motor de JavaScript— en una llamada a función con trabajo adicional. La diferencia es de uno o dos órdenes de magnitud en el acceso individual, y aunque en una interfaz normal eso es irrelevante, hay contextos donde decide. Esta lección pone el coste sobre la mesa, con sus cuatro componentes y con los sitios donde de verdad importa.

🎯 Al terminar esta lección sabrás
  • Enumerar los cuatro componentes del coste de un proxy.
  • Entender el efecto sobre las formas ocultas y la caché en línea.
  • Identificar los contextos donde la indirección decide.
  • Aplicar las tres técnicas para evitarla donde importa.

Los cuatro componentes

La llamada a la trampa. Un acceso normal a propiedad, en un motor moderno, se resuelve con una comprobación de forma oculta y una lectura de un desplazamiento fijo: unos pocos ciclos. Un acceso a través de un proxy invoca la trampa, que es una llamada a función de JavaScript, ejecuta su cuerpo y llama a Reflect.get, que es otra operación. La diferencia es de al menos un orden de magnitud.

El trabajo dentro de la trampa. Buscar o crear la fuente de esa propiedad en un Map, tejer la arista en dos estructuras, comprobar si el valor es un objeto y consultar la caché de proxies. Todo eso ocurre en cada acceso, incluso a la misma propiedad, incluso cientos de veces en un bucle.

La desoptimización. Este es el componente que más se subestima. Los motores de JavaScript optimizan el acceso a propiedades mediante formas ocultas y cachés en línea: tras unas cuantas ejecuciones, el motor sabe que en ese punto siempre llega un objeto con la misma forma y compila el acceso a un desplazamiento directo. Un proxy no tiene forma estable desde el punto de vista del optimizador, así que ese punto de acceso se vuelve megamórfico y el motor deja de optimizarlo. El efecto se propaga: una función que recibe a veces objetos y a veces proxies acaba con todos sus accesos desoptimizados.

La memoria de las fuentes. Una fuente por propiedad leída, cada una con su lista de observadores. Un objeto con veinte propiedades leídas paga veinte fuentes más el Map que las indexa más el propio proxy. Comparado con una señal atómica que contuviera el objeto entero, es mucho más.

⚠️
El problema no es el proxy, es la mezcla

Si todo tu código trabaja siempre con proxies, el motor de JavaScript puede al menos especializar para ese caso. El problema aparece cuando la misma función recibe a veces un objeto crudo y a veces un proxy: el punto de acceso se vuelve polimórfico y el optimizador se rinde. Es un argumento fuerte a favor de la consistencia: o todo el estado de un módulo es reactivo o ninguno lo es, pero no la mitad.

Dónde importa de verdad

En la inmensa mayoría del código de una interfaz, este coste es completamente irrelevante: unas decenas de accesos por actualización, frente a un layout que cuesta milisegundos. Hay cuatro contextos donde sí decide.

Bucles apretados sobre estructuras grandes. Recorrer un array reactivo de cien mil elementos paga cien mil trampas más cien mil comprobaciones de tipo más cien mil consultas de caché. Aquí la diferencia es de milisegundos a decenas de milisegundos.

Algoritmos numéricos. Cualquier cosa que acceda repetidamente a propiedades en un cálculo: física, geometría, procesamiento de señal. La desoptimización mata cualquier posibilidad de que el motor genere código eficiente.

Serialización. Convertir a JSON un objeto reactivo grande atraviesa cada propiedad a través de su trampa, y además puede tejer aristas si ocurre dentro de un contexto reactivo, lo que es casi siempre un error.

Bibliotecas de terceros. Pasar un objeto reactivo a una librería que no lo espera hace que cada acceso interno de esa librería pase por la trampa. Con librerías de cálculo intensivo, la ralentización es dramática, y además pueden crear aristas indeseadas.

Las tres técnicas para evitarlo

Marcar como no reactivo. Casi todos los motores ofrecen una forma de decir que un objeto no debe envolverse nunca. Vue tiene markRaw. Es la solución correcta para instancias de clases de terceros, para estructuras grandes e inmutables, y para cualquier cosa que se pase a una librería externa.

const estado = reactivo({
  configuracion: { tema: 'oscuro' },
  motorFisico: marcarCrudo(new MotorFisico()),   // nunca envolver
});

Reactividad superficial. Envolver solo el primer nivel, dejando los valores anidados crudos. Vue lo ofrece con shallowRef y shallowReactive; Svelte con $state.raw. Es lo correcto cuando el objeto se reemplaza entero en cada cambio en vez de mutarse por dentro.

Salir del proxy antes del bucle. Obtener el objeto crudo una vez y trabajar sobre él dentro del bucle, con la disciplina de leer las dependencias reactivas antes de entrar.

// Lento: cien mil trampas
efecto(() => {
  let suma = 0;
  for (const p of estado.puntos) suma += p.x;
  usar(suma);
});

// Rapido: una lectura reactiva y luego trabajo crudo
efecto(() => {
  const puntos = crudo(estado.puntos);   // depende de estado.puntos
  let suma = 0;
  for (const p of puntos) suma += p.x;    // sin trampas
  usar(suma);
});

Ojo con el cambio de semántica: la segunda versión depende de la identidad del array, no de sus elementos. Si un elemento se muta por dentro, el efecto no se entera. Es un intercambio deliberado y hay que documentarlo.

flowchart TB
A[acceso a propiedad] --> B{es un proxy}
B -->|no| C[desplazamiento directo, unos ciclos]
B -->|si| D[llamada a la trampa]
D --> E[buscar la fuente en un Map]
E --> F[tejer la arista]
F --> G[comprobar tipo y consultar cache]
G --> H[Reflect get]
style A fill:#89b4fa,color:#11111b
style B fill:#f9e2af,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b
style E fill:#f9e2af,color:#11111b
style F fill:#f9e2af,color:#11111b
style G fill:#f9e2af,color:#11111b
style H fill:#f9e2af,color:#11111b
La reactividad profunda cambia coste de escritura por coste de lectura

El intercambio de fondo, formulado con precisión, es este. Con señales atómicas, leer es barato y estructurar es caro: cada lectura es una llamada a una función que ya tiene su fuente identificada, pero tú tienes que decidir a mano cómo repartir el estado en señales, y si lo repartes mal el grano queda demasiado grueso. Con reactividad profunda, estructurar es gratis y leer es caro: escribes un objeto normal y el proxy te da grano por propiedad automáticamente, pero pagas una trampa en cada acceso. Y hay una asimetría que decide la comparación en la práctica: en una interfaz típica hay muchísimas más lecturas que escrituras, porque cada actualización lee decenas de propiedades para producir su salida. Eso empuja el balance hacia el modelo atómico en el caso puramente cuantitativo. Lo que compensa en la otra dirección es humano y no debe subestimarse: el modelo atómico exige decidir la granularidad a mano, y esa decisión se toma mal con frecuencia, sobre todo en equipos grandes o en modelos de datos que evolucionan. Un proxy da granularidad óptima sin pensar. De modo que la elección honesta no es cuál es más rápido —el atómico lo es, y por un margen medible— sino si el equipo va a acertar con la granularidad. Si la respuesta es no, la reactividad profunda es más rápida en la práctica que un modelo atómico mal repartido, y por un margen mucho mayor que el que separa a los dos modelos bien usados.

⚔️ Mide la indireccion
  1. Construye un array de cien mil objetos, en versión cruda y en versión reactiva.
  2. Suma una propiedad de todos con un bucle en ambos casos y compara los tiempos.
  3. Repite obteniendo el objeto crudo antes del bucle y comprueba cuánto recuperas.
  4. Escribe una función que reciba a veces un objeto crudo y a veces un proxy, ejecútala mil veces alternando, y compara con dos funciones separadas.