Web Components e Micro-Frontends
Ponto chave: Web Components são a resposta do browser para componentes reutilizáveis. Enquanto React, Vue e Angular vêm e vão, Custom Elements e Shadow DOM são padrões W3C nativos. Dominar esses padrões te permite construir design systems que sobrevivem a migrações de framework.
1. Web Components: Visão Geral
Web Components é um termo guarda-chuva para três specs que criam componentes encapsulados:
┌─────────────────┬──────────────────┬────────────────────┐
│ Custom Elements │ Shadow DOM │ HTML Templates │
│ Define novos │ Encapsula DOM │ <template> e <slot>│
│ elementos HTML │ e CSS em árvore │ markup inerte e │
│ com lifecycle │ isolada │ pontos de composição│
└─────────────────┴──────────────────┴────────────────────┘
Browser support: baseline em todos os browsers modernos desde 2020+. Sem necessidade de polyfill.
Quando usar: design systems cross-framework, widgets embeddable (chat, payment), componentes para CMS, micro-frontends com tecnologias diferentes, componentes que precisam sobreviver a migrações de framework.
Quando NÃO usar: app 100% React (o framework já resolve), SSR crítico (Declarative Shadow DOM ainda é limitado), time inteiro domina um framework.
2. Custom Elements
2.1 Definindo e Registrando
class MyGreeting extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || 'World';
this.innerHTML = `<p>Hello, ${name}!</p>`;
}
}
// Nome DEVE conter hífen — evita conflito com tags HTML nativas
customElements.define('my-greeting', MyGreeting);
2.2 Lifecycle Callbacks
class MyComponent extends HTMLElement {
constructor() { super(); this._count = 0; } // elemento CRIADO, NÃO acessar DOM
connectedCallback() { this.render(); } // INSERIDO no DOM — setup real aqui
disconnectedCallback() { // REMOVIDO do DOM — cleanup
this._controller?.abort();
}
attributeChangedCallback(name, oldValue, newValue) { // atributo OBSERVADO mudou
if (oldValue !== newValue) this.render();
}
adoptedCallback() {} // movido para novo document (raro, via document.adoptNode)
static get observedAttributes() { return ['name', 'variant']; } // OBRIGATÓRIO
render() { /* ... */ }
}
customElements.define('my-component', MyComponent);
2.3 Exemplo Completo: Custom Counter
class MyCounter extends HTMLElement {
static observedAttributes = ['count'];
constructor() { super(); this.attachShadow({ mode: 'open' }); }
connectedCallback() { this.render(); }
attributeChangedCallback() { this.render(); }
render() {
const count = parseInt(this.getAttribute('count') || '0');
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; }
button { padding: 8px 16px; font-size: 1rem; border: 1px solid #ccc;
border-radius: 4px; cursor: pointer; background: white; }
button:hover { background: #f0f0f0; }
span { min-width: 2ch; text-align: center; font-weight: bold; }
</style>
<button id="dec">-</button>
<span>${count}</span>
<button id="inc">+</button>
`;
this.shadowRoot.getElementById('dec').onclick = () =>
this.setAttribute('count', count - 1);
this.shadowRoot.getElementById('inc').onclick = () =>
this.setAttribute('count', count + 1);
}
}
customElements.define('my-counter', MyCounter);
2.4 Best Practices
1. Naming: prefixo do projeto (ds-button, acme-input) — sempre kebab-case com hífen
2. Attributes vs Properties: attributes são strings no HTML, properties são valores JS.
Reflita attributes↔properties para API consistente.
3. Comunicação: use CustomEvent com bubbles:true e composed:true para subir eventos
4. Não use innerHTML no constructor — o elemento pode não estar no DOM ainda
3. Shadow DOM Deep Dive
3.1 Light DOM vs Shadow DOM
┌────────────── Document (Light DOM) ──────────────┐
│ <h1>Página principal</h1> │
│ <my-card> │
│ ┌──────── Shadow Root ───────────┐ │
│ │ <style> h1 { color: red; } </style> │
│ │ ← NÃO afeta o h1 da página! │ │
│ │ <h1>Título do card</h1> │ │
│ │ <slot></slot> │ │
│ └────────────────────────────────┘ │
│ <p>Conteúdo projetado via slot</p> │
│ </my-card> │
└──────────────────────────────────────────────────┘
3.2 mode: ‘open’ vs ‘closed’
this.attachShadow({ mode: 'open' }); // element.shadowRoot → ShadowRoot
this.attachShadow({ mode: 'closed' }); // element.shadowRoot → null
// Quase sempre use 'open'. 'closed' dá falsa sensação de segurança.
3.3 Encapsulamento e :host
/* Dentro do Shadow DOM — estilos são SCOPED */
p { color: blue; } /* só afeta <p> dentro deste shadow */
:host { display: block; padding: 16px; } /* seleciona o próprio custom element */
:host(.dark) { background: #1a1a1a; } /* aplica quando host tem classe .dark */
:host-context(.theme-dark) { background: #1a1a1a; } /* baseado em ancestral */
3.4 CSS Custom Properties como API de Theming
CSS custom properties atravessam a shadow boundary — a forma correta de theming:
/* Dentro do Shadow DOM — consome variáveis externas */
button {
background: var(--btn-bg, #0066cc);
color: var(--btn-color, white);
padding: var(--btn-padding, 8px 16px);
border-radius: var(--btn-radius, 4px);
}
/* CSS global controla o theme sem quebrar encapsulamento */
themable-button { --btn-bg: #e63946; --btn-radius: 20px; }
.dark-theme themable-button { --btn-bg: #457b9d; }
3.5 ::part(), ::slotted() e Slots
<!-- Shadow DOM interno com part attributes -->
<div class="card">
<div part="header"><slot name="title"></slot></div>
<div part="body"><slot></slot></div> <!-- default slot -->
</div>
/* Consumidor estiliza as partes expostas via ::part() */
fancy-card::part(header) { background: linear-gradient(135deg, #667eea, #764ba2); }
/* ::slotted() estiliza conteúdo projetado via slot */
::slotted(h2) { margin-top: 0; color: #333; }
<!-- Uso com named slots -->
<modal-dialog>
<h2 slot="title">Confirmar ação</h2>
<p>Tem certeza?</p> <!-- default slot -->
<div slot="actions"><button>OK</button></div>
</modal-dialog>
3.6 Event Retargeting
Eventos dentro do Shadow DOM sofrem retargeting ao cruzar a boundary:
document.querySelector('my-component').addEventListener('click', (e) => {
e.target; // <my-component> (retargeted, não o <button> interno)
e.composedPath(); // [button, div, shadow-root, my-component, body, ...]
});
// Para custom events cruzarem a shadow boundary:
this.dispatchEvent(new CustomEvent('item-selected', {
bubbles: true, composed: true, detail: { id: 42 }
}));
4. HTML Templates
<template> define markup que o browser parseia mas não renderiza até ser clonado:
const template = document.createElement('template');
template.innerHTML = `
<style>.notification { padding: 12px; border-radius: 4px; }</style>
<div class="notification"><span class="message"></span></div>
`;
class NotificationBanner extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// cloneNode(true) — muito mais eficiente que innerHTML para múltiplas instâncias
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
this.shadowRoot.querySelector('.message').textContent = this.getAttribute('message');
}
}
customElements.define('notification-banner', NotificationBanner);
Declarative Shadow DOM permite SSR de Web Components:
<my-card>
<template shadowrootmode="open">
<style>:host { display: block; padding: 16px; }</style>
<slot></slot>
</template>
<p>Conteúdo renderizado no servidor.</p>
</my-card>
5. Lit
5.1 Por Que Lit
Vanilla Web Components são verbose. Lit adiciona reactive properties, template diffing eficiente e scoped styles em ~5KB gzipped:
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
interface Todo { id: number; text: string; done: boolean; }
@customElement('todo-list')
export class TodoList extends LitElement {
static styles = css`
:host { display: block; max-width: 400px; font-family: system-ui; }
.input-row { display: flex; gap: 8px; margin-bottom: 16px; }
input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
button { padding: 8px 16px; background: #0066cc; color: white; border: none; border-radius: 4px; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eee; }
.done { text-decoration: line-through; opacity: 0.6; }
`;
@property({ type: String }) title = 'Minhas Tarefas';
@state() private _todos: Todo[] = [];
@state() private _input = '';
render() {
const remaining = this._todos.filter(t => !t.done).length;
return html`
<h2>${this.title}</h2>
<div class="input-row">
<input .value=${this._input}
@input=${(e: InputEvent) => this._input = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this._addTodo()}
placeholder="Nova tarefa..." />
<button @click=${this._addTodo}>Adicionar</button>
</div>
<ul>${this._todos.map(todo => html`
<li>
<input type="checkbox" .checked=${todo.done} @change=${() => this._toggle(todo.id)} />
<span class=${todo.done ? 'done' : ''}>${todo.text}</span>
</li>
`)}</ul>
<p>${remaining} restante${remaining !== 1 ? 's' : ''}</p>
`;
}
private _addTodo() {
if (!this._input.trim()) return;
this._todos = [...this._todos, { id: Date.now(), text: this._input.trim(), done: false }];
this._input = '';
}
private _toggle(id: number) {
this._todos = this._todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
}
firstUpdated() { this.shadowRoot?.querySelector('input')?.focus(); }
updated(changed: Map<string, unknown>) {
if (changed.has('title')) console.log(`Título: ${this.title}`);
}
}
5.2 @lit/react Wrapper
import { createComponent } from '@lit/react';
import React from 'react';
import { TodoList } from './todo-list.js';
export const TodoListReact = createComponent({
tagName: 'todo-list', elementClass: TodoList, react: React,
events: { onTodoAdded: 'todo-added' },
});
6. Interop com React
O Problema
React trata tudo como attribute (string), não property. Objetos viram [object Object], eventos não conectam:
<my-element data={{ id: 1 }} /> // setAttribute('data', '[object Object]') ❌
<my-element onItemSelected={fn} /> // setAttribute('onitemselected', fn) ❌
Soluções
1. @lit/react (recomendada) — wrapper que converte properties e eventos corretamente.
2. ref + manual property setting:
function App() {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
(el as any).data = { id: 1 };
const handler = (e: CustomEvent) => console.log(e.detail);
el.addEventListener('item-selected', handler);
return () => el.removeEventListener('item-selected', handler);
}, []);
return <my-element ref={ref} name="Lucas" />;
}
3. React 19: suporte nativo melhorado — diferencia properties de attributes automaticamente e conecta event handlers via on*.
7. Micro-Frontends
7.1 Motivação e Quando Usar
Micro-frontends aplicam microservices ao frontend — UI composta por pedaços independentes:
┌───────────────── Shell / Host App ──────────────────┐
│ (routing, layout, autenticação, navigation) │
├────────────┬────────────────┬────────────────────────┤
│ MFE: Catálogo │ MFE: Carrinho │ MFE: Checkout │
│ Time: Discover│ Time: Commerce│ Time: Payments │
│ Tech: React │ Tech: Vue │ Tech: React │
└────────────┴────────────────┴────────────────────────┘
Vale a pena: 50+ devs frontend, times com domínios distintos, deploy independente real, migração gradual de framework. Não vale: time pequeno (menos de 15 devs), UI fortemente acoplada, “autonomia tecnológica” como desculpa para falta de padrão.
7.2 Trade-offs
Problema │ Impacto
───────────────────────┼───────────────────────────────
Bundle duplication │ 3 MFEs com React = 3 cópias (300KB+)
UX inconsistente │ Sem design system rígido, cada MFE diverge
Routing complexity │ Quem controla a URL? Deep linking entre MFEs?
Shared state │ Carrinho↔catálogo, auth global
Performance │ Múltiplos runtimes = mais JS
Dev experience │ Rodar 5 MFEs localmente é doloroso
7.3 Module Federation
Webpack 5 / Rspack Module Federation permite apps carregarem módulos de outros apps em runtime.
Remote (expõe módulos):
// webpack.config.js — app-catalog
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
Host (consome módulos):
// webpack.config.js — shell
new ModuleFederationPlugin({
name: 'shell',
remotes: {
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
cart: 'cart@https://cart.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
Uso — import resolvido em runtime:
const ProductList = React.lazy(() => import('catalog/ProductList'));
function CatalogPage() {
return (
<Suspense fallback={<Skeleton />}>
<ProductList category="electronics" />
</Suspense>
);
}
Fluxo interno: host carrega → rota acessada → fetch do remoteEntry.js → container verifica shared deps (React já carregado? reutiliza como singleton) → módulo carregado e renderizado.
7.4 Composition Patterns
Pattern │ Quando │ Trade-offs
───────────────────┼───────────────────────────┼─────────────────────
Client-side │ SPAs, interatividade │ Mais JS, loading states
(Module Federation)│ │
Build-time │ Monorepo, controle total │ Deploy não independente
(npm, turborepo) │ │
Server-side │ SEO, performance │ Infra complexa
(SSI, ESI, SSR) │ │
Edge-side │ CDN, fragments cacheáveis │ Lógica limitada
(Cloudflare Workers)│ │
7.5 single-spa
Orquestrador de lifecycle de micro-frontends baseado em rotas:
import { registerApplication, start } from 'single-spa';
registerApplication({
name: 'catalog',
app: () => System.import('@myorg/catalog'),
activeWhen: '/catalog',
});
registerApplication({
name: 'cart',
app: () => System.import('@myorg/cart'),
activeWhen: '/cart',
});
start();
Cada MFE exporta lifecycle hooks:
import singleSpaReact from 'single-spa-react';
const lifecycle = singleSpaReact({ React, ReactDOM, rootComponent: CatalogApp });
export const { bootstrap, mount, unmount } = lifecycle;
7.6 Shared State entre MFEs
Custom Events (Event Bus):
// MFE Catalog dispara
window.dispatchEvent(new CustomEvent('cart:add-item', {
detail: { productId: 'abc-123', quantity: 1 },
}));
// MFE Cart escuta
window.addEventListener('cart:add-item', (e) => addToCart(e.detail));
Shared Store: pacote npm com singleton exportado, pattern pub/sub (subscribe/notify). Type-safe, mas acopla MFEs ao contrato do store.
URL State: query params como canal de comunicação — funciona naturalmente com routing. Simples, mas limitado a dados serializáveis na URL.
Broadcast Channel API: comunicação entre tabs/iframes do mesmo origin:
const channel = new BroadcastChannel('app-events');
channel.postMessage({ type: 'USER_LOGGED_IN', payload: { userId: '123' } });
channel.addEventListener('message', (e) => { /* ... */ });
8. Design Systems com Web Components
8.1 Theming com Design Tokens
:root {
--ds-color-primary: #0066cc;
--ds-color-danger: #dc3545;
--ds-color-surface: #ffffff;
--ds-color-text: #212529;
--ds-spacing-sm: 8px;
--ds-spacing-md: 16px;
--ds-radius-sm: 4px;
--ds-font-family: 'Inter', system-ui, sans-serif;
--ds-shadow-md: 0 4px 6px rgba(0,0,0,0.07);
}
[data-theme="dark"] {
--ds-color-primary: #4da3ff;
--ds-color-surface: #1a1a2e;
--ds-color-text: #e0e0e0;
}
@customElement('ds-button')
export class DsButton extends LitElement {
static styles = css`
button {
font-family: var(--ds-font-family);
padding: var(--ds-spacing-sm) var(--ds-spacing-md);
border-radius: var(--ds-radius-sm);
border: none; cursor: pointer;
}
:host([variant="primary"]) button { background: var(--ds-color-primary); color: white; }
:host([variant="danger"]) button { background: var(--ds-color-danger); color: white; }
:host([disabled]) button { opacity: 0.5; cursor: not-allowed; }
`;
@property({ reflect: true }) variant = 'primary';
@property({ type: Boolean, reflect: true }) disabled = false;
render() {
return html`<button ?disabled=${this.disabled} part="button"><slot></slot></button>`;
}
}
8.2 Exemplos do Ecossistema
Shoelace (Web Awesome): 50+ componentes production-ready, Lit, theming via custom properties. Spectrum Web Components (Adobe): acessibilidade WCAG 2.1 AA. Carbon Web Components (IBM): design system Carbon como Web Components.
8.3 Storybook
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './ds-button.js';
const meta: Meta = {
title: 'Components/Button',
component: 'ds-button',
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'danger'] },
disabled: { control: 'boolean' },
},
};
export default meta;
export const Primary: StoryObj = {
render: (args) => html`
<ds-button variant=${args.variant} ?disabled=${args.disabled}>Click me</ds-button>
`,
};
9. Exercícios
Exercício 1: Tooltip Web Component
Crie <ds-tooltip> vanilla (sem Lit): atributos text e position (top/bottom/left/right), Shadow DOM, mostra no hover e focus, CSS custom properties para theming, role="tooltip" e aria-describedby.
Exercício 2: Design System Tokens
Crie tokens.css com variáveis completas. Construa <ds-theme-provider> que aceita theme="light"|"dark". Crie <ds-button>, <ds-input>, <ds-card> consumindo tokens. Teste troca de tema em runtime.
Exercício 3: Lit + React Integration
Crie <user-profile-card> com Lit aceitando user: { name, avatar, bio } como property. Use @lit/react para wrapper. Demonstre passagem de properties e consumo de CustomEvent no React.
Exercício 4: Module Federation POC
Host app (shell) + 2 remotes. Configure shared deps como singleton. Implemente fallback UI quando remote falha. Teste: deploy remote com mudança, host reflete sem redeploy.
Exercício 5: Micro-Frontend Communication
Implemente 3 patterns: Custom Events, Shared Store, URL State. Compare qual é melhor para auth global, carrinho de compras e filtros de busca.
10. Referências
- MDN Web Components — documentação completa das APIs nativas
- Lit — framework para Web Components do Google
- Custom Elements Everywhere — compatibilidade de frameworks com Custom Elements
- Module Federation — documentação oficial
- single-spa — orquestração de micro-frontends
- Micro Frontends in Action — Michael Geers (Manning)
- Web Awesome (Shoelace) — design system open-source com Web Components
- Spectrum Web Components — Adobe design system