지난 시간에 다룬 Context 개념을 우리 프로젝트에 사용할 Router를 구현하며 적용해보려고 한다.
const App = () => {
const { pathname } = window.location;
return (
<>
{pathname === "/cart" && <CartPage />}
{pathname === "/order" && <OrderPage />}
{!["/order", "/cart"].includes(pathname) && <ProductPage />}
</>
);
};
기존 App은 위와 같이 pathname에 따라 화면을 조건부 렌더링하는 방식이었다. 이는 페이지 렌더링 시 서버를 사용하는 방식이므로, 브라우저 자체 렌더링 방식으로 변경하기 위해 Router Context를 구현하고자 한다.
📁 MyRouter.jsx
import React from "react";
import { getComponentName } from "./utils";
export const routerContext = React.createContext({});
routerContext.displayName = "RouterContext";
export class Router extends React.Component {
constructor(props) {
super(props);
this.state = {
path: window.location.pathname,
};
// 비동기로 동작하므로 this 바인딩
this.handleChangePath = this.handleChangePath.bind(this);
this.handleOnPopState = this.handleOnPopState.bind(this);
}
handleChangePath(path) {
this.setState({ path }); // path 값을 인자 값으로 변경
window.history.pushState({ path }, "", path); // 주소창 주소 변경
}
// popState 이벤트가 발행됐을 때(브라우저의 앞/뒤로 가기) 경로 변경하는 함수
handleOnPopState(event) {
const nextPath = event.state && event.state.path;
if (!nextPath) return;
this.setState({ path: nextPath });
}
componentDidMount() {
// 브라우저의 앞/뒤로 가기 이벤트(popstate)가 발행되면 handleOnPopState 함수를 실행
window.addEventListener("popstate", this.handleOnPopState);
// 컴포넌트 렌더링 시 현재 경로로 state 저장 (설정하지 않으면 null)
window.history.replaceState({ path: this.state.path }, "");
}
componentWillUnmount() {
window.removeEventListener("popstate", this.handleOnPopState);
}
render() {
const contextValue = {
path: this.state.path,
changePath: this.handleChangePath,
};
return (
<routerContext.Provider value={contextValue}>
{this.props.children}
</routerContext.Provider>
);
}
}
// Routes가 정해진 path에만 얽매이지 않고, children으로 받아 렌더링하도록 리팩토링(!) => 재사용성 향상
export const Routes = ({ children }) => {
return (
<routerContext.Consumer>
{({ path }) => {
let selectedRoute = null;
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return; // 리액트 Element인지 검사
if (child.type === React.Fragment) return; // <></>인지 검사
if (!child.props.path || !child.props.element) return; // Route 컴포넌트가 맞는지 검사
if (child.props.path !== path.replace(/\?.*$/, "")) return; // 요청 경로를 검사(query 문자열 제거)
selectedRoute = child.props.element;
});
return selectedRoute;
}}
</routerContext.Consumer>
);
};
export const Route = () => null;
export const withRouter = (WrappedComponent) => {
const WithRouter = (props) => (
<routerContext.Consumer>
{({ path, changePath }) => {
// 주소 이동하기
const navigate = (nextPath) => {
if (path !== nextPath) changePath(nextPath);
};
// 주소 일치 여부 확인
const match = (comparedPath) => comparedPath === path;
// 쿼리 스트링 이용하기
const params = () => {
const params = new URLSearchParams(window.location.search);
const paramsObject = {};
for (const [key, value] of params) {
paramsObject[key] = value;
}
return paramsObject;
}
const enhancedProps = {
navigate,
match,
params
};
return <WrappedComponent {...props} {...enhancedProps} />;
}}
</routerContext.Consumer>
);
WithRouter.displayName = `WithRouter(${getComponentName(WrappedComponent)})`;
return WithRouter;
};
먼저 createContext 함수를 통해 routerContext 객체를 생성한다. 이 파일은 크게 Provider를 담당하는 Router
컴포넌트와 Consumer를 담당하는 Routes
컴포넌트로 나눌 수 있다.
🅿 Router
컴포넌트를 살펴보자. window 객체를 사용해 현재 pathname을 가져오고, 클래스 내에서 state로 관리한다. 이 때 handleChangePath 함수를 통해 상태 값을 업데이트(setState)하고, 주소를 변경(window.history.pushState)한다.
브라우저의 앞/뒤로 가기 버튼 클릭 시에는 popstate 이벤트가 발행되는데, 이 때 event 객체를 활용해 경로를 변경하는 handleOnPopState 함수를 구현하고 이를 EventListener로 등록한다.
또한 컴포넌트 렌더링 시 현재 경로를 state의 path 값으로 대체하도록 window.history.replaceState 함수를 사용한다.
SPA 라우터에서는 브라우저 주소와 컴포넌트의 state에 저장된 path가 동기화되어야 한다. replaceState 함수는 브라우저 히스토리에 초기 상태를 설정해, 브라우저의 앞/뒤로 가기 버튼 이용 시 일관성 유지에 도움을 준다.
⚠️ 만약 replaceState 를 사용하지 않을 경우, 새로고침하거나 브라우저 탐색 버튼을 사용할 때 상태가 null이 될 수도 있다. 따라서 컴포넌트가 마운트되었을 때 해당 작업이 이루어질 수 있도록 해야 한다.
최종적으로 Router
객체는 경로 path와 경로를 변경하는 함수를 Provider의 value 속성을 통해 하위 트리에 전달한다. 따라서 Consumer 컴포넌트들은 모두 이 값을 사용할 수 있다.
🅲 이제 Consumer 컴포넌트인 Routes
컴포넌트를 살펴보자.