Mobile Micro Frontends mit React Native Expo: WebView-Architektur und reale Implementierung

Tiefgreifende Analyse der Implementierung von Micro Frontend-Architekturen in mobilen Apps mit React Native Expo und WebViews. Echte Produktionserfahrungen, Performance-Daten und kampferprobte Patterns.

Letztes Jahr stand unser Team vor einer Herausforderung, die immer häufiger wird: Wir mussten eine mobile App entwickeln, die mehrere webbasierte Services verschiedener Teams integrieren konnte, jedes mit eigenen Deployment-Zyklen und Tech-Stacks. Der Haken? Wir hatten 8 Wochen Zeit für die Lieferung, und die Web-Teams konnten ihre Feature-Entwicklung nicht stoppen, um uns zu helfen.

Da entschieden wir uns, Micro Frontend-Architektur mit WebViews in unsere React Native App zu bringen. Was folgte, waren 6 Monate voller Debugging, Performance-Optimierung und einiger kreativer Lösungen, die ich nie zu implementieren gedacht hätte. Hier ist, was wir gelernt haben.

Mobile Micro Frontend Serie#

Dies ist Teil 1 einer umfassenden 3-teiligen Serie über mobile Micro Frontends:

Neu bei mobilen Micro Frontends? Fang hier an, um die Architektur und Implementierungs-Grundlagen zu verstehen.

Bereit zur Implementierung? Spring zu Teil 2 für Kommunikations-Patterns.

Läuft bereits in der Produktion? Check Teil 3 für Optimierungs-Strategien.

Warum Mobile Micro Frontends?#

Traditionelle mobile App-Entwicklung hat ein grundlegendes Problem: Native Code-Deployment ist langsam. App Store-Überprüfungen, Benutzer-Update-Adoption und die Koordination von Releases zwischen mehreren Teams schaffen Engpässe, die Web-Entwickler bereits vor Jahren gelöst haben.

Unsere spezifischen Einschränkungen waren:

  • 5 verschiedene Web-Teams mit bestehenden React/Vue/Angular-Anwendungen
  • Wöchentliche Web-Deployments vs. monatliche Mobile-Releases
  • A/B-Test-Anforderungen, die nicht auf App-Updates warten konnten
  • Compliance-Features, die sofortige Deployment-Fähigkeit benötigten

Die Lösung? Webbasierte Micro Frontends in unserer React Native App mit WebViews einbetten.

Architektur-Überblick#

Hier ist die High-Level-Architektur, die wir implementiert haben:

Loading diagram...

Die native App fungiert als Shell, die:

  • Authentifizierung und Session-Management übernimmt
  • Native Funktionalität bereitstellt (Kamera, Biometrie, etc.)
  • Navigation zwischen Micro Frontends verwaltet
  • Eine Kommunikations-Bridge zwischen WebViews und nativem Code implementiert

Alternative Ansätze: Jenseits traditioneller WebViews#

Bevor ich in unsere WebView-Implementierung eintauche, lass mich die alternativen Ansätze teilen, die wir evaluiert haben und warum wir WebViews anstelle von ihnen gewählt haben.

Option 1: Re.Pack mit Module Federation#

Re.Pack ist Callstack's Lösung, um Module Federation zu React Native zu bringen. Es ist im Wesentlichen webpack's Module Federation, die in React Native läuft.

Was wir ausprobierten:

TypeScript
// Re.Pack Konfiguration
const { ModuleFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        booking: 'booking@http://localhost:3001/remoteEntry.js',
        shopping: 'shopping@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

Warum wir es nicht gewählt haben:

  • Komplexität: Benötigte bedeutende Änderungen an unseren bestehenden webpack-Konfigurationen
  • Team-Koordination: Alle Teams mussten gleichzeitig Re.Pack adoptieren
  • Debugging: Module Federation-Debugging in React Native war noch unreif
  • Performance: Initiale Bundle-Größe stieg um 40% durch Federation-Overhead

Wann Re.Pack verwenden:

  • Du fängst frisch mit einem neuen Projekt an
  • Alle Teams können sich auf denselben Bundler koordinieren
  • Du brauchst echtes Runtime-Modul-Sharing
  • Du baust eine "Super App" mit mehreren unabhängigen Teams

Option 2: Rspack mit React Native#

Rspack ist ein Rust-basierter Bundler, der webpack-kompatibel aber viel schneller ist. Wir experimentierten mit Rspack für unsere Micro Frontend-Builds.

Was wir ausprobierten:

TypeScript
// rspack.config.mjs
export default {
  entry: './src/index.tsx',
  module: {
    rules: [
      {
        test: /\.tsx$/,
        use: {
          loader: 'builtin:swc-loader',
          options: {
            jsc: {
              parser: {
                syntax: 'typescript',
                tsx: true
              },
              transform: {
                react: {
                  runtime: 'automatic'
                }
              }
            }
          }
        }
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'micro-frontend',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx'
      }
    })
  ]
};

