StackPractices
intermediate Por Mathias Paulenko

Patrón Bridge para Desacoplar Componentes UI de Temas

Separa la abstracción de su implementación para que ambas varien de forma independiente: el patrón Bridge aplicado a temas UI y motores de renderizado.

Descripción general

El patrón Bridge desacopla una abstracción de su implementación para que ambas varien de forma independiente. En lugar de una jerarquía de clases que combina tipos de componentes con plataformas de renderizado, Bridge crea dos jerarquías separadas: una para las abstracciones (los componentes) y otra para las implementaciones (los renderizadores o temas).

Este recurso aplica el patrón a un problema concreto de frontend —temas UI y motores de renderizado intercambiables en TypeScript. Para la versión general con ejemplos en Python y Java, consulta el patrón Bridge.

Cuándo usarlo

  • Necesitas soportar varias plataformas o temas sin una explosión de subclases
  • Los cambios en la implementación no deberían obligar a recompilar la capa de abstracción
  • Ambas dimensiones —qué hace el objeto y cómo se renderiza— evolucionan de forma independiente
  • Quieres intercambiar el comportamiento de renderizado en runtime (un selector de tema es el caso clásico)

Evítalo cuando un solo método o un flag de configuración sea suficiente. Si solo varía una dimensión, un Strategy es más simple y cumple lo mismo.

Problema

Soportar Button, Checkbox y Slider en Web, iOS y Android produce 9 subclases: WebButton, iOSButton, AndroidButton, WebCheckbox, iOSCheckbox, y así sucesivamente. Añade una cuarta plataforma o un componente nuevo y la matriz crece otra vez: cada tipo nuevo toca todas las plataformas, y cada plataforma nueva toca todos los tipos.

Lo mismo pasa con los temas. Tres componentes y tres temas (claro, oscuro, alto contraste) son otra familia combinatoria, y la duplicación no se queda en el número de clases: la lógica de estilos se copia entre subclases, así que arreglar un border-radius significa tocar cada clase hoja que lo comparte. La herencia te obliga a elegir un eje como jerarquía “principal” y acoplar el otro con condicionales —así nacen WebDarkButton e IOSLightCheckbox.

Solución

Divide las dos dimensiones. UIComponent (la abstracción) guarda una referencia a un UIRenderer (la implementación) y le delega el renderizado. Componentes y renderizadores crecen por separado: M componentes + N renderizadores en lugar de M×N subclases.

// bridge/Renderer.ts
interface UIRenderer {
  renderButton(label: string, onClick: () => void): string;
  renderCheckbox(label: string, checked: boolean): string;
  renderSlider(min: number, max: number, value: number): string;
}

// Implementaciones
class WebRenderer implements UIRenderer {
  renderButton(label: string, onClick: () => void): string {
    return `<button onclick="${onClick.name}()">${label}</button>`;
  }

  renderCheckbox(label: string, checked: boolean): string {
    const checkedAttr = checked ? 'checked' : '';
    return `<label><input type="checkbox" ${checkedAttr}> ${label}</label>`;
  }

  renderSlider(min: number, max: number, value: number): string {
    return `<input type="range" min="${min}" max="${max}" value="${value}">`;
  }
}

class NativeRenderer implements UIRenderer {
  renderButton(label: string): string {
    return `[Native Button: ${label}]`;
  }

  renderCheckbox(label: string, checked: boolean): string {
    return `[Native Checkbox: ${label} ${checked ? '✓' : ' '}]`;
  }

  renderSlider(min: number, max: number, value: number): string {
    return `[Native Slider: ${value}/${max}]`;
  }
}

// Abstracciones
abstract class UIComponent {
  constructor(protected renderer: UIRenderer) {}
  abstract render(): string;
}

class Button extends UIComponent {
  constructor(
    renderer: UIRenderer,
    private label: string,
    private onClick: () => void
  ) {
    super(renderer);
  }

  render(): string {
    return this.renderer.renderButton(this.label, this.onClick);
  }
}

class Checkbox extends UIComponent {
  constructor(
    renderer: UIRenderer,
    private label: string,
    private checked: boolean
  ) {
    super(renderer);
  }

  render(): string {
    return this.renderer.renderCheckbox(this.label, this.checked);
  }
}

// Uso
function submitForm(): void { /* ... */ }

const webButton = new Button(new WebRenderer(), 'Submit', submitForm);
const nativeButton = new Button(new NativeRenderer(), 'Submit', submitForm);

console.log(webButton.render());     // <button onclick="submitForm()">Submit</button>
console.log(nativeButton.render());  // [Native Button: Submit]

TypeScript permite que NativeRenderer.renderButton omita el parámetro onClick: las clases que implementan una interfaz pueden aceptar menos argumentos de los declarados, lo que deja a los renderizadores de consola honestos sobre lo que ignoran.

Variación: Theme Bridge

La misma estructura sirve para temas en lugar de plataformas —la jerarquía de implementación es Theme en vez de UIRenderer:

// bridge/Theme.ts
interface Theme {
  getColors(): { primary: string; background: string; text: string };
  getBorderRadius(): number;
  getSpacing(): number;
}

class LightTheme implements Theme {
  getColors() { return { primary: '#007bff', background: '#ffffff', text: '#333333' }; }
  getBorderRadius() { return 4; }
  getSpacing() { return 8; }
}

