React Native와 웹뷰 사이의 라우팅 동기화하기

Yoomin Kang·2024년 9월 1일
post-thumbnail

React 웹에서 react-router-dom을 사용할 경우, 우리는 다음과 같이 라우팅을 하곤 한다.

// App.tsx (웹)
import Detail from '@src/pages/Detail/Detail';
import Home from '@src/pages/Home/Home';
import { Route, Routes } from 'react-router-dom';

function App() {
  return (
    <Routes>
      <Route path="/home" element={<Home />} />
      <Route path="/detail/:id" element={<Detail />} />
    </Routes>
  );
}

export default App;

이 웹을 React Native에서 웹뷰로 띄울 경우 내비게이션을 어떻게 처리해줘야 할까?

간단하게 생각해보자. Stack.Screenname에 경로를 넣고, 각 웹뷰 컴포넌트에서 name 값을 이용하여 uri를 전달해주면 해결될 것 같다.

즉, React Native에서 다음과 같이 코드를 짜면 된다.

// App.tsx (앱)
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import WebComponent from '@src/components/WebComponent';

const App = () => {
  const HomeStack = createNativeStackNavigator();
	
  return (
    <NavigationContainer>
      <HomeStack.Navigator>
        <HomeStack.Screen name="/home" component={WebComponent} />
        <HomeStack.Screen name="/detail/:id" component={WebComponent} />
      </HomeStack.Navigator>
    </NavigationContainer>
  );
};

export default App;

이제 WebComponent를 작성해보자.

먼저, 가장 기본적인 웹뷰 컴포넌트 형식을 만들어준다.

// @components/WebComponent.tsx (앱)
import { RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useRef } from 'react';
import { SafeAreaView, StyleSheet, View } from 'react-native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';

type RootStackParamList = {
  [key: string]: undefined | { [key: string]: any };
};

type WebComponentProps = {
  navigation: NativeStackNavigationProp<RootStackParamList>;
  route: RouteProp<RootStackParamList, keyof RootStackParamList>;
};
const WebComponent = ({ navigation, route }: WebComponentProps) => {
  const webViewRef = useRef<WebView>(null);
  const uri = 'http://localhost:5173';

  const onMessage = async (event: WebViewMessageEvent) => {};
  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.webViewContainer}>
        <WebView
          source={{ uri: uri }}
          ref={webViewRef}
          onMessage={onMessage}
          style={styles.container}
        />
      </View>
    </SafeAreaView>
  );
};

export default WebComponent;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  webViewContainer: {
    flex: 1,
    width: '100%',
  },
  webView: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

uri를 실제 uri로 바꿔주기만 하면 된다!

:id를 params로 받아온 실제 id 값으로 대체하기 위해 buildUrl 함수를 작성해주자.

