Saltar al contenido

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: 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.

JSX
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.

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.

ASTRO
---// src/pages/producto.astroimport 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.

JSX
import { createSignal, onMount, Show, lazy, Suspense } from 'solid-js';// El modulo con Three.js no entra en el paquete inicialconst 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.

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.