class DarkTheme implements Theme {
  getColors() { return { primary: '#4dabf7', background: '#1a1a1a', text: '#e0e0e0' }; }
  getBorderRadius() { return 8; }
  getSpacing() { return 12; }
}

abstract class ThemedComponent {
  constructor(protected theme: Theme) {}
}

class ThemedButton extends ThemedComponent {
  render(label: string): string {
    const colors = this.theme.getColors();
    return `
      <button style="
        background: ${colors.primary};
        color: ${colors.text};
        border-radius: ${this.theme.getBorderRadius()}px;
        padding: ${this.theme.getSpacing()}px;
      ">${label}</button>
    `;
  }
}

const light = new ThemedButton(new LightTheme());
const dark = new ThemedButton(new DarkTheme());

Cómo funciona

El patrón tiene cuatro roles:

  1. Abstracción (UIComponent) define la interfaz de alto nivel que usan los clientes y guarda la referencia al implementador.
  2. Abstracción refinada (Button, Checkbox, ThemedButton) extiende la abstracción con comportamiento propio del componente —nunca renderiza directamente.
  3. Implementación (UIRenderer, Theme) declara la interfaz de plataforma o tema que consumen las abstracciones.
  4. Implementación concreta (WebRenderer, NativeRenderer, DarkTheme) produce la salida específica de cada plataforma.
flowchart diagram: subgraph Abstracciones

La arista punteada es el “bridge”: la abstracción guarda una referencia al objeto de implementación y lo invoca. Como ese enlace es composición y no herencia, cualquiera de las dos jerarquías puede crecer sin tocar a la otra.

Cambio de tema en runtime

La composición también permite reemplazar la implementación con la app en marcha. Dale al componente un setter y el tema pasa a ser intercambiable:

class Button extends UIComponent {
  constructor(theme: Theme, private label: string) { super(theme); }

  setTheme(theme: Theme): void {
    this.theme = theme;
  }

  render(): string { return this.theme.renderButton(this.label); }
}

let currentTheme: Theme = new LightTheme();
const button = new Button(currentTheme, "Submit");
console.log(button.render()); // Tema claro

button.setTheme(new DarkTheme());
console.log(button.render()); // Tema oscuro

En esta variante Theme expone renderButton directamente, así que DarkTheme necesita su propia salida —un primary más oscuro y mayor contraste—, si no, el ejemplo no demuestra que el bridge haga nada:

class DarkTheme implements Theme {
  primary = "#1e293b";
  background = "#0f172a";
  text = "#f8fafc";
  renderButton(label: string): string {
    return `<button style="background:${this.primary};color:${this.text};padding:10px 20px;border:1px solid ${this.text}">${label}</button>`;
  }
}

Compromisos

  • Un nivel más de indirección: cada llamada a render pasa por una interfaz. Es barato en la práctica, pero complica las trazas y el onboarding —un bug apunta a Button, pero el defecto puede vivir en el renderizador que contiene.
  • Dos jerarquías que mantener honestas: la interfaz de implementación debe cubrir todo lo que necesita cada abstracción —añadir un método a UIRenderer obliga a todos los renderizadores a implementarlo, y olvidar uno deja un hueco en runtime, no un error de compilación.
  • Solo compensa cuando varían los dos ejes: una sola dimensión variable paga el coste de complejidad sin beneficio. Si solo vas a tener un tema, deja los estilos inline.
  • El diseño de la interfaz importa de verdad: si metes demasiado en la interfaz de implementación acaba siendo una god-interface; si metes poco, las abstracciones empiezan a hacer downcasting o a saltarse el bridge.

Errores comunes

  • Confundir Bridge con Adapter: Adapter hace compatibles interfaces no relacionadas a posteriori; Bridge separa interfaz e implementación por adelantado, a propósito.
  • Usar Bridge cuando un Strategy basta —Strategy cambia un algoritmo; Bridge separa dos jerarquías enteras.
  • Dejar lógica de renderizado en la abstracción, con lo que el “bridge” no soporta peso y vuelves a las M×N clases.
  • Declarar el miembro de implementación protected y luego asignarlo desde fuera —usa un setter como setTheme() o vuelve a instanciar el componente.

Lecturas recomendadas

Preguntas frecuentes

¿En qué se diferencia de Strategy?

Strategy cambia el comportamiento de un solo objeto en runtime. Bridge separa dos jerarquías de clases completas para que cada una evolucione por su lado. Si la relación es permanente —un componente siempre tiene un tema— Bridge encaja mejor.

¿Puedo usarlo para backends de base de datos?

Sí. La abstracción es tu interfaz de repositorio; las implementaciones son adaptadores de SQL, MongoDB o DynamoDB —la misma forma de dos jerarquías aplicada a persistencia en lugar de renderizado.

¿Sirve este patrón para proyectos pequeños?

Normalmente no. Con un puñado de componentes y una sola plataforma, las interfaces extra cuestan más de lo que ahorran. Introdúcelo cuando el dolor del M×N aparezca de verdad.

¿Funciona Bridge con inyección de dependencias?

Muy bien. Registra la implementación concreta en el contenedor DI e inyéctala en la abstracción —cambiar de implementación pasa a ser un cambio de configuración, que combina de forma natural con el cambio de tema en runtime.

¿Puedo aplicarlo parcialmente, solo a un componente?

Sí. Muchos equipos empiezan por el componente que más duele —por ejemplo, el theming de Button— y amplían la interfaz de implementación a medida que se unen otros. El patrón admite adopción incremental.