
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.Screen의 name에 경로를 넣고, 각 웹뷰 컴포넌트에서 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와 웹뷰 사이에서 라우팅을 동기화하는 법에 대해 알아보았다. 웹뷰를 다루다 보면 은근히 신경쓸 것이 많은데, 이렇게 라우팅을 통일시켜 놓으면 조금 더 수월한 개발이 가능할 것이다.