Lit Element: componentes web modernos con TypeScript
Los componentes web son una forma de crear elementos reutilizables usando el propio navegador. Lit Element se apoya en esa tecnología para ofrecer una capa más cómoda: propiedades reactivas, plantillas declarativas y estilos encapsulados, todo con TypeScript y sin frameworks pesados.
Web Components
Las Web Components son un conjunto de estándares del navegador:
- Custom Elements: permite definir etiquetas propias como
<mi-componente>. - Shadow DOM: aísla los estilos y el marcado interno del componente.
- HTML Templates: permite declarar fragmentos de marcado reutilizables.
- ES Modules: permite importar y exportar código entre archivos.
Un componente web nativo se define registrando una clase que extiende de HTMLElement:
class MiComponente extends HTMLElement {
connectedCallback() {
this.textContent = "Hola desde un custom element";
}
}
customElements.define("mi-componente", MiComponente);
Escribir así funciona, pero cuando el componente crece el código se vuelve repetitivo. Lit Element evita esa parte manual.
¿Qué es Lit Element?
Lit es una librería construida sobre las Web Components. Con ella se declaran componentes como clases que extienden de LitElement.
Sus ideas principales son:
- Propiedades reactivas: cuando una propiedad cambia, la plantilla se actualiza automáticamente.
- Plantillas declarativas: el marcado se escribe con template literals y se actualiza de forma eficiente.
- Estilos encapsulados: cada componente gestiona su propio CSS.
- Reutilización: un componente Lit es un custom element y funciona en cualquier proyecto HTML o framework.
Entorno de trabajo
Antes de escribir código conviene tener las herramientas listas:
- Node JS: ejecuta JavaScript fuera del navegador y gestiona las dependencias.
- NPM: instala paquetes como
lit,@lit/reacto herramientas de construcción. - Un IDE: Visual Studio Code es una opción común por su soporte de TypeScript.
Un proyecto nuevo puede iniciarse con las plantillas que ofrece la propia comunidad de Lit:
npm create @lit/lit my-component
cd my-component
npm install
npm run dev
El comando dev levanta un entorno de desarrollo con recarga y sirve el componente.
Estructura de ficheros
Una base típica tiene archivos como:
my-component/
├── src/
│ ├── my-element.ts
│ └── my-element.css
├── package.json
├── tsconfig.json
└── index.html
src/my-element.ts contiene la definición del componente. package.json describe el proyecto y sus dependencias. tsconfig.json configura el compilador de TypeScript y index.html es la página donde se prueba el componente.
Mi primer componente
Un componente Lit mínimo se ve así (src/saludo-element.ts):
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("saludo-element")
export class SaludoElement extends LitElement {
static styles = css`
p {
color: var(--color-primario, #2563eb);
font-family: monospace;
}
`;
@property({ type: String })
nombre = "Mundo";
render() {
return html`<p>Hola, ${this.nombre}</p>`;
}
}
Después se usa como cualquier etiqueta:
<saludo-element nombre="Arturo"></saludo-element>
El decorador @customElement registra la clase con un nombre de etiqueta. La propiedad estática styles define el CSS encapsulado. @property convierte la propiedad en reactiva: al cambiar nombre, Lit vuelve a ejecutar render() y actualiza solo la parte que cambió.
Propiedades
Las propiedades se declaran con @property. Reciben un atributo HTML y lo convierten a un tipo de dato. Además, cualquier cambio dispara una actualización de la plantilla.
@property({ type: Number })
contador = 0;
Plantilla
La plantilla se define en render() usando el template literal html. Puede contener interpolaciones con variables, condicionales y bucles.
Estilos
El CSS se define con css dentro de static styles. Gracias al Shadow DOM, no filtra al resto de la página.
Tipos y conversiones de propiedades
Los atributos HTML son cadenas. @property indica cómo convertirlos. Algunos ejemplos:
@property({ type: String }) titulo = "";
@property({ type: Number }) cantidad = 0;
@property({ type: Boolean }) activo = false;
type: Stringasigna el valor directamente.type: Numberconvierte la cadena a número.type: Booleanconvierte la presencia del atributo entrueofalse.
Además, reflect controla si el cambio se devuelve también como atributo del DOM:
@property({ type: Number, reflect: true })
cantidad = 0;
Uso de propiedades en la plantilla
Una vez declaradas, las propiedades se interpolan directamente:
render() {
return html`
<p>Producto: ${this.titulo}</p>
<p>Cantidad: ${this.cantidad}</p>
`;
}
lit-html
html es la función que crea plantillas. Cada render compara la plantilla nueva con la anterior, algo parecido a este flujo:
render()
↓
lit-html
↓
plantilla nueva
↓
comparación con la anterior
↓
actualización mínima del DOM
Condicionales
Se usan expresiones de JavaScript dentro de la plantilla:
render() {
return html`
${this.cargando
? html`<p>Cargando...</p>`
: html`<p>Contenido listo</p>`}
`;
}
Bucles
Los arreglos pueden recorrerse con map:
const items = ["Lit", "React", "Vue"];
render() {
return html`
<ul>
${items.map((item) => html`<li>${item}</li>`)}
</ul>
`;
}
Binding
Los bindings permiten conectar atributos, propiedades del DOM, atributos booleanos y eventos:
value="${this.nombre}": interpola texto o atributo..value="${this.valor}": asigna la propiedad del elemento.?disabled="${this.deshabilitado}": agrega o quita un atributo booleano.@click="${this.ejecutar}": enlaza un evento.
render() {
return html`
<input
.value=${this.nombre}
@input=${this.capturar}
/>
<button ?disabled=${this.deshabilitado} @click=${this.enviar}>
Guardar
</button>
`;
}
Slots
Los slots permiten inyectar contenido desde afuera y forman parte de la composición:
import { LitElement, html } from "lit";
export class Tarjeta extends LitElement {
render() {
return html`
<div class="tarjeta">
<header><slot name="cabecera"></slot></header>
<div><slot></slot></div>
</div>
`;
}
}
<tarjeta>
<span slot="cabecera">Título</span>
<p>Contenido por defecto</p>
</tarjeta>
Directivas HTML
Lit incluye directivas que amplían las plantillas. Las más usadas:
repeat: renderiza listas con claves estables.classMapystyleMap: aplican clases y estilos condicionalmente.ifDefined: agrega un atributo solo si la propiedad está definida.
import { classMap } from "lit/directives/class-map.js";
render() {
return html`
<div class=${classMap({ activo: this.activo, oculto: !this.visible })}>
Estado
</div>
`;
}
Estilos
Propiedad styles
static styles recibe plantillas de CSS. Pueden declararse varios bloques:
static styles = [
css`p { margin: 0; }`,
css`h2 { font-size: 1.25rem; }`,
];
Reutilización de estilos
css permite extraer estilos comunes a un archivo e importarlos:
import { baseStyles } from "./styles.js";
static styles = [baseStyles];
Shadow DOM
Por defecto Lit usa Shadow DOM encajado. Eso significa que los estilos del componente no se escapan y los estilos globales normalmente no entran. Esta encapsulación es una de las ventajas de las Web Components.
Variables CSS
Las variables CSS atraviesan el límite del Shadow DOM. Permiten que la página configure la apariencia del componente desde afuera:
import { LitElement, html, css } from "lit";
export class Boton extends LitElement {
static styles = css`
button {
background: var(--boton-fondo, #2563eb);
color: var(--boton-texto, #ffffff);
}
`;
render() {
return html`<button><slot></slot></button>`;
}
}
boton {
--boton-fondo: #16a34a;
}
Eventos
EventListeners
Los eventos se enlazan directamente en la plantilla con @:
@click=${this.manejarClick}
También pueden registrarse manualmente sobre el DOM interno:
firstUpdated() {
this.renderRoot.querySelector("input")?.addEventListener("input", this.capturar);
}
DispatchEvent
Un componente puede emitir eventos hacia afuera con dispatchEvent:
enviarMensaje() {
this.dispatchEvent(
new CustomEvent("mensaje-enviado", {
detail: { texto: this.campoTexto },
bubbles: true,
composed: true,
})
);
}
Custom Event
Los CustomEvent transportan datos en detail:
render() {
return html`
<input .value=${this.texto} @input=${this.actualizar} />
<button @click=${this.enviar}>Enviar</button>
`;
}
actualizar(evento: Event) {
const input = evento.target as HTMLInputElement;
this.texto = input.value;
}
enviar() {
this.dispatchEvent(
new CustomEvent("enviar-texto", {
detail: { texto: this.texto },
bubbles: true,
composed: true,
})
);
}
En el componente padre:
render() {
return html`
<enviar-texto @enviar-texto=${this.recibir}></enviar-texto>
`;
}
Ciclos de vida
Lit define callbacks que permiten reaccionar al ciclo de vida del componente.
Web Component
Los elementos nativos tienen estos momentos:
constructor: se crea el componente.connectedCallback: el componente entra al documento.disconnectedCallback: el componente se elimina del documento.attributeChangedCallback: cambia un atributo observado.
Lit Element
Lit agrega hooks vinculados a su sistema reactivo:
render(): construye la plantilla.firstUpdated(): ocurre después del primer render, útil para interactuar con el DOM interno.updated(): ocurre después de cada actualización.
import { LitElement, html } from "lit";
export class Temporizador extends LitElement {
firstUpdated() {
console.log("Primera actualización del DOM");
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("scroll", this.registrarScroll);
}
disconnectedCallback() {
window.removeEventListener("scroll", this.registrarScroll);
}
}
Composición de componentes
Llamada entre componentes
La comunicación típica usa propiedades hacia abajo y eventos hacia arriba:
Padre
|
|-- propiedad
v
Hijo
|
|-- evento
v
Padre
Mixins
Un mixin es una función que extiende una clase con comportamiento reutilizable:
function PuedeLeer(superClase: typeof LitElement) {
return class extends superClase {
leerTexto() {
return this.textContent ?? "";
}
};
}
Controllers
Los controllers (ReactiveController) encapsulan lógica relacionada con el ciclo de vida de un componente:
import type { ReactiveController, ReactiveControllerHost } from "lit";
export class RelojController implements ReactiveController {
private host: ReactiveControllerHost;
private timer?: number;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected() {
this.timer = window.setInterval(() => this.host.requestUpdate(), 1000);
}
hostDisconnected() {
window.clearInterval(this.timer);
}
}
Publicación
Publicación de un componente
Para que otros puedan consumir el componente se empaqueta y se publica en un registro como NPM:
npm run build
npm publish
Uso de componentes
Un componente publicado se instala y se importa en la aplicación:
npm install mi-componente
import { SaludoElement } from "mi-componente";
A partir de ahí se usa como etiqueta normal:
<saludo-element nombre="Equipo"></saludo-element>
RollUp
Rollup es una de las herramientas de empaquetado frecuentes en proyectos Lit. Agrupa los módulos en archivos optimizados para el navegador:
src (módulos ES)
↓
Rollup
↓
bundle.js
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
export default {
input: "src/index.ts",
output: { dir: "dist", format: "es", sourcemap: true },
plugins: [typescript(), nodeResolve()],
};
Pruebas
Pruebas de unidad
Verifican el comportamiento de un solo componente de forma aislada.
Una prueba sencilla puede montar el componente y comprobar su contenido:
import { expect, test } from "@web/test-runner";
test("muestra el saludo por defecto", async () => {
const componente = document.createElement("saludo-element");
document.body.appendChild(componente);
await componente.updateComplete;
expect(componente.shadowRoot.textContent).to.include("Hola, Mundo");
});
Pruebas de integración
Comprueban la interacción entre varios componentes o entre un componente y sus eventos.
Pruebas de aceptación
Validan el comportamiento desde el punto de vista del usuario, como si se tratara de un navegador real. Cubren flujos completos: cargar la página, interactuar con el componente y verificar el resultado.
Conclusión
Lit Element no reemplaza las Web Components: las facilita. Sobre una base estándar del navegador agrega reactividad, plantillas declarativas, estilos encapsulados y soporte de TypeScript. Propiedades, plantillas, eventos y ciclo de vida forman el núcleo del desarrollo; la composición, la publicación y las pruebas permiten llevar esos componentes a proyectos reales.
© Arturo Juárez Monroy