Blog'a Dön
React NativeWebViewMobileIntegrationTypeScript

WebView Entegrasyon Kalıpları: React Native ve React Web Arasında Köprü Kurmak

Umut Korkmaz2025-04-0110 min read

Mobil bankacılık uygulamamız React Native ile geliştirildi, ancak bazı özellikler — karmaşık veri görselleştirmeleri, belge görüntüleyiciler, zengin metin (rich-text) editörleri — React web ile daha iyi sunuluyor. Zorluk şuydu: güvenliği, performansı ve kesintisiz bir kullanıcı deneyimini korurken bir React web uygulamasını React Native bir kabuk (shell) içine gömmek. Aylarca süren yinelemelerden sonra, karar kıldığımız mimari işte burada.

Native Bir Uygulamada Neden WebView?

Nasıl yapıldığına dalmadan önce, nedenini gerekçelendireyim. React web içeriğini üç nedenle gömüyoruz:

  1. Paylaşılan kod tabanları: Yatırım panomuz, web ve mobilde aynı şekilde çalışan D3.js grafikleri kullanıyor
  2. Hızlı yineleme: Web içeriği, uygulama mağazası inceleme döngüleri olmadan güncellenebilir
  3. Uzmanlık özellikleri: PDF render etme ve belge işaretleme (annotation) kütüphanelerinin daha iyi web uygulamaları var

Bedeli karmaşıklık. Aslında güvenli biçimde iletişim kurması gereken iki JavaScript çalışma zamanı (runtime) çalıştırıyorsunuz.

HMAC İmzalı İletişim Protokolü

En kritik husus, native ile web arasındaki iletişim köprüsüdür. Mesaj enjeksiyon saldırılarını önlemek için HMAC-SHA256 ile imzalanmış mesajlar kullanıyoruz. Birisi WebView'e JavaScript enjekte edebilirse (ele geçirilmiş bir CDN veya XSS aracılığıyla), imzasız mesajlar onların native işlevselliği çağırmasına izin verirdi.

typescript
// shared/messageProtocol.ts (used by both native and web)
import { HmacSHA256, enc } from 'crypto-js';

interface BridgeMessage {
  type: string;
  payload: any;
  timestamp: number;
  nonce: string;
  signature: string;
}

export function signMessage(
  type: string,
  payload: any,
  secret: string
): BridgeMessage {
  const timestamp = Date.now();
  const nonce = generateNonce();

  const dataToSign = JSON.stringify({ type, payload, timestamp, nonce });
  const signature = HmacSHA256(dataToSign, secret).toString(enc.Hex);

  return { type, payload, timestamp, nonce, signature };
}

export function verifyMessage(
  message: BridgeMessage,
  secret: string
): boolean {
  const { signature, ...rest } = message;
  const dataToVerify = JSON.stringify(rest);
  const expectedSignature = HmacSHA256(dataToVerify, secret).toString(enc.Hex);

  // Timing-safe comparison
  if (signature.length !== expectedSignature.length) return false;
  let result = 0;
  for (let i = 0; i < signature.length; i++) {
    result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
  }

  // Reject messages older than 30 seconds
  if (Date.now() - message.timestamp > 30000) return false;

  return result === 0;
}

Zamanlamadan bağımsız (timing-safe) karşılaştırma, zamanlama saldırılarını önler ve 30 saniyelik pencere yeniden oynatma saldırılarını engeller. HMAC gizli anahtarı, oturum başına türetilir ve WebView'in JavaScript bağlamında asla açığa çıkmaz.

WebViewPoolContext

Bir WebView oluşturmak pahalıdır — eksiksiz bir tarayıcı motoru başlatır. Düşük donanımlı Android cihazlarda bu 500-800ms sürer. WebViewPoolContext'imiz, ihtiyaç duyulduğunda hazır olmaları için WebView'leri önceden başlatır:

typescript
// contexts/WebViewPoolContext.tsx
import React, { createContext, useContext, useRef, useCallback } from 'react';
import { WebView } from 'react-native-webview';

interface PooledWebView {
  id: string;
  ref: React.RefObject<WebView>;
  isAvailable: boolean;
  lastUsed: number;
}