Warum wir es nicht gewählt haben:

  • React Native-Kompatibilität: Rspack hat noch keine native React Native-Unterstützung
  • Ökosystem-Reife: Weniger Plugins und Loader im Vergleich zu webpack
  • Team-Adoption: Hätte bedeutende Umschulung von 5 Teams erfordert
  • Production-Stabilität: Wir konnten unser Zeitlimit nicht mit einem neueren Tool riskieren

Wann Rspack verwenden:

  • Du baust reine Web-Micro-Frontends
  • Build-Performance ist kritisch (Rspack ist 10x schneller als webpack)
  • Du kannst es dir leisten, ein Early Adopter zu sein
  • Deine Teams sind mit Rust-basierten Tools vertraut

Option 3: Vite + React Native#

Wir evaluierten auch die Verwendung von Vite für unsere Micro Frontend-Builds, um dessen schnelles HMR und modernes Build-System zu nutzen.

Was wir ausprobierten:

TypeScript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
          utils: ['lodash', 'date-fns']
        }
      }
    }
  },
  server: {
    cors: true
  }
});

Warum wir es nicht gewählt haben:

  • Module Federation: Vite's Module Federation-Unterstützung war noch experimentell
  • Production-Builds: Unsere Production-Builds waren langsamer als webpack
  • Plugin-Ökosystem: Weniger Plugins für unsere spezifischen Bedürfnisse
  • Team-Vertrautheit: Teams waren mehr mit webpack vertraut

Wann Vite verwenden:

  • Du baust moderne Web-Anwendungen
  • Entwicklungsgeschwindigkeit ist wichtiger als Production-Optimierung
  • Du brauchst keine komplexe Module Federation
  • Deine Teams bevorzugen moderne Tooling

Option 4: Hybrid-Ansatz (Was wir tatsächlich gemacht haben)#

Nach der Evaluation aller Optionen wählten wir einen Hybrid-Ansatz, der uns das Beste aller Welten gab:

Loading diagram...

Warum das funktionierte:

  • Team-Autonomie: Jedes Team konnte seinen bevorzugten Bundler verwenden
  • Schrittweise Migration: Teams konnten im Laufe der Zeit zu besseren Tools migrieren
  • Risiko-Minderung: Wenn ein Ansatz fehlschlug, arbeiteten andere weiter
  • Performance: Jedes Team konnte seine eigenen Builds optimieren

Implementierung: Die wahre Geschichte#

Aufbau der WebView-Architektur#

Wir begannen mit Expo's WebView, stießen aber schnell auf unsere erste Herausforderung. Die anfängliche Implementierung sah täuschend einfach aus:

TypeScript
import React from 'react';
import { WebView } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';

export function MicroFrontendContainer({ url }: { url: string }) {
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <WebView
        source={{ uri: url }}
        style={{ flex: 1 }}
      />
    </SafeAreaView>
  );
}

Das funktionierte genau 5 Minuten in der Entwicklung, bevor wir unser erstes Production-Problem trafen: Die WebView zeigte zufällig einen weißen Bildschirm auf Android-Geräten mit weniger als 4GB RAM.

Das Speicherproblem#

Nach dem Hinzufügen von Crash-Reporting entdeckten wir, dass WebViews jeweils 150-200MB verbrauchten. Mit 3 geladenen Micro Frontends erreichten wir Speicherlimits auf Low-End-Geräten. So lösten wir es:

TypeScript
import React, { useRef, useCallback, useState } from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { AppState, AppStateStatus } from 'react-native';

interface MicroFrontendConfig {
  url: string;
  preload?: boolean;
  cachePolicy?: 'default' | 'reload' | 'cache-else-load';
}

class WebViewManager {
  private static instance: WebViewManager;
  private activeWebViews = new Map<string, {
    ref: React.RefObject<WebView>;
    lastUsed: number;
    memoryUsage: number;
  }>();

  private maxWebViews = 3;
  private maxMemoryPerWebView = 100 * 1024 * 1024; // 100MB

  static getInstance(): WebViewManager {
    if (!WebViewManager.instance) {
      WebViewManager.instance = new WebViewManager();
    }
    return WebViewManager.instance;
  }

  registerWebView(id: string, ref: React.RefObject<WebView>): void {
    this.activeWebViews.set(id, {
      ref,
      lastUsed: Date.now(),
      memoryUsage: 0
    });

    // Aufräumen wenn wir Limits überschreiten
    this.cleanupIfNeeded();
  }

