Artigos
Padrões de arquitetura para múltiplos layouts e temas
Como estruturar uma plataforma frontend para suportar 100+ temas e vários layouts, sem deixar que a lógica personalizada se infiltre nos componentes partilhados? Neste artigo, explico a divisão em camadas core / view / theme leaf, as configurações tipadas, os testes de integridade da família e o carregamento de CSS dos temas a pedido, bem como as escolhas e compromissos de cada camada.
· Atualizado · 10 min de leitura
Fui responsável pela reestruturação da arquitetura de uma grande plataforma frontend em Nx / Angular. Tinha de suportar mais de 100 temas e vários layouts. Outros membros concluíam frequentemente subtarefas relacionadas com múltiplos layouts e temas à sua maneira, de acordo com os próprios hábitos de desenvolvimento. Com o tempo, isso tornava-se dívida técnica: a mesma necessidade passava a ter várias implementações com lógicas diferentes.
Neste artigo, explico o essencial do design que usei: como dividir em camadas, manter completos mais de cem temas e fazer com que o browser só transfira o CSS de que precisa. Como o código production original não pode ser divulgado, voltei a implementar a arquitetura com código original e três temas fictícios. Publiquei-a em frontend-engineering-case-studies, com uma Demo online que pode experimentar. Todo o código do artigo vem desta versão pública.
Começo pela conclusão: separe «o aspeto» de «o que faz» e deixe o compilador e os testes garantir a consistência de mais de cem temas.
O problema não é mudar as cores#
O ponto de partida mais comum para suportar vários temas é adicionar condições num componente partilhado. Por exemplo, o tema A precisa de mais um botão, enquanto o tema B tem de encaminhar para outra página durante a campanha:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Quando há apenas três temas, é rápido escrever assim. Mas, quando passam a ser cem, cada componente partilhado acaba carregado de ramificações que «só se aplicam a determinado tema». As consequências são:
- Uma alteração para o tema A pode estragar silenciosamente o tema B, sem que ninguém seja responsável por verificar o B.
- A mesma diferença é implementada várias vezes em componentes diferentes, e de maneiras distintas.
- Ao acrescentar um tema, ninguém consegue ter a certeza de que «todos os pontos necessários foram atualizados».
O problema não é apenas mudar as cores: é permitir diferenças de aspeto e de produto entre temas, mantendo toda a família completa e previsível.
Primeiro, distinguir: layout e tema são coisas diferentes#
A analogia com uma casa é a mais fácil de entender:
- Estrutura e canalizações: pilares e instalações de água e eletricidade. São iguais em todas as casas e não devem mudar consoante as preferências dos moradores.
- Layout: a planta. Dois ou três quartos, onde fica a sala. Define «como as coisas estão dispostas».
- Tema: a decoração. Cores das paredes, materiais, tamanho dos cantos arredondados. Define «o aspeto das coisas».
No frontend, estes três elementos correspondem a camadas diferentes. Quando se misturam, acabamos em situações como «alterar o fluxo de dados para mudar uma cor».
Estrutura em quatro camadas#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
De baixo para cima:
core: limita-se a desenhar#
Os componentes core recebem apenas dados «já preparados e prontos a renderizar» e emitem apenas a intenção do utilizador (intent). Não sabem qual é o tema atual, não tratam de routing nem do formato das respostas da API e não referenciam recursos de marca.
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
O core é partilhado por todos os temas e tem o maior raio de impacto, por isso as suas regras são as mais rigorosas: só recebe os dados necessários e só emite intenções.
view: responsável pela coordenação#
A view é o intermediário. Obtém os dados do data service e transforma-os no formato esperado pelo core; quando o core emite uma intenção, encaminha-a para o actions facade.
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
A origem dos dados (data service) e a ação a executar quando se carrega num botão (actions facade) têm limites bem definidos e não ficam dispersos pelos componentes da interface.
Esta camada é adequada para os layouts. Plantas diferentes podem corresponder a shared view diferentes. Todas usam o mesmo data service e facade; apenas organizam os componentes core de maneira diferente. Para simplificar, a Demo pública tem apenas um shared view; a reestruturação production introduziu 26.
theme leaf: um nó folha por tema#
Cada tema é um componente muito simples que faz apenas uma coisa: escolhe uma configuração de aspeto completa e passa-a ao shared view.
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
Não contém lógica de negócio. Em production, após a reestruturação, um theme leaf completo e representativo tinha 23 linhas; a mesma via na implementação antiga tinha várias centenas.
selector: enumera explicitamente os membros da família#
O selector apresenta o leaf correspondente com base no id do tema em runtime:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Parece algo banal; o essencial é ser «explícito»: é fácil ver quais são os temas e a que leaf corresponde cada um, e os testes podem verificá-los um a um.
Deixar o compilador verificar cada tema#
Quanto mais temas há, maior o risco de falhas por omissão. Acrescenta-se um tema e esquece-se a configuração; altera-se o formato da configuração e alguns temas antigos ficam para trás.
A solução é definir a lista de temas numa única fonte e usar o satisfies do TypeScript para exigir que cada tema tenha uma configuração completa:
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
Se houver mais um tema em DEMO_THEMES e este não for acrescentado a THEME_APPEARANCES, a compilação falha. O mesmo acontece se o nome de um campo estiver incorreto ou o formato do caminho CSS não for válido.
A vantagem de satisfies face a uma anotação direta de tipo é que verifica a estrutura, mas preserva o tipo concreto de cada valor. Assim, quando se consulta THEME_APPEARANCES.aurora, o tipo não é alargado para um ThemeAppearance genérico.
Usar testes para garantir a integridade de toda a família#
O compilador consegue validar a configuração, mas não garante que «o tema é realmente renderizado». Essa validação fica a cargo dos testes de integridade da família, que enumeram todos os temas declarados:
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
A configuração também tem um teste correspondente: o campo id de cada configuração de tema tem de coincidir com a chave correspondente na tabela de configurações; o caminho CSS também tem de cumprir as regras da família.
Em conjunto, estes dois tipos de verificação transformam o trabalho de verificar manualmente em code review se «faltou algum tema sibling» num erro que o CI deteta. A escala de mais de cem temas é impossível de rever a olho.
Transferir apenas o CSS do tema em uso#
A divisão em camadas resolve problemas de manutenção, mas os vários temas também têm um custo que afeta diretamente os utilizadores: o CSS.
A abordagem original juntava os 136 ficheiros stylesheet dos temas num único pacote. Independentemente do tema utilizado, cada pessoa tinha de transferir e analisar tudo. Com o carregamento a pedido, os recursos de um único tema passaram de cerca de 2.4 MB para 17 KB, uma redução de cerca de 99%.
O mecanismo em si é simples: cada configuração de tema declara o seu próprio stylesheetHref, e um único loader gere o único <link> de tema na página:
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
A plataforma chama-o quando o tema muda:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
«Único» é a palavra-chave. Se cada sítio inserir o seu próprio <link>, as mudanças repetidas podem deixar estilos do tema anterior ou fazer com que duas folhas CSS se sobreponham. Os testes confirmam que, mesmo após várias mudanças, continua a haver apenas um stylesheet gerido na página.
O CSS do tema define apenas design token (variáveis CSS); os estilos do core leem apenas estas variáveis:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Assim, mudar de tema só troca uma tabela de variáveis e os estilos do core não precisam de ser alterados. O modo claro e escuro usa outro conjunto de variáveis no mesmo ficheiro.
Compromissos#
Este design tem custos:
- Mais ficheiros. Há um leaf por tema e um view por layout, o que aumenta o número de ficheiros pequenos. Em contrapartida, a responsabilidade de cada ficheiro fica clara e a integridade pode ser verificada.
- A configuração não pode transformar-se numa linguagem de programação. O config serve para descrever o aspeto e diferenças bem delimitadas. Se começar a incluir condições e fluxos, é apenas uma forma diferente de esconder os if/else originais.
- O core precisa de testes de contrato mais rigorosos. É partilhado por todos os temas, por isso uma alteração afeta mais de cem.
- A mudança de CSS em runtime precisa de um owner único. Pedidos de stylesheet sem coordenação podem provocar cintilação ou deixar estilos residuais; por isso, o loader tem de ser único.
- A cache continua a ser importante. Mesmo carregando apenas um tema, nomes de ficheiro estáveis e um cache header continuam a determinar a velocidade nas visitas seguintes.
Quando vale a pena adotar esta abordagem#
Se o teu produto só tiver os modos claro e escuro, as variáveis CSS são suficientes; não precisas de toda esta estrutura.
Quando começares a encontrar estas situações, vale a pena considerá-la:
- Há mais de dez temas ou versões de cliente, e continuam a aumentar.
- As diferenças entre temas vão além das cores e incluem layout, funcionalidades ativadas ou desativadas e texto.
- Uma alteração para um tema afeta frequentemente outro de forma inesperada.
- Ao adicionar um tema, ninguém sabe ao certo que pontos têm de ser atualizados.
Limites das evidências#
É importante esclarecer o âmbito dos números production mencionados neste artigo:
- 23 linhas refere-se a um theme leaf completo e representativo após a reestruturação; não significa que todos os leaf e todos os ficheiros tenham 23 linhas.
- A migration introduziu 70 módulos da nova arquitetura (24 facade, 10 data service, 10 configurações de tema e 26 shared view). Este é o âmbito da reestruturação, não um resultado de desempenho ou de redução de código.
- 2.4 MB → 17 KB é uma medição feita em production. Os três pequenos ficheiros CSS da Demo pública não reproduzem este valor.
- No mesmo projeto de melhoria da qualidade, o Lighthouse Performance da página de teste principal passou de 50+ para 90+, mas esse resultado deve-se ao conjunto de várias melhorias, não apenas ao carregamento de CSS a pedido.
O repo público demonstra o próprio mecanismo da arquitetura: contratos tipados, mudança em runtime, testes de integridade da família, CSS a pedido e o CI que executa estas verificações. A tabela completa que relaciona as afirmações com as evidências está na auditoria dos limites das evidências do repo.
Para ver o resultado, abre a Demo online, muda entre os três temas, idiomas e modos claro e escuro, e compara com o código-fonte no GitHub.