interface WebViewPoolContextType {
  acquireWebView: (purpose: string) => PooledWebView | null;
  releaseWebView: (id: string) => void;
  getPoolStatus: () => PoolStatus;
}

const POOL_SIZE = 3;
const MAX_IDLE_TIME = 5 * 60 * 1000; // 5 minutes

export const WebViewPoolProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const pool = useRef<PooledWebView[]>(
    Array.from({ length: POOL_SIZE }, (_, i) => ({
      id: `webview-pool-${i}`,
      ref: React.createRef<WebView>(),
      isAvailable: true,
      lastUsed: 0,
    }))
  );

  const acquireWebView = useCallback((purpose: string) => {
    const available = pool.current.find((wv) => wv.isAvailable);
    if (!available) {
      // All WebViews in use — check for stale ones
      const stale = pool.current.find(
        (wv) => Date.now() - wv.lastUsed > MAX_IDLE_TIME
      );
      if (stale) {
        stale.isAvailable = false;
        stale.lastUsed = Date.now();
        return stale;
      }
      return null;
    }

    available.isAvailable = false;
    available.lastUsed = Date.now();
    return available;
  }, []);

  const releaseWebView = useCallback((id: string) => {
    const webView = pool.current.find((wv) => wv.id === id);
    if (webView) {
      webView.isAvailable = true;
      // Reset the WebView state
      webView.ref.current?.injectJavaScript(
        'window.location.href = "about:blank"; true;'
      );
    }
  }, []);

  return (
    <WebViewPoolContext.Provider
      value={{ acquireWebView, releaseWebView, getPoolStatus }}
    >
      {children}
      {/* Render hidden pre-initialized WebViews */}
      {pool.current.map((wv) => (
        <WebView
          key={wv.id}
          ref={wv.ref}
          source={{ uri: 'about:blank' }}
          style={{ width: 0, height: 0, position: 'absolute' }}
          javaScriptEnabled
          onMessage={handlePoolMessage}
        />
      ))}
    </WebViewPoolContext.Provider>
  );
};

Havuz, 3 WebView'i önceden başlatır. Bir tane edinmek anlıktır — soğuk başlangıç gecikmesi yoktur. Serbest bırakıldığında, WebView durumu temizlemek için about:blank'e gider ancak tarayıcı motorunu sıcak tutar.

Native'den Web'e Köprü

Köprü bileşenimiz, havuzdaki WebView'i imzalama protokolüyle sarar:

typescript
// components/SecureWebView.tsx
interface SecureWebViewProps {
  url: string;
  onEvent: (event: WebViewEvent) => void;
}

export const SecureWebView: React.FC<SecureWebViewProps> = ({
  url,
  onEvent,
}) => {
  const { acquireWebView, releaseWebView } = useWebViewPool();
  const webViewRef = useRef<PooledWebView | null>(null);
  const sessionSecret = useRef(generateSessionSecret());

  useEffect(() => {
    const wv = acquireWebView('secure-content');
    webViewRef.current = wv;
    return () => {
      if (wv) releaseWebView(wv.id);
    };
  }, []);

  const sendToWeb = useCallback((type: string, payload: any) => {
    const message = signMessage(type, payload, sessionSecret.current);
    const js = `window.__NATIVE_BRIDGE__.receive(${JSON.stringify(message)}); true;`;
    webViewRef.current?.ref.current?.injectJavaScript(js);
  }, []);

  const handleMessage = useCallback((event: WebViewMessageEvent) => {
    try {
      const message: BridgeMessage = JSON.parse(event.nativeEvent.data);

      if (!verifyMessage(message, sessionSecret.current)) {
        console.warn('Invalid message signature from WebView');
        return;
      }

      switch (message.type) {
        case 'TOKEN_REQUEST':
          handleTokenRequest(message.payload);
          break;
        case 'NAVIGATION':
          onEvent({ type: 'navigate', data: message.payload });
          break;
        case 'ANALYTICS':
          trackEvent(message.payload);
          break;
        default:
          onEvent({ type: message.type, data: message.payload });
      }
    } catch (error) {
      console.error('Failed to parse WebView message:', error);
    }
  }, []);

  return (
    <WebView
      ref={webViewRef.current?.ref}
      source={{ uri: url }}
      onMessage={handleMessage}
      injectedJavaScriptBeforePageLoad={`
        window.__NATIVE_BRIDGE__ = {
          sessionId: '${sessionSecret.current.substring(0, 8)}',
          receive: function(message) {
            window.dispatchEvent(
              new CustomEvent('native-message', { detail: message })
            );
          }
        };
        true;
      `}
      originWhitelist={['https://*']}
      javaScriptEnabled
      domStorageEnabled={false}
      thirdPartyCookiesEnabled={false}
      sharedCookiesEnabled={false}
    />
  );
};

