Integrar con Solid y con Astro: señales sin re-render e islas sin SSR
Por qué Solid encaja especialmente bien con un bucle de render, cómo montar una escena como isla de Astro, y qué directiva de cliente elegir para que no se intente renderizar en el servidor.
Solid y Astro representan dos ideas que benefician mucho al 3D y que casi nadie aprovecha del todo. Solid porque su reactividad no reconstruye el árbol de componentes: una señal que cambia ejecuta un efecto y nada más, que es exactamente lo que un bucle de render necesita para recibir valores sin pagar reconciliación. Astro porque su modelo de islas te deja publicar una página que es HTML estático con un canvas que solo se hidrata cuando entra en pantalla, lo que resuelve por construcción el problema de cargar tres megabytes de motor 3D en una página donde el 3D es un adorno.
- Montar una escena en Solid con
onMountyonCleanupy alimentarla con señales sin recrearla. - Explicar por qué un efecto de Solid es un canal más barato hacia el bucle que un efecto de React.
- Elegir la directiva de cliente correcta en Astro y saber por qué
client:onlyes obligatoria aquí. - Cargar el motor de forma diferida para que el peso no entre en la ruta crítica.
Solid: efectos de grano fino contra el bucle
La diferencia entre Solid y un framework con reconciliación no es de sintaxis. En Solid, el cuerpo de un componente se ejecuta una sola vez. Lo que se vuelve a ejecutar cuando cambia una señal son los efectos que la leen, y solo esos. Para una escena 3D eso significa que la closure del bucle nunca se recrea y que no hace falta el objeto mutable intermedio de la lección anterior: el propio efecto es el canal.
import { onMount, onCleanup, createSignal, createEffect } from 'solid-js';
import * as THREE from 'three';
export default function Escena() {
let contenedor;
const [velocidad, setVelocidad] = createSignal(1);
// Referencias que los efectos van a tocar. Viven en el cierre del
// componente, que se ejecuta una vez y nunca mas.
let malla, renderer, scene, camera, controlVelocidad = 1;
onMount(() => {
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
contenedor.appendChild(renderer.domElement);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.z = 4;
const geometria = new THREE.TorusKnotGeometry(0.7, 0.25, 128, 32);
const material = new THREE.MeshStandardMaterial({ roughness: 0.3 });
malla = new THREE.Mesh(geometria, material);
scene.add(malla, new THREE.HemisphereLight(0xffffff, 0x223344, 2));
const ajustar = () => {
const { clientWidth: w, clientHeight: h } = contenedor;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
};
const observador = new ResizeObserver(ajustar);
observador.observe(contenedor);
ajustar();
const reloj = new THREE.Timer();
reloj.connect(document);
renderer.setAnimationLoop((marca) => {
reloj.update(marca);
const dt = Math.min(reloj.getDelta(), 1 / 30);
malla.rotation.y += dt * controlVelocidad;
renderer.render(scene, camera);
});
onCleanup(() => {
renderer.setAnimationLoop(null);
observador.disconnect();
reloj.dispose();
geometria.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
});
});
// Este efecto se ejecuta cuando cambia la senal, y solo hace una
// asignacion. Ni reconciliacion ni recreacion de la escena.
createEffect(() => {
controlVelocidad = velocidad();
});
return (
<>
<div ref={contenedor} style={{ width: '100%', height: '60vh' }} />
<input
type="range" min="0" max="5" step="0.1"
value={velocidad()}
onInput={(e) => setVelocidad(Number(e.currentTarget.value))}
/>
</>
);
}
Dos cosas que conviene subrayar. La primera es que onCleanup se puede registrar dentro de onMount, y se ejecuta cuando el componente se destruye. Eso mantiene la creación y la destrucción del mismo recurso en el mismo bloque, que es la forma más difícil de olvidar algo.
La segunda es que createEffect fuera de onMount se ejecuta también en el primer render, antes de que exista la escena. Aquí no importa porque solo asigna a una variable, pero si el efecto tocara malla habría que protegerlo. La alternativa limpia es declarar los efectos también dentro de onMount, donde el orden ya está garantizado.
Solid no tiene el doble montaje del modo estricto de React, así que un fallo de limpieza no se manifiesta en desarrollo. La contrapartida es que hay que ser más disciplinado: escribe la prueba de montar y desmontar veinte veces a mano, porque el framework no te la va a regalar.
Astro: islas, y por qué client:only
Astro renderiza los componentes en el servidor por defecto y envía HTML. Un componente que crea un WebGLRenderer no puede renderizarse en el servidor: no hay document, no hay canvas, no hay contexto WebGL. El resultado es un error de construcción o, peor, un componente que falla silenciosamente y deja un hueco.
La solución es client:only, que le dice a Astro que ese componente no se renderice en el servidor en absoluto y que se monte únicamente en el cliente. Requiere indicar el framework como valor, porque sin renderizado en servidor Astro no puede inferirlo.
---
// src/pages/producto.astro
import Escena from '../componentes/Escena.jsx';
import Cabecera from '../componentes/Cabecera.astro';
---
<html lang="es">
<body>
<Cabecera />
<section class="visor">
<!-- Contenido real que existe con y sin 3D -->
<h1>Silla Kompas</h1>
<p>Estructura de fresno macizo, asiento tapizado en lana.</p>
<!-- La isla: no se renderiza en servidor, se monta al ser visible -->
<Escena client:only="solid-js" />
</section>
</body>
</html>
La tabla de directivas, con el criterio de cuándo usar cada una para 3D:
| Directiva | Qué hace | Para 3D |
|---|---|---|
client:load |
Hidrata en cuanto se carga la página | Solo si el 3D es lo primero que se ve |
client:visible |
Hidrata cuando entra en el viewport | La mejor opción por defecto |
client:idle |
Hidrata cuando el navegador está ocioso | Para 3D decorativo bajo el pliegue |
client:only |
No renderiza en servidor, hidrata en cliente | Obligatoria con Three.js |
La confusión habitual es creer que hay que elegir una. client:only resuelve el problema del servidor y client:visible resuelve el del momento. Astro permite combinar el comportamiento de retraso con la exclusión del servidor usando client:only junto con carga diferida del propio módulo, que es lo que hace la siguiente sección.
Cargar el motor fuera de la ruta crítica
Three.js completo pesa del orden de seiscientos kilobytes sin comprimir, y con los addons y un modelo la isla puede acercarse al megabyte. Cargarlo en el arranque de una página cuyo contenido principal es texto es un despilfarro medible en la métrica de contenido visible.
El patrón correcto es un componente envoltorio ligero que solo importa el pesado cuando hace falta.
import { createSignal, onMount, Show, lazy, Suspense } from 'solid-js';
// El modulo con Three.js no entra en el paquete inicial
const EscenaReal = lazy(() => import('./Escena.jsx'));
export default function VisorDiferido() {
const [activo, setActivo] = createSignal(false);
let hueco;
onMount(() => {
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return;
setActivo(true);
io.disconnect();
},
{ rootMargin: '200px' }, // empieza a cargar antes de que se vea
);
io.observe(hueco);
});
return (
<div ref={hueco} class="visor-hueco">
<Show
when={activo()}
fallback={<img src="/producto-estatico.avif" alt="Silla Kompas vista de tres cuartos" />}
>
<Suspense fallback={<p>Cargando el visor interactivo…</p>}>
<EscenaReal />
</Suspense>
</Show>
</div>
);
}
El rootMargin de doscientos píxeles es el detalle que hace que la experiencia sea buena: la descarga arranca antes de que el usuario llegue, así que cuando el hueco entra en pantalla la escena ya está lista o casi. Sin margen, el usuario ve el sustituto y luego un salto.
Y fíjate en el fallback: una imagen estática con texto alternativo real. No es un placeholder gris, es el contenido que ve quien no llega a activar la isla, quien tiene el JavaScript bloqueado, o quien navega con un lector de pantalla. Eso enlaza directamente con la accesibilidad del 3D.
El fallo de integración con Astro que más tiempo consume no es de código sino de configuración: el paquete three incluye ficheros en varios formatos y algunos empaquetadores resuelven la versión no modular. El síntoma es un error de importación de three/addons/... que solo ocurre en la construcción de producción y no en desarrollo, porque el servidor de desarrollo sirve los módulos sin empaquetar. Cuando lo veas, comprueba que estás importando desde three/addons/ y no desde three/examples/jsm/ (la primera es el alias oficial y la que los empaquetadores resuelven bien) y que no hay dos copias de three en el árbol de dependencias, cosa que ocurre en cuanto instalas una librería que lo declara como dependencia normal en lugar de como dependencia de pares. Dos copias de Three.js en la misma página producen fallos imposibles de leer, porque objeto.isMesh es true pero objeto instanceof THREE.Mesh es false.
Qué gana cada framework y qué no cambia
Solid gana en que la comunicación con el bucle es directa y barata. React gana en ecosistema, y su modelo declarativo tiene una respuesta específica para 3D que se trata en el modelo declarativo. Astro gana en que el coste del 3D queda contenido en una isla y no contamina el resto de la página.
Lo que no cambia en ninguno de los tres es la parte que de verdad importa: la escena sigue siendo una unidad con montaje y destrucción, sigue necesitando el dispose completo, y sigue teniendo que pausar cuando no se ve. El framework aporta el momento del montaje y el momento del desmontaje. Todo lo demás es tuyo, y es idéntico en los tres.
Esa es la razón para escribir la escena como una factoría independiente en lugar de dentro de un componente: el día que cambies de framework, el trabajo es reescribir veinte líneas de envoltorio, no la escena. Y mientras tanto, la escena se puede probar sin montar ningún framework.
Coge la factoría de la primera lección del nivel y móntala tres veces: en un componente de Solid, en una isla de Astro con client:only, y en un fichero HTML plano con un <script type="module">. La factoría no debe cambiar ni una línea entre los tres. Si tienes que tocarla, es que se te ha colado una dependencia del framework dentro de la escena, y ese es exactamente el acoplamiento que este patrón evita.