Lit Element: componentes web modernos con TypeScript

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/react o 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: String asigna el valor directamente.
  • type: Number convierte la cadena a número.
  • type: Boolean convierte la presencia del atributo en true o false.

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.
  • classMap y styleMap: 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