Güvenlik ayarlarına dikkat edin: domStorageEnabled={false} yerel depolama (local storage) erişimini engeller, thirdPartyCookiesEnabled={false} izlemeyi (tracking) engeller ve originWhitelist erişimi HTTPS ile sınırlar.

Web Tarafı Köprü İstemcisi

React web uygulaması, köprüyü enjekte edilen global aracılığıyla alır:

typescript
// web/src/bridges/nativeBridge.ts
class NativeBridge {
  private listeners: Map<string, Set<(payload: any) => void>> = new Map();

  constructor() {
    window.addEventListener('native-message', ((event: CustomEvent) => {
      const message = event.detail as BridgeMessage;
      const handlers = this.listeners.get(message.type);
      if (handlers) {
        handlers.forEach((handler) => handler(message.payload));
      }
    }) as EventListener);
  }

  on(type: string, handler: (payload: any) => void): () => void {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type)!.add(handler);

    return () => {
      this.listeners.get(type)?.delete(handler);
    };
  }

  send(type: string, payload: any): void {
    // The web side also signs messages
    const message = signMessage(type, payload, this.getSecret());
    window.ReactNativeWebView?.postMessage(JSON.stringify(message));
  }

  async requestToken(): Promise<string> {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error('Token request timeout')), 5000);

      const unsub = this.on('TOKEN_RESPONSE', (payload) => {
        clearTimeout(timeout);
        unsub();
        resolve(payload.token);
      });

      this.send('TOKEN_REQUEST', {});
    });
  }

  private getSecret(): string {
    return (window as any).__BRIDGE_SECRET__;
  }
}

export const nativeBridge = new NativeBridge();

Token Devri (Handoff) Kalıbı

WebView'in kimlik doğrulama token'larına ihtiyacı var ama bunları saklamamalı. Web içeriğinden gelen her API çağrısı, native katmandan taze bir token ister:

typescript
// web/src/api/client.ts
const apiClient = axios.create({ baseURL: API_URL });

apiClient.interceptors.request.use(async (config) => {
  if (isRunningInWebView()) {
    const token = await nativeBridge.requestToken();
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

Bu, token'ların WebView'in belleğinde gereğinden uzun süre saklanmamasını sağlar. Native TokenManager yenileme mantığını yönetir ve WebView her istekte geçerli bir token alır.

Performans Sonuçları

| Metrik | Havuzsuz | Havuzla | |--------|-------------|-----------| | WebView soğuk başlangıç (Android) | 650ms | 50ms | | WebView soğuk başlangıç (iOS) | 320ms | 30ms | | Mesaj gidiş-dönüş süresi | 15ms | 12ms | | Bellek ek yükü | Talep üzerine | +45MB taban |

Üç havuzlanmış WebView'in 45MB taban bellek maliyeti, ödenen bedeldir. 6-8GB RAM'e sahip modern cihazlarda kabul edilebilir. Bütçe cihazlarında ise havuzu 1'e düşürürüz.

WebView entegrasyon kalıbı gösterişli değil, ancak iki dünyanın da en iyisinden yararlanmamızı sağlıyor — çekirdek bankacılık akışları için native performans, uzmanlık özellikleri için web esnekliği. HMAC imzalama minimum gecikme ama önemli güvenlik ekliyor ve havuz, WebView hakkında en yaygın kullanıcı şikâyetini ortadan kaldırıyor: yükleme gecikmesi.