// @components/WebComponent.tsx (앱)
const buildUri = (
  template: string,
  params: { [key: string]: string },
  searchParams: { [key: string]: string } | undefined,
) => {
  const urlWithParams = template.replace(
    /:(\w+)/g,
    (_, key) => params[key] || '',
  );
  const queryParams = Object.entries(searchParams ?? {})
    .filter(([, value]) => value !== undefined && value !== '')
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`,
    )
    .join('&');
  if (queryParams) {
    return `${urlWithParams}?${queryParams}`;
  }
  return urlWithParams;
};

예를 들어 buildUri('/detail/:id', {id: '123'}, {name: 'test'})의 결과는 /detail/123?name=test가 된다.

id 외에 userId 등 다른 params가 있어도 잘 작동한다. (ex: buildUri('/detail/:userId/:id', {userId: '123', id: '456'})의 결과는 /detail/123/456)

이제 WebComponent에서 template과 params를 받아와 buildUri에 넣기만 하면 실제 uri가 완성된다.

// @components/WebComponent.tsx (앱)
const urlTemplate = route.name;
const { pathParams, searchParams, ...restParams } = route.params ?? {};
const uri = baseUrl + buildUri(urlTemplate, pathParams, searchParams);

페이지를 이동하고 싶을 때는 다음과 같이 작성하면 된다.

// 앱
navigation.navigate('/detail/:id', {pathParams: {id: '123'}, searchParams: {name: 'test'}});

그렇다면 웹뷰에서 페이지를 이동하고 싶을 때는 어떻게 하면 될까?

window.ReactNativeWebView.postMessage로 웹에서 앱으로 이벤트를 보내고, WebComponent의 onMessage에서 이를 잡아 처리하면 된다.

웹단에서 다음과 같이 작성하자. type으로 'ROUTER_EVENT'를 보내고, data에 path와 params를 넘겨주는 방식이다. 뒤로가기의 경우 path로 'goBack'을 보낸다.

// @utils/pushPath.ts (웹)
export const pushPath = (data: object): void => {
  window.ReactNativeWebView.postMessage(
    JSON.stringify({ type: 'ROUTER_EVENT', data }),
  );
};

export const goBack = () => {
  pushPath({
    path: 'goBack',
  });
};

이제 웹뷰에서 페이지를 이동하고 싶으면 웹단에서 다음과 같이 호출하면 된다.

// 웹
pushPath({
  path: '/detail/:id',
  params: {
    pathParams: {
	  id: '1',
	},
    searchParams: {
	  name: 'test'
    }
  },
});

이제 이 이벤트를 앱단에서 받아보자. 간단하다. type이 'ROUTER_EVENT'일 경우 data.path에 따라 뒤로 가거나, params를 담아 페이지를 이동시킨다.

// @components/WebComponent.tsx (앱)
const handleRouterEvent = (data: {
  path: string;
  params?: { [key: string]: string };
}) => {
  if (data.path === 'goBack') {
    const popAction = StackActions.pop(1);
    navigation.dispatch(popAction);
  } else {
    navigation.navigate(data.path, data.params);
  }
};

const onMessage = async (event: WebViewMessageEvent) => {
  const { nativeEvent } = event;
  const { type, data } = JSON.parse(nativeEvent.data);
  switch (type) {
    case 'ROUTER_EVENT':
      handleRouterEvent(data);
      break;
    default:
      break;
  }
};

다음은 완성된 WebComponent 코드이다.

// @components/WebComponent.tsx (앱)
import { RouteProp, StackActions } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { baseUrl } from '@src/constants/baseUrl';
import { useRef } from 'react';
import { SafeAreaView, StyleSheet, View } from 'react-native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';

const buildUri = (
  template: string,
  params: { [key: string]: string },
  searchParams: { [key: string]: string } | undefined,
) => {
  const urlWithParams = template.replace(
    /:(\w+)/g,
    (_, key) => params[key] || '',
  );
  const queryParams = Object.entries(searchParams ?? {})
    .filter(([, value]) => value !== undefined && value !== '')
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`,
    )
    .join('&');
  if (queryParams) {
    return `${urlWithParams}?${queryParams}`;
  }
  return urlWithParams;
};

type RootStackParamList = {
  [key: string]: undefined | { [key: string]: any };
};

type WebComponentProps = {
  navigation: NativeStackNavigationProp<RootStackParamList>;
  route: RouteProp<RootStackParamList, keyof RootStackParamList>;
};
const WebComponent = ({ navigation, route }: WebComponentProps) => {
  const webViewRef = useRef<WebView>(null);
  
  const urlTemplate = route.name;
  const { pathParams, searchParams, ...restParams } = route.params ?? {};
  const uri = baseUrl + buildUri(urlTemplate, pathParams, searchParams);

  const handleRouterEvent = (data: {
    path: string;
    params?: { [key: string]: string };
  }) => {
    if (data.path === 'goBack') {
      const popAction = StackActions.pop(1);
      navigation.dispatch(popAction);
    } else {
      navigation.navigate(data.path, data.params);
    }
  };

  const onMessage = async (event: WebViewMessageEvent) => {
    const { nativeEvent } = event;
    const { type, data } = JSON.parse(nativeEvent.data);
    switch (type) {
      case 'ROUTER_EVENT':
        handleRouterEvent(data);
        break;
      default:
        break;
    }
  };
  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.webViewContainer}>
        <WebView
          source={{ uri: uri }}
          ref={webViewRef}
          onMessage={onMessage}
          style={styles.container}
        />
      </View>
    </SafeAreaView>
  );
};

export default WebComponent;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  webViewContainer: {
    flex: 1,
    width: '100%',
  },
  webView: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

지금까지 React Native와 웹뷰 사이에서 라우팅을 동기화하는 법에 대해 알아보았다. 웹뷰를 다루다 보면 은근히 신경쓸 것이 많은데, 이렇게 라우팅을 통일시켜 놓으면 조금 더 수월한 개발이 가능할 것이다.

profile
FE Developer @Toss | GSHS 36 | Korea Univ 21

0개의 댓글