Skip to content

Commit 2c11243

Browse files
react-router 2 렌더링
1 parent 1dfa087 commit 2c11243

4 files changed

Lines changed: 701 additions & 271 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
---
2+
title: "React-Router Deep Dive 2. 최신의 history 객체 보장하기"
3+
date: "2025-04-05T21:09:18.164Z"
4+
description: "react-router의 선언적 모드의 동작 방식을 살펴봅니다."
5+
category: "기술아티클"
6+
estimation: 15
7+
thumbnail: "./thumbnail.png"
8+
slug: "/react-router-deep-dive-2"
9+
---
10+
11+
앞으로 두개의 아티클을 통해 주소창의 주소를 직접 변경하거나, `history.push`와 같은 메서드를 사용해 브라우저의 주소를 변경하였을때 주소와 매치되는 컴포넌트가 렌더링되는 과정을 살펴보겠습니다. 이번 아티클에서는 우리가 사용하는 history 객체가 어떻게 최신임을 보장받을수 있는지 살펴볼것입니다.
12+
13+
분석에 사용할 코드는 아래와 같습니다.
14+
15+
```typescript
16+
import { BrowserRouter, Routes, Route } from "react-router"
17+
18+
const App = () => {
19+
return (
20+
<BrowserRouter>
21+
<Routes>
22+
<Route index element={<Home />} />
23+
<Route path="about" element={<About />} />
24+
{/* multi level path */}
25+
<Route path="dashboard" element={<Dashboard />}>
26+
<Route index element={<Summary />} />
27+
<Route path="settings" element={<Settings />} />
28+
</Route>
29+
</Routes>
30+
</BrowserRouter>
31+
)
32+
}
33+
```
34+
35+
## BroserRouter
36+
37+
가장 처음 실행하게 되는 컴포넌트는 `BroserRouter` 입니다. 이 컴포넌트는 이름에서 알 수 있듯이 브라우저를 위한 설정을 한뒤 `Router`컴포넌트를 반환하는 컴포넌트입니다.
38+
39+
```typescript
40+
export function BrowserRouter({
41+
basename,
42+
children,
43+
window,
44+
}: BrowserRouterProps) {
45+
let historyRef = React.useRef<BrowserHistory>()
46+
if (historyRef.current == null) {
47+
// createBrowserHistory를 이용해 history 생성
48+
historyRef.current = createBrowserHistory({ window, v5Compat: true })
49+
}
50+
let history = historyRef.current
51+
52+
let [state, setStateImpl] = React.useState({
53+
action: history.action,
54+
location: history.location,
55+
})
56+
57+
// React.startTransition으로 setState함수를 한번 래핑
58+
let setState = React.useCallback(
59+
(newState: { action: NavigationType; location: Location }) => {
60+
React.startTransition(() => setStateImpl(newState))
61+
},
62+
[setStateImpl]
63+
)
64+
65+
// history.listen 함수를 호출해 history 구독
66+
React.useLayoutEffect(() => history.listen(setState), [history, setState])
67+
68+
return (
69+
<Router
70+
basename={basename}
71+
children={children}
72+
location={state.location}
73+
navigationType={state.action}
74+
navigator={history}
75+
/>
76+
)
77+
}
78+
```
79+
80+
코드를 살펴보면 `createBrowserHistory`함수를 이용해 `history`객체를 생성한뒤 `setState`를 콜백함수로 넣어 구독하고 있습니다. 따라서`history`객체가 변경되면 컴포넌트가 리렌더링되어 변경된 `history`가 하위 컴포넌트에 반영되므로 우리가 `history`에서 꺼내어 사용하는 주소에 관한 정보들(pathname, hash, search등)은 항상 최신값을 유지할 수 있게 됩니다.
81+
82+
그렇다면 어떤 행위가 `history`객체를 변경하는 행위이고 어떤 과정을 거쳐 객체 변경이 컴포넌트에 통지될까요? 이를 알아보기 위해 `history`객체를 생성하는 `createBrowserHistory`함수를 살펴보겠습니다.
83+
84+
> `setState`를 사용할때, `useState`에서 바로 꺼내어 사용하지 않고, `startTransition`으로 한번 래핑하여 사용하고 있습니다. 이유는 간단한데, 유저의 입력이벤트를 막으면서 까지 이동한 페이지의 렌더링을 하게되면 유저경험을 해칠수 있기 때문입니다. 따라서 `startTransition`를 사용하여 이동한 페이지의 렌더링 우선순위를 낮춰주고 있습니다. 자세한 내용은 [링크](https://ko.react.dev/reference/react/startTransition)를 참고해보세요.
85+
86+
### createBrowserHistory
87+
88+
```typescript
89+
export function createBrowserHistory(
90+
options: BrowserHistoryOptions = {}
91+
): BrowserHistory {
92+
function createBrowserLocation(
93+
window: Window,
94+
globalHistory: Window["history"]
95+
) {
96+
let { pathname, search, hash } = window.location
97+
return createLocation(
98+
"",
99+
{ pathname, search, hash },
100+
// state defaults to `null` because `window.history.state` does
101+
(globalHistory.state && globalHistory.state.usr) || null,
102+
(globalHistory.state && globalHistory.state.key) || "default"
103+
)
104+
}
105+
106+
function createBrowserHref(window: Window, to: To) {
107+
return typeof to === "string" ? to : createPath(to)
108+
}
109+
110+
return getUrlBasedHistory(
111+
createBrowserLocation,
112+
createBrowserHref,
113+
null,
114+
options
115+
)
116+
}
117+
```
118+
119+
여기서 핵심은 `history`객체를 반환하는 `getUrlBasedHistory`함수입니다. 하지만 이 함수 내부를 분석하기전에 `createBrowserLocation`함수와 `createBrowserHref`함수를 간단히 살펴보겠습니다.
120+
121+
#### createBrowserLocation
122+
123+
함수명 그대로 브라우저용 location 객체를 만드는것입니다.
124+
125+
```typescript
126+
function createBrowserLocation(
127+
window: Window,
128+
globalHistory: Window["history"]
129+
) {
130+
let { pathname, search, hash } = window.location
131+
return createLocation(
132+
"",
133+
{ pathname, search, hash },
134+
(globalHistory.state && globalHistory.state.usr) || null,
135+
(globalHistory.state && globalHistory.state.key) || "default"
136+
)
137+
}
138+
139+
export function createLocation(
140+
current: string | Location,
141+
to: To,
142+
state: any = null,
143+
key?: string
144+
): Readonly<Location> {
145+
let location: Readonly<Location> = {
146+
pathname: typeof current === "string" ? current : current.pathname,
147+
search: "",
148+
hash: "",
149+
...(typeof to === "string" ? parsePath(to) : to),
150+
state,
151+
key: (to && (to as Location).key) || key || createKey(),
152+
}
153+
return location
154+
}
155+
```
156+
157+
`pathname`, `search`, `hash`등을 가진 `location` 객체를 생성합니다. 이때 `to`파라미터로 넘어온 객체는 실제 `window.location`에서 꺼내온 `pathname`, `search`, `hash`입니다.
158+
159+
#### createBrowserHref
160+
161+
함수명 그대로 브라우저용 href를 만드는것입니다. `pathname`, `search`, `hash`가 합쳐진 값을 반환합니다.
162+
163+
```typescript
164+
function createBrowserHref(window: Window, to: To) {
165+
return typeof to === "string" ? to : createPath(to)
166+
}
167+
168+
export function createPath({
169+
pathname = "/",
170+
search = "",
171+
hash = "",
172+
}: Partial<Path>) {
173+
if (search && search !== "?")
174+
pathname += search.charAt(0) === "?" ? search : "?" + search
175+
if (hash && hash !== "#")
176+
pathname += hash.charAt(0) === "#" ? hash : "#" + hash
177+
return pathname
178+
}
179+
```
180+
181+
`pathname` 뒤에 `search``hash`를 순서대로 붙여서 반환하게됩니다.
182+
183+
### getUrlBasedHistory
184+
185+
```typescript
186+
function getUrlBasedHistory(
187+
getLocation: (window: Window, globalHistory: Window["history"]) => Location,
188+
createHref: (window: Window, to: To) => string,
189+
validateLocation: ((location: Location, to: To) => void) | null,
190+
options: UrlHistoryOptions = {}
191+
): UrlHistory {
192+
let { window = document.defaultView!, v5Compat = false } = options
193+
let globalHistory = window.history
194+
let action = Action.Pop
195+
let listener: Listener | null = null
196+
197+
let index = getIndex()!
198+
if (index == null) {
199+
index = 0
200+
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "")
201+
}
202+
203+
function getIndex(): number {
204+
let state = globalHistory.state || { idx: null }
205+
return state.idx
206+
}
207+
208+
function handlePop() {
209+
action = Action.Pop
210+
let nextIndex = getIndex()
211+
let delta = nextIndex == null ? null : nextIndex - index
212+
index = nextIndex
213+
if (listener) {
214+
listener({ action, location: history.location, delta })
215+
}
216+
}
217+
218+
function push(to: To, state?: any) {
219+
action = Action.Push
220+
let location = createLocation(history.location, to, state)
221+
if (validateLocation) validateLocation(location, to)
222+
223+
index = getIndex() + 1
224+
let historyState = getHistoryState(location, index)
225+
let url = history.createHref(location)
226+
227+
globalHistory.pushState(historyState, "", url)
228+
229+
if (v5Compat && listener) {
230+
listener({ action, location: history.location, delta: 1 })
231+
}
232+
}
233+
234+
function replace(to: To, state?: any) {
235+
action = Action.Replace
236+
let location = createLocation(history.location, to, state)
237+
if (validateLocation) validateLocation(location, to)
238+
239+
index = getIndex()
240+
let historyState = getHistoryState(location, index)
241+
let url = history.createHref(location)
242+
globalHistory.replaceState(historyState, "", url)
243+
244+
if (v5Compat && listener) {
245+
listener({ action, location: history.location, delta: 0 })
246+
}
247+
}
248+
249+
function createURL(to: To): URL {
250+
let base =
251+
window.location.origin !== "null"
252+
? window.location.origin
253+
: window.location.href
254+
255+
let href = typeof to === "string" ? to : createPath(to)
256+
href = href.replace(/ $/, "%20")
257+
invariant(
258+
base,
259+
`No window.location.(origin|href) available to create URL for href: ${href}`
260+
)
261+
return new URL(href, base)
262+
}
263+
264+
let history: History = {
265+
get action() {
266+
return action
267+
},
268+
get location() {
269+
return getLocation(window, globalHistory)
270+
},
271+
listen(fn: Listener) {
272+
if (listener) {
273+
throw new Error("A history only accepts one active listener")
274+
}
275+
window.addEventListener(PopStateEventType, handlePop)
276+
listener = fn
277+
278+
return () => {
279+
window.removeEventListener(PopStateEventType, handlePop)
280+
listener = null
281+
}
282+
},
283+
createHref(to) {
284+
return createHref(window, to)
285+
},
286+
createURL,
287+
encodeLocation(to) {
288+
// Encode a Location the same way window.location would
289+
let url = createURL(to)
290+
return {
291+
pathname: url.pathname,
292+
search: url.search,
293+
hash: url.hash,
294+
}
295+
},
296+
push,
297+
replace,
298+
go(n) {
299+
return globalHistory.go(n)
300+
},
301+
}
302+
303+
return history
304+
}
305+
```
306+
307+
이 함수는 앞서 말씀드린대로 history객체를 반환하는 함수입니다. 반환하는 객체를 살펴보면 `listen`이라는 history객체를 구독할수 있도록 해주는 함수도 있고, 우리가 주소를 변경할때 사용하는 `push`, `replace`함수도 있습니다.
308+
309+
#### listen
310+
311+
```typescript
312+
function listen(fn: Listener) {
313+
// history의 리스너는 하나만 등록 가능함.
314+
if (listener) {
315+
throw new Error("A history only accepts one active listener")
316+
}
317+
window.addEventListener(PopStateEventType, handlePop)
318+
listener = fn
319+
320+
return () => {
321+
window.removeEventListener(PopStateEventType, handlePop)
322+
listener = null
323+
}
324+
}
325+
```
326+
327+
먼저 `listen` 함수를 살펴봅시다. 인자로 넣은 `listener`를 등록하는 역할 이외에도 `popState`이벤트를 구독하는 역할도 하고있습니다. `popState`이벤트는 브라우저의 뒤로가기 버튼을 누를때 트리거 되므로, 이를 통해 뒤로가기로 인한 주소 변경을 감지할수 있게 됩니다.
328+
329+
#### 주소 변경
330+
331+
뒤로가기 버튼을 눌러 실행되는 `handlePop`함수나, 주소를 변경하기위해 사용하는 `push`,`replace`함수를 살펴보면, 동작이 거의 유사함을 알 수 있습니다.
332+
333+
```typescript
334+
function handlePop() {
335+
action = Action.Pop
336+
let nextIndex = getIndex()
337+
let delta = nextIndex == null ? null : nextIndex - index
338+
index = nextIndex
339+
if (listener) {
340+
listener({ action, location: history.location, delta })
341+
}
342+
}
343+
344+
function push(to: To, state?: any) {
345+
action = Action.Push
346+
let location = createLocation(history.location, to, state)
347+
if (validateLocation) validateLocation(location, to)
348+
349+
index = getIndex() + 1
350+
let historyState = getHistoryState(location, index)
351+
let url = history.createHref(location)
352+
353+
globalHistory.pushState(historyState, "", url)
354+
355+
if (v5Compat && listener) {
356+
listener({ action, location: history.location, delta: 1 })
357+
}
358+
}
359+
360+
function replace(to: To, state?: any) {
361+
action = Action.Replace
362+
let location = createLocation(history.location, to, state)
363+
if (validateLocation) validateLocation(location, to)
364+
365+
index = getIndex()
366+
let historyState = getHistoryState(location, index)
367+
let url = history.createHref(location)
368+
globalHistory.replaceState(historyState, "", url)
369+
370+
if (v5Compat && listener) {
371+
listener({ action, location: history.location, delta: 0 })
372+
}
373+
}
374+
```
375+
376+
위 세가지 함수(handlePop, push, replace)는 공통적으로 다음 동작을 수행합니다.
377+
378+
1. 액션 타입 변경
379+
2. push 또는 replace는 pushState또는 replaceState함수를 호출하여 주소 변경
380+
3. index 변경
381+
4. action, location, delta 정보를 포함하여 listener함수 실행
382+
383+
이러한 과정을 통해 주소가 변경되었을때, React 컴포넌트가 주소에 관련된 정보와 함께 history객체의 변경사실을 통지받을 수 있음을 알 수 있습니다. 따라서 이후 살펴볼 컴포넌트나, 내부적으로 사용자가 작성한 컴포넌트에서 사용하는 history객체는 항상 최신임을 보장받을수 있게 됩니다.
384+
385+
## 마치며
386+
387+
이번 아티클에서는 React-router가 어떻게 최신 history 객체를 보장하는지 살펴보았습니다. 다음 아티클에서는 라우트에 맞는 컴포넌트를 렌더링하는 방법을 살펴보겠습니다.
388+
389+
## 참고자료
390+
391+
[startTransition](https://ko.react.dev/reference/react/startTransition)
File renamed without changes.

0 commit comments

Comments
 (0)