  private cleanupIfNeeded(): void {
    if (this.activeWebViews.size <= this.maxWebViews) return;

    // Finde am wenigsten kürzlich verwendete WebView
    const entries = Array.from(this.activeWebViews.entries());
    const lru = entries.reduce((min, current) =>
      current[1].lastUsed < min[1].lastUsed ? current : min
    );

    // Räume die WebView auf
    lru[1].ref.current?.clearCache();
    this.activeWebViews.delete(lru[0]);

    console.log(`[WebViewManager] WebView ${lru[0]} wegen Speicherlimits aufgeräumt`);
  }

  updateUsage(id: string): void {
    const webView = this.activeWebViews.get(id);
    if (webView) {
      webView.lastUsed = Date.now();
    }
  }
}

export function OptimizedMicroFrontendContainer({
  config
}: {
  config: MicroFrontendConfig
}) {
  const webViewRef = useRef<WebView>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);
  const webViewManager = WebViewManager.getInstance();

  const handleLoadStart = useCallback(() => {
    setIsLoading(true);
    setHasError(false);
  }, []);

  const handleLoadEnd = useCallback(() => {
    setIsLoading(false);
    webViewManager.updateUsage(config.url);
  }, [config.url]);

  const handleError = useCallback((error: any) => {
    console.error('[WebView] Ladefehler:', error);
    setHasError(true);
    setIsLoading(false);
  }, []);

  const handleMessage = useCallback((event: WebViewMessageEvent) => {
    // Bridge-Nachrichten behandeln (in Teil 2 behandelt)
    console.log('[WebView] Nachricht erhalten:', event.nativeEvent.data);
  }, []);

  // Bei Manager registrieren
  React.useEffect(() => {
    webViewManager.registerWebView(config.url, webViewRef);
  }, [config.url]);

  // App-State-Änderungen behandeln
  React.useEffect(() => {
    const handleAppStateChange = (nextAppState: AppStateStatus) => {
      if (nextAppState === 'background') {
        // Cache löschen wenn App in den Hintergrund geht
        webViewRef.current?.clearCache();
      }
    };

    const subscription = AppState.addEventListener('change', handleAppStateChange);
    return () => subscription?.remove();
  }, []);

  if (hasError) {
    return (
      <SafeAreaView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text>Micro Frontend konnte nicht geladen werden</Text>
        <Button title="Wiederholen" onPress={() => setHasError(false)} />
      </SafeAreaView>
    );
  }

  return (
    <SafeAreaView style={{ flex: 1 }}>
      {isLoading && (
        <View style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          backgroundColor: 'white',
          justifyContent: 'center',
          alignItems: 'center',
          zIndex: 1000
        }}>
          <ActivityIndicator size="large" />
        </View>
      )}

      <WebView
        ref={webViewRef}
        source={{
          uri: config.url,
          headers: {
            'X-Platform': 'react-native',
            'X-App-Version': '1.0.0'
          }
        }}
        style={{ flex: 1 }}
        onLoadStart={handleLoadStart}
        onLoadEnd={handleLoadEnd}
        onError={handleError}
        onMessage={handleMessage}
        cacheEnabled={config.cachePolicy !== 'reload'}
        cacheMode={config.cachePolicy === 'cache-else-load' ? 'LOAD_CACHE_ELSE_NETWORK' : 'LOAD_DEFAULT'}
        // Kritisch für Performance
        javaScriptEnabled={true}
        domStorageEnabled={true}
        startInLoadingState={true}
        scalesPageToFit={true}
        // Sicherheit
        allowsInlineMediaPlayback={false}
        mediaPlaybackRequiresUserAction={true}
        // Performance
        removeClippedSubviews={true}
        overScrollMode="never"
      />
    </SafeAreaView>
  );
}

Das Bundle-Größen-Problem#

Unsere anfänglichen Micro Frontend-Bundles waren massiv - jeweils 2-3MB. Das verursachte langsame Ladezeiten und schlechte Benutzererfahrung. So optimierten wir:

TypeScript
// webpack.config.js - Optimiert für WebView-Bereitstellung
const { ModuleFederationPlugin } = require('webpack').container;
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\\\|\/]/node_modules[\\\\|\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all',
          priority: 5
        }
      }
    },
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true, // Console.logs entfernen
            drop_debugger: true
          },
          mangle: {
            safari10: true // Safari 10 Probleme beheben
          }
        }
      })
    ]
  },
  plugins: [
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 10240,
      minRatio: 0.8
    }),
    new ModuleFederationPlugin({
      name: 'micro-frontend',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
      }
    })
  ]
};

Alternative: Rspack-Konfiguration#

Für Teams, die Rspack ausprobieren wollten, hier die äquivalente Konfiguration:

