History API는 SPA에서 페이지를 새로 고침하지 않고 URL
을 변경하며, 네비게이션 기록을 관리할 수 있도록 하는 기능입니다.
React Router는 History API를 추상화한 라이브러리로, 라우팅 기능을 제공합니다.
History API를 학습하며 React Router의 동작 원리를 이해하기 위해 코드를 분석해 보았습니다.
// react-router/packages/react-router-dom/index.tsx
/**
* A `<Router>` for use in web browsers. Provides the cleanest URLs.
*/
export function BrowserRouter({
basename,
children,
future,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) { // history 객체 초기화
historyRef.current = createBrowserHistory({ window, v5Compat: true });
}
let history = historyRef.current;
let [state, setStateImpl] = React.useState({
action: history.action, // 현재 히스토리 액션(POP, PUSH, REPLACE)
location: history.location, // 현재 URL
});
let { v7_startTransition } = future || {};
let setState = React.useCallback(
(newState: { action: NavigationType; location: Location }) => {
v7_startTransition && startTransitionImpl
? startTransitionImpl(() => setStateImpl(newState))
: setStateImpl(newState);
},
[setStateImpl, v7_startTransition]
);
// 컴포넌트가 렌더링 되기 전 실행
// history.listen을 사용해 URL 변경 이벤트(popstate)를 구독
// URL이 변경될 때 setState를 호출하여 컴포넌트의 상태를 업데이트
React.useLayoutEffect(() => history.listen(setState), [history, setState]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
future={future}
/>
);
}
// react-router/packages/router/history.ts
/**
* Browser history stores the location in regular URLs. This is the standard for
* most web apps, but it requires some configuration on the server to ensure you
* serve the same app at multiple URLs.
*
* @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory
*/
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
function createBrowserLocation(
window: Window,
globalHistory: Window["history"]
) {
let { pathname, search, hash } = window.location;
return createLocation(
"",
{ pathname, search, hash },
// state defaults to `null` because `window.history.state` does
(globalHistory.state && globalHistory.state.usr) || null,
(globalHistory.state && globalHistory.state.key) || "default"
);
}
function createBrowserHref(window: Window, to: To) {
return typeof to === "string" ? to : createPath(to);
}
return getUrlBasedHistory(
createBrowserLocation,
createBrowserHref,
null,
options
);
}
// react-router/packages/router/history.ts
function getUrlBasedHistory(
getLocation: (window: Window, globalHistory: Window["history"]) => Location,
createHref: (window: Window, to: To) => string,
validateLocation: ((location: Location, to: To) => void) | null,
options: UrlHistoryOptions = {}
): UrlHistory {
let { window = document.defaultView!, v5Compat = false } = options;
let globalHistory = window.history;
let action = Action.Pop;
let listener: Listener | null = null; // URL 변경 시 실행
let index = getIndex()!;
if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
}
function getIndex(): number {
let state = globalHistory.state || { idx: null };
return state.idx;
}
function handlePop() { // popstate 이벤트 발생 시 실행되는 이벤트 핸들러
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index;
index = nextIndex;
if (listener) { // listener함수 호출
listener({ action, location: history.location, delta });
}
}
function push(to: To, state?: any) {
action = Action.Push;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex() + 1; // 현재 히스토리 위치 + 1
let historyState = getHistoryState(location, index); // 새로운 히스토리 상태 객체 생성
let url = history.createHref(location);
try {
globalHistory.pushState(historyState, "", url); // 브라우저 히스토리 스택에 추가하며 URL 변경
} catch (error) {
// ...
}
if (v5Compat && listener) {
// URL 변경 후 등록된 listener 호출
listener({ action, location: history.location, delta: 1 });
}
}
function replace(to: To, state?: any) {
action = Action.Replace;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex();
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.replaceState(historyState, "", url);
if (v5Compat && listener) {
// URL 변경 후 등록된 listener 호출
listener({ action, location: history.location, delta: 0 });
}
}
// ...
let history: History = {
get action() {
return action;
},
get location() {
return getLocation(window, globalHistory);
},
listen(fn: Listener) { // 브라우저의 popstate 이벤트를 구독 관리하는 함수
if (listener) { // popstate이벤트가 중복 처리되는 문제 방지
throw new Error("A history only accepts one active listener");
}
window.addEventListener(PopStateEventType, handlePop); // 리스너 등록
listener = fn; // 사용자가 전달한 콜백함수를 저장하여 URL 변경 시 호출
return () => { // 구독 해지
window.removeEventListener(PopStateEventType, handlePop);
listener = null;
};
},
createHref(to) {
return createHref(window, to);
},
createURL,
encodeLocation(to) {
// Encode a Location the same way window.location would
let url = createURL(to);
return {
pathname: url.pathname,
search: url.search,
hash: url.hash,
};
},
push,
replace,
go(n) {
return globalHistory.go(n);
},
};
return history;
}
/**
* A container for a nested tree of `<Route>` elements that renders the branch
* that best matches the current location.
*
* @see https://reactrouter.com/components/routes
*/
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
// packages/react-router/lib/hooks.tsx
/**
* Returns the element of the route that matched the current location, prepared
* with the correct context to render the remainder of the route tree. Route
* elements in the tree must render an `<Outlet>` to render their child route's
* element.
*
* @see https://reactrouter.com/hooks/use-routes
*/
export function useRoutes(
routes: RouteObject[],
locationArg?: Partial<Location> | string
): React.ReactElement | null {
return useRoutesImpl(routes, locationArg);
}
// Internal implementation with accept optional param for RouterProvider usage
export function useRoutesImpl(
routes: RouteObject[],
locationArg?: Partial<Location> | string, // 현재 URL 정보
dataRouterState?: RemixRouter["state"],
future?: RemixRouter["future"]
): React.ReactElement | null {
// ...
let { navigator } = React.useContext(NavigationContext);
let { matches: parentMatches } = React.useContext(RouteContext);
let routeMatch = parentMatches[parentMatches.length - 1];
let parentParams = routeMatch ? routeMatch.params : {};
let parentPathname = routeMatch ? routeMatch.pathname : "/";
let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
let parentRoute = routeMatch && routeMatch.route;
// ...
let locationFromContext = useLocation(); // 라우터 컨텍스트에서 제공하는 URL
let location;
if (locationArg) {
let parsedLocationArg =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
invariant(
parentPathnameBase === "/" ||
parsedLocationArg.pathname?.startsWith(parentPathnameBase),
`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, ` +
`the location pathname must begin with the portion of the URL pathname that was ` +
`matched by all parent routes. The current pathname base is "${parentPathnameBase}" ` +
`but pathname "${parsedLocationArg.pathname}" was given in the \`location\` prop.`
);
location = parsedLocationArg;
} else {
location = locationFromContext;
}
let pathname = location.pathname || "/";
let remainingPathname = pathname; // 부모 라우트의 경로를 제외한 나머지 경로
if (parentPathnameBase !== "/") {
let parentSegments = parentPathnameBase.replace(/^\//, "").split("/");
let segments = pathname.replace(/^\//, "").split("/");
remainingPathname = "/" + segments.slice(parentSegments.length).join("/");
}
// routes 배열 안에서 URL 경로와 일치하는 Route들을 탐색
let matches = matchRoutes(routes, { pathname: remainingPathname });
// ...
let renderedMatches = _renderMatches(
matches &&
matches.map((match) =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([
parentPathnameBase,
// Re-encode pathnames that were decoded inside matchRoutes
navigator.encodeLocation
? navigator.encodeLocation(match.pathname).pathname
: match.pathname,
]),
pathnameBase:
match.pathnameBase === "/"
? parentPathnameBase
: joinPaths([
parentPathnameBase,
// Re-encode pathnames that were decoded inside matchRoutes
navigator.encodeLocation
? navigator.encodeLocation(match.pathnameBase).pathname
: match.pathnameBase,
]),
})
),
parentMatches,
dataRouterState,
future
);
if (locationArg && renderedMatches) {
return (
<LocationContext.Provider
value={{
location: {
pathname: "/",
search: "",
hash: "",
state: null,
key: "default",
...location,
},
navigationType: NavigationType.Pop,
}}
>
{renderedMatches}
</LocationContext.Provider>
);
}
return renderedMatches;
}
URL
정보를 컴포넌트의 state
로 관리합니다.popstate
이벤트가 발생하면 이를 구독하여 핸들러(여기서는 setState
)를 실행함으로써 라우터에 URL
변경을 알립니다.globalHistory.pushState
실행하여 URL
을 변경한 뒤에도 핸들러(setState
)를 실행하여 라우터에 URL
변경을 알립니다.요약하자면,
브라우저에서 뒤로 가기나 앞으로 가기 버튼을 클릭하여 발생하는 popstate
이벤트와
History API의 pushState
및 replaceState
호출을 구독하여,
URL
이 변경되면 이를 라우터에 알리고, URL
경로와 일치하는 컴포넌트를 찾아 렌더링합니다.