Las dos lecciones anteriores han resuelto la integración de un componente Lit con el mundo que lo rodea una vez que ya está en el navegador del usuario: HTML plano, o dentro de otro framework de aplicación. Esta lección plantea un problema de naturaleza distinta, que ocurre antes de que el navegador tenga siquiera oportunidad de ejecutar una sola línea de JavaScript: ¿qué recibe un usuario, o un motor de búsqueda, en el primer instante en que la respuesta HTML de una página llega desde el servidor, antes de que se cargue y ejecute el bundle de Lit? Por defecto, la respuesta es "muy poco": una etiqueta <task-card> vacía, sin contenido dentro, hasta que el JavaScript se descargue, se ejecute y el componente decida renderizarse por su cuenta. El renderizado en el servidor (Server-Side Rendering, SSR) existe para cambiar esa respuesta.
Contenido
- El problema: una página vacía hasta que carga el JavaScript
- Qué es la hidratación y por qué importa
- Por qué SSR interesa especialmente a los Web Components
- Panorama de
@lit-labs/ssry su carácter experimental - La función
renderde@lit-labs/ssr - Declarative Shadow DOM: el mecanismo estándar que lo hace posible
- Ejemplo conceptual:
<task-card>renderizada en el servidor - Limitaciones actuales a tener en cuenta
- El problema: una página vacía hasta que carga el JavaScript
Todo el desarrollo de TaskFlow durante el curso ha asumido, sin decirlo explícitamente, que el navegador del usuario descarga el HTML de la página, después descarga y ejecuta el JavaScript que define los componentes (customElements.define('task-card', TaskCard), y el resto), y solo entonces cada etiqueta <task-card> presente en el marcado "cobra vida" y se renderiza con su contenido real. Entre el primer instante (HTML recibido) y el segundo (JavaScript ejecutado y componentes definidos), hay una ventana de tiempo —a veces milisegundos, a veces varios segundos en una conexión lenta o un dispositivo modesto— en la que la página existe, pero sus componentes Lit están vacíos: el navegador conoce la etiqueta <task-card> como un elemento sin comportamiento especial (lo que la especificación de Custom Elements llama un elemento "no mejorado", unupgraded), sin ningún contenido visible dentro de su Shadow DOM, porque ese Shadow DOM ni siquiera existe todavía.
Para una aplicación pensada para usarse después de una interacción explícita del usuario (tras iniciar sesión, por ejemplo), esa ventana suele ser aceptable. Para contenido que debe estar visible lo antes posible —una página de inicio, un listado de tareas que un usuario quiere ver en cuanto abre el enlace, o cualquier página que un motor de búsqueda deba poder indexar sin ejecutar JavaScript—, esa ventana es exactamente el problema que SSR resuelve: generar, en el servidor, el HTML ya relleno con el contenido real de cada componente, de forma que el navegador reciba desde el primer instante una página con contenido visible, sin depender de que el JavaScript se haya ejecutado todavía.
- Qué es la hidratación y por qué importa
Renderizar en el servidor resuelve solo la mitad del problema: el HTML que llega al navegador ya tiene el contenido visible, pero todavía no es interactivo. Un botón dentro de una <task-card> renderizada en el servidor se ve en pantalla, pero no responde a un clic hasta que ocurre un segundo paso, llamado hidratación: el proceso por el cual el JavaScript del componente, una vez descargado y ejecutado en el navegador, "toma posesión" del HTML ya existente —en lugar de destruirlo y regenerarlo desde cero— y lo conecta con la lógica reactiva real del componente, incluidos sus escuchadores de eventos y su capacidad de volver a renderizarse ante cambios futuros de estado.
La hidratación es, en cierto sentido, lo contrario de crear un componente desde cero: en un renderizado normal (sin SSR), Lit parte de una etiqueta vacía y construye todo su contenido interno la primera vez que se ejecuta render(); en una hidratación, Lit encuentra contenido ya presente en el DOM (generado por el servidor) y necesita reconciliarse con él, reconociendo qué partes de ese HTML corresponden a qué expresiones dinámicas de la plantilla, sin volver a construir nada que ya esté correctamente presente. Esta distinción importa porque explica por qué SSR no es simplemente "ejecutar los mismos componentes en Node.js en lugar de en el navegador": el navegador, después, tiene que hacer un trabajo adicional y distinto (hidratar) del que hace cuando no hay SSR de por medio (renderizar desde cero).
- Por qué SSR interesa especialmente a los Web Components
SSR no es una idea exclusiva de Lit; es una técnica extendida en el ecosistema de frameworks de aplicación (Next.js para React, Nuxt para Vue, Angular Universal para Angular), motivada históricamente por dos razones que se aplican igual de bien a cualquier componente, incluidos los de Lit: el SEO (los motores de búsqueda indexan mejor una página que ya contiene el contenido real en el HTML, en lugar de depender de ejecutar JavaScript para descubrirlo, aunque los rastreadores modernos han mejorado bastante en ese aspecto) y el rendimiento percibido (el usuario ve contenido significativo antes, en lugar de una pantalla vacía o un indicador de carga durante la ventana descrita en el apartado 1).
Los Web Components añaden, sin embargo, una dificultad propia que los frameworks de aplicación tradicionales no tienen de la misma forma: el Shadow DOM. Un componente de React o Vue, sin Web Components de por medio, renderiza directamente en el DOM normal de la página, sin ninguna frontera de encapsulación especial; el HTML generado en el servidor para ese componente es, estructuralmente, el mismo tipo de HTML que cualquier otro elemento de la página. Un componente Lit, en cambio, encapsula su contenido dentro de un Shadow DOM (como se estudió en la lección 04-01), y el HTML servido desde el servidor tradicional no tiene, por definición, forma de representar "aquí hay una raíz de Shadow DOM con este contenido dentro" usando solo las etiquetas HTML de siempre. Sin una solución a ese problema concreto, SSR para Web Components sería, en la práctica, imposible de implementar de forma fiel al comportamiento real del componente en el navegador.
- Panorama de
@lit-labs/ssr y su carácter experimental
@lit-labs/ssr y su carácter experimental@lit-labs/ssr es el paquete que el propio equipo de Lit mantiene para resolver este problema: ejecutar componentes Lit en un entorno de servidor (normalmente Node.js) y producir HTML que incluya, de forma fiel, el contenido real de cada componente, incluido su Shadow DOM. El prefijo labs en el nombre del paquete no es casual ni decorativo: Lit reserva ese espacio de nombres para paquetes que el propio equipo considera todavía en fase experimental, con una API que puede cambiar entre versiones menores con más libertad que el núcleo estable de lit usado durante el resto del curso. Esto no significa que @lit-labs/ssr sea inutilizable en producción —de hecho, existen proyectos reales que lo usan—, pero sí que conviene abordarlo con la expectativa de revisar el registro de cambios (changelog) con más atención de la habitual antes de actualizar de versión, y de aceptar que algunas piezas del ecosistema alrededor (como la integración con determinados frameworks de servidor) pueden estar menos maduras que el núcleo de Lit.
El paquete se instala de forma independiente, igual que @lit/context en el módulo anterior:
Y está pensado para ejecutarse en el propio servidor de la aplicación —dentro de un manejador de rutas de Express, de una función de servidor de un framework más amplio (como Next.js, con adaptaciones), o de cualquier entorno Node.js capaz de importar módulos ES— no en el navegador del usuario final.
- La función
render de @lit-labs/ssr
render de @lit-labs/ssrLa pieza central de @lit-labs/ssr es su propia función render, distinta de la plantilla html habitual de Lit pero pensada para consumir exactamente el mismo tipo de valor que render() de un componente devolvería:
// servidor.js (fragmento conceptual, entorno Node.js)
import { render } from '@lit-labs/ssr';
import { html } from 'lit';
import './src/components/task-card.js';
async function generarHtmlDeTarjeta(tarea) {
const resultado = render(html`
<task-card
titulo="${tarea.titulo}"
estado="${tarea.estado}"
prioridad="${tarea.prioridad}"
></task-card>
`);
let html_generado = '';
for (const fragmento of resultado) {
html_generado += fragmento;
}
return html_generado;
}render(...) recibe una plantilla html corriente —la misma sintaxis usada durante todo el curso— y devuelve un iterable (concretamente, un generador) de fragmentos de cadena, no una única cadena completa de golpe; esto permite, en un servidor real, ir enviando la respuesta al cliente por partes a medida que se generan (streaming), en lugar de esperar a tener el HTML completo en memoria antes de responder, algo especialmente valioso para páginas con muchos componentes o con datos que tardan en resolverse. El ejemplo del fragmento anterior concatena todos los fragmentos en una única cadena, por simplicidad, pero un servidor real orientado a streaming escribiría cada fragmento directamente en la respuesta HTTP en cuanto estuviera disponible.
Para que esto funcione, <task-card> debe estar registrada exactamente igual que en el navegador —el mismo customElements.define('task-card', TaskCard)—, con la particularidad de que @lit-labs/ssr proporciona sus propias implementaciones de las APIs del DOM que Lit necesita (HTMLElement, customElements, y el resto), ya que Node.js no las tiene de forma nativa; el paquete se encarga de simular ese entorno lo suficiente como para que la misma clase de componente, sin ninguna modificación especial pensada para el servidor, pueda ejecutar su ciclo de renderizado normal y producir un resultado correcto.
- Declarative Shadow DOM: el mecanismo estándar que lo hace posible
La pieza que resuelve el problema señalado en el apartado 3 —cómo representar un Shadow DOM dentro de HTML plano, sin ejecutar JavaScript— es Declarative Shadow DOM (DSD), una extensión relativamente reciente del propio estándar de Shadow DOM, no una invención propia de Lit ni de @lit-labs/ssr. DSD permite declarar, directamente en HTML, una plantilla especial marcada con el atributo shadowrootmode, que el navegador reconoce e "adjunta" automáticamente como el Shadow DOM real del elemento que la contiene, sin necesitar ninguna llamada a attachShadow(...) desde JavaScript:
<task-card>
<template shadowrootmode="open">
<style>/* estilos encapsulados del componente */</style>
<article>
<h3>Revisar propuesta de cliente</h3>
<p>Estado: progreso · Prioridad: 3</p>
</article>
</template>
</task-card>Cuando el navegador procesa este marcado, reconoce el <template shadowrootmode="open"> y lo convierte, de forma nativa y sin ejecutar ni una línea de JavaScript todavía, en el Shadow DOM real de <task-card>; el contenido dentro del <template> deja de ser un simple <template> inerte (como los <template> normales del HTML, que nunca se muestran por sí mismos) y pasa a ser el árbol de Shadow DOM efectivo del elemento, visible en pantalla desde el primer momento en que el navegador procesa el HTML, exactamente igual que si attachShadow(...) se hubiera llamado desde JavaScript inmediatamente después de crear el elemento. Este es, precisamente, el HTML que @lit-labs/ssr produce cuando renderiza un componente con Shadow DOM: no un simple <task-card>...</task-card> con el contenido "aplanado" dentro, sino esta estructura con la plantilla de DSD anidada, fiel a cómo se comportaría el componente en el navegador.
Es este soporte nativo del navegador, no ninguna magia interna de Lit, lo que permite que la hidratación (apartado 2) funcione con fidelidad: cuando el JavaScript de Lit se ejecuta después, encuentra ya un Shadow DOM real adjunto al elemento (gracias a DSD), con el contenido correcto dentro, y solo necesita reconectar la lógica reactiva, en lugar de tener que crear el Shadow DOM desde cero como haría sin SSR.
- Ejemplo conceptual:
<task-card> renderizada en el servidor
<task-card> renderizada en el servidorUniendo las piezas anteriores, así se vería, de forma conceptual, el flujo completo para servir una página inicial de TaskFlow con varias tarjetas ya renderizadas en el servidor:
// servidor.js (conceptual, con un framework de servidor genérico)
import { render } from '@lit-labs/ssr';
import { html } from 'lit';
import './src/components/task-list.js';
import { cargarTareas } from './src/services/tareas-service.js';
app.get('/', async (peticion, respuesta) => {
const tareas = await cargarTareas();
const plantilla = html`
<!DOCTYPE html>
<html lang="es">
<head><title>TaskFlow</title></head>
<body>
<task-list></task-list>
<script type="module" src="/main.js"></script>
</body>
</html>
`;
respuesta.setHeader('Content-Type', 'text/html');
for (const fragmento of render(plantilla)) {
respuesta.write(fragmento);
}
respuesta.end();
});El punto clave de este flujo es que cargarTareas() —la misma función de servicio introducida en la lección 07-03 para simular una carga asíncrona— se resuelve antes de generar el HTML, directamente en el servidor, con await; a diferencia del uso de until visto en aquella lección (pensado para el navegador, donde render() debe permanecer síncrono mientras la promesa se resuelve más adelante), el servidor puede esperar sin ningún problema, porque no hay ninguna restricción de sincronía equivalente a la de render() del lado del cliente: el servidor simplemente no responde a la petición HTTP hasta que la promesa de cargarTareas() resuelve, y solo entonces genera el HTML, ya con las tareas incluidas dentro del Shadow DOM de <task-list> y de cada <task-card> anidada. El <script type="module" src="/main.js"> al final del <body> es el que dispara, ya en el navegador, la hidratación: en cuanto se ejecuta y define <task-list> y <task-card>, Lit reconoce el Shadow DOM ya presente (gracias a DSD) y lo conecta con la lógica reactiva real, sin volver a construirlo desde cero.
- Limitaciones actuales a tener en cuenta
@lit-labs/ssr resuelve el problema central de SSR para Web Components, pero conviene conocer, antes de adoptarlo en un proyecto real, varias limitaciones vigentes en el momento de escribir este curso:
- Soporte de navegador de Declarative Shadow DOM: aunque DSD ya forma parte de la especificación estándar y los navegadores principales lo soportan, un proyecto que necesite dar soporte a navegadores más antiguos debe comprobar la compatibilidad exacta antes de depender de él en producción, o incluir un polyfill para los casos sin soporte nativo.
- Coste de mantener dos entornos de ejecución: un componente que se ejecuta tanto en el servidor (dentro del entorno simulado de
@lit-labs/ssr) como en el navegador debe evitar depender de APIs exclusivas del navegador (comowindow,document.querySelectorsobre el documento global, o temporizadores del navegador) de una forma que rompa en el entorno de servidor; código como elContadorTiempoRestanteControllerde la lección 06-03, que usasetInterval, necesitaría revisión cuidadosa para comportarse razonablemente si se intentara renderizar en el servidor sin adaptación. - Ecosistema de integración con frameworks de servidor todavía en desarrollo: la integración con frameworks de servidor concretos (Express, u otros más específicos de SSR de aplicaciones completas) varía en madurez, y algunos flujos —como el streaming incremental de fragmentos mencionado en el apartado 5— requieren más código de conexión manual que el equivalente ya resuelto en frameworks de aplicación más establecidos, como Next.js para React.
- La hidratación no es automática ni gratuita: aunque
@lit-labs/ssrgenera el HTML inicial correctamente, el propio proceso de hidratación en el navegador (reconciliar el DOM ya existente con la lógica reactiva) sigue siendo, en el momento de escribir este curso, una pieza del ecosistema que exige más atención al detalle que el renderizado desde cero habitual, y no todos los patrones usados libremente durante el curso (por ejemplo, ciertas directivas con estado interno complejo) están garantizados de comportarse de forma idéntica en un componente hidratado frente a uno renderizado normalmente desde el principio.
Ninguna de estas limitaciones invalida el paquete; simplemente señalan que, a diferencia del núcleo estable de Lit usado durante el resto del curso, SSR con @lit-labs/ssr es un terreno donde conviene probar a fondo el caso concreto de la aplicación antes de darlo por sentado en producción.
Errores Comunes y Consejos
- Pensar que SSR "aplana" el Shadow DOM en HTML normal: como se ha explicado en el apartado 6,
@lit-labs/ssrno descarta el Shadow DOM al generar HTML; lo representa fielmente mediante Declarative Shadow DOM, precisamente para que la hidratación posterior encuentre una estructura real, no una aproximación simplificada. - Confundir SSR con hidratación como si fueran lo mismo: como se explicó en el apartado 2, son dos pasos distintos y complementarios; SSR genera el HTML inicial en el servidor, la hidratación conecta ese HTML ya existente con la lógica reactiva en el navegador. Sin el segundo paso, la página tendría contenido visible pero ningún componente respondería a interacciones.
- Usar APIs exclusivas del navegador sin comprobar el entorno: como se ha señalado en el apartado 8, un componente pensado para ejecutarse también en el servidor debe evitar asumir, sin comprobación, que
windowodocumentse comportan exactamente igual que en un navegador real; el entorno simulado de@lit-labs/ssrcubre lo esencial, pero no es un navegador completo. - Adoptar
@lit-labs/ssrpara un proyecto interno sin necesidad real de SEO ni de rendimiento percibido crítico: dado su carácter experimental (apartado 4) y sus limitaciones (apartado 8), conviene reservarlo para los casos donde el problema que resuelve —contenido visible antes de que cargue el JavaScript— tiene un impacto real y medible, no adoptarlo por sistema en cualquier proyecto Lit sin evaluar si el coste adicional está justificado.
Ejercicios
- Explica, con tus propias palabras y basándote en el apartado 3, por qué SSR para un componente de React o Vue sin Web Components no necesita ningún mecanismo equivalente a Declarative Shadow DOM, mientras que un componente Lit sí lo necesita.
- Un compañero de equipo, tras leer sobre
@lit-labs/ssr, propone usarlo para renderizar en el servidor elContadorTiempoRestanteControllerde<task-card>(el temporizador de urgencia de la lección 06-03) exactamente tal como está escrito, esperando que el HTML inicial ya muestre el estado de urgencia correcto. Señala, basándote en el apartado 8, qué problema concreto tendría ese controlador al ejecutarse en el entorno de servidor sin ninguna adaptación. - Retoma el ejemplo del apartado 7 y explica por qué
await cargarTareas()es perfectamente válido dentro del manejador de la ruta del servidor, mientras que la lección 07-03 insistió en querender()de un componente Lit nunca puede ser una funciónasyncni esperar directamente una promesa. ¿Son ambas afirmaciones compatibles, o una contradice a la otra?
Soluciones
- Un componente de React o Vue sin Web Components no crea, en ningún momento, una raíz de Shadow DOM separada; todo su contenido se inserta directamente en el DOM normal de la página, como cualquier otro conjunto de etiquetas HTML. El HTML generado en el servidor para ese componente es, por tanto, estructuralmente indistinguible de cualquier otro fragmento de HTML de la página, y no necesita ningún mecanismo especial para representarlo: basta con las etiquetas normales, ya soportadas por cualquier motor HTML desde siempre. Un componente Lit, en cambio, encapsula su contenido dentro de un Shadow DOM real (lección 04-01), una estructura que HTML tradicional no tenía forma de expresar de manera declarativa hasta la llegada de Declarative Shadow DOM; sin DSD, el servidor solo podría generar el contenido "aplanado", sin ninguna frontera de encapsulación, lo cual no reproduciría fielmente cómo se comporta realmente el componente en el navegador.
ContadorTiempoRestanteControllerusasetInterval(según se describió en la lección 06-03) para recalcular periódicamente si una tarea está cerca de vencer, actualizando su estado en el tiempo real transcurrido en el navegador. En el entorno de servidor de@lit-labs/ssr, la petición HTTP se resuelve y responde en un instante concreto, no permanece "viva" indefinidamente como una pestaña de navegador abierta; iniciar unsetIntervaldurante ese renderizado no tendría ningún sentido práctico (el servidor no va a mantener ese intervalo corriendo eternamente por cada petición procesada, y si lo hiciera, sería una fuga de recursos grave), y el HTML generado solo podría reflejar el estado de urgencia calculado en el instante exacto de la petición, no una actualización continua. El controlador necesitaría, como mínimo, comprobar en qué entorno se ejecuta y evitar programar elsetIntervalsi no hay un navegador real detrás, dejando que sea la hidratación posterior, ya en el cliente, quien active el temporizador real.- Ambas afirmaciones son perfectamente compatibles, porque se refieren a restricciones de contextos distintos. La restricción de la lección 07-03 se aplica al método
render()de un componente Lit ejecutándose en el navegador, donde Lit necesita que ese método devuelva una plantilla de forma síncrona para poder actualizar el DOM de inmediato, sin bloquear el hilo principal del navegador esperando una promesa. El manejador de una ruta de servidor, en cambio, no tiene esa restricción: es habitual y correcto que una función de manejo de peticiones HTTP seaasyncy espere conawaitcualquier operación asíncrona (una consulta a base de datos, una llamada a otro servicio) antes de generar y enviar la respuesta completa; el servidor, a diferencia delrender()de un componente, no necesita devolver algo "de inmediato" en el mismo sentido, porque su trabajo es responder a una única petición HTTP, no mantener una interfaz de usuario reactiva y fluida frente a cambios continuos de estado.
Conclusión
Esta lección ha presentado @lit-labs/ssr como la vía, todavía experimental pero funcional, para generar en el servidor el HTML inicial de componentes Lit con su Shadow DOM fielmente representado gracias a Declarative Shadow DOM, resolviendo el problema de la página vacía hasta que carga el JavaScript, a costa de un segundo paso —la hidratación— y de varias limitaciones vigentes que conviene evaluar caso por caso antes de adoptarlo en producción. Con la integración hacia HTML plano, hacia otros frameworks y hacia el servidor ya cubierta, queda un último frente de integración con el mundo exterior a TaskFlow, de naturaleza más práctica: cómo empaquetar y publicar los propios componentes para que otros proyectos puedan consumirlos, y qué cambia si se opta por escribirlos en TypeScript en lugar del JavaScript usado hasta ahora.
Curso de Lit
Módulo 1: Introducción a Lit y Web Components
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