TypeScript
// rspack.config.mjs - Rspack-Version
export default {
  entry: './src/index.tsx',
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\\\|\/]/node_modules[\\\\|\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        }
      }
    }
  },
  module: {
    rules: [
      {
        test: /\.tsx$/,
        use: {
          loader: 'builtin:swc-loader',
          options: {
            jsc: {
              parser: {
                syntax: 'typescript',
                tsx: true
              },
              transform: {
                react: {
                  runtime: 'automatic'
                }
              },
              minify: {
                compress: {
                  drop_console: true,
                  drop_debugger: true
                }
              }
            }
          }
        }
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'micro-frontend',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx'
      }
    })
  ]
};

Performance-Vergleich:

  • Webpack Build-Zeit: 45 Sekunden
  • Rspack Build-Zeit: 8 Sekunden (82% schneller)
  • Bundle-Größe: Ähnlich (innerhalb von 5%)
  • Speichernutzung: Rspack verwendete 30% weniger Speicher während der Builds

Das Navigations-Problem#

WebView-Navigation funktioniert nicht wie native Navigation. Benutzer erwarten, dass der Zurück-Button funktioniert, aber WebViews haben ihre eigene History. Hier unsere Lösung:

TypeScript
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { BackHandler } from 'react-native';

export function WebViewWithNavigation({
  webViewRef,
  canGoBack,
  onGoBack
}: {
  webViewRef: React.RefObject<WebView>;
  canGoBack: boolean;
  onGoBack: () => void;
}) {
  const navigation = useNavigation();

  useFocusEffect(
    React.useCallback(() => {
      const onBackPress = () => {
        if (canGoBack) {
          // Versuche zuerst in WebView zurückzugehen
          webViewRef.current?.goBack();
          return true; // Verhindere Standard-Zurück-Verhalten
        } else {
          // Lasse native Navigation damit umgehen
          onGoBack();
          return false;
        }
      };

      BackHandler.addEventListener('hardwareBackPress', onBackPress);

      return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress);
    }, [canGoBack, onGoBack])
  );

  return null;
}

Performance-Ergebnisse#

Nach der Implementierung dieser Optimierungen verbesserten sich unsere Metriken dramatisch:

Lade-Performance#

  • Initiale Ladezeit: 2.3s → 0.8s (65% Verbesserung)
  • Bundle-Größe: 2.8MB → 1.2MB (57% Reduzierung)
  • Speichernutzung: 200MB → 120MB pro WebView (40% Reduzierung)

Benutzererfahrung#

  • App-Abstürze: 2.3% → 0.1% (96% Reduzierung)
  • Weiße-Bildschirm-Vorfälle: 15% → 1% (93% Reduzierung)
  • Benutzer-Zufriedenheitswert: 3.2 → 4.6 (44% Verbesserung)

Build-Performance#

  • Webpack-Builds: 45s → 35s (22% schneller)
  • Rspack-Builds: 8s (82% schneller als webpack)
  • Entwicklungs-HMR: 2s → 0.3s (85% schneller)

Wichtige Erkenntnisse#

  1. WebViews sind machbar, benötigen aber Optimierung: Sie funktionieren gut für Micro Frontends, benötigen aber sorgfältiges Speicher- und Performance-Management.

  2. Hybrid-Ansätze funktionieren am besten: Lass Teams ihre bevorzugten Tools verwenden, während eine konsistente Schnittstelle beibehalten wird.

  3. Rspack zeigt Potenzial: Für neue Projekte solltest du Rspack wegen seiner Geschwindigkeit und webpack-Kompatibilität in Betracht ziehen.

  4. Re.Pack ist mächtig aber komplex: Großartig für Super Apps, benötigt aber erhebliche Koordination.

  5. Performance ist kritisch: User bemerken langsame WebViews sofort. Optimiere aggressiv.

Was kommt als Nächstes?#

In Teil 2 tauchen wir ein in:

  • Aufbau einer robusten Kommunikations-Bridge zwischen WebViews und nativem Code
  • Type-safe Nachrichten-Passing
  • Umgang mit Authentifizierung und nativen Features
  • Debugging- und Monitoring-Strategien

Das Fundament ist entscheidend. Baust du die Architektur richtig auf, und alles andere wird handhabbar. Baust du sie falsch auf, und du wirst für immer gegen Performance-Probleme kämpfen.

Nächstes Mal schauen wir uns an, wie WebViews und nativer Code zuverlässig miteinander kommunizieren können, und die Debugging-Albträume, die wir dabei gelöst haben.

Loading...

Kommentare (0)

An der Unterhaltung teilnehmen

Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren

Noch keine Kommentare

Sei der erste, der deine Gedanken zu diesem Beitrag teilt!

Related Posts