paint-brush
500줄로 나만의 Typesafe React Router를 작성하는 방법~에 의해@olegwock
549 판독값
549 판독값

500줄로 나만의 Typesafe React Router를 작성하는 방법

~에 의해 OlegWock39m2024/03/11
Read on Terminal Reader

너무 오래; 읽다

따라서 이 게시물에 저와 함께하여 블랙박스를 열고 내부 작동 방식을 이해하기 위해 처음부터 자체 유형 안전 라우터를 구축해 보겠습니다. 이 기사에서는 귀하가 이미 React를 알고 있고 TypeScript에 익숙하다고 가정합니다.
featured image - 500줄로 나만의 Typesafe React Router를 작성하는 방법
OlegWock HackerNoon profile picture
0-item

제가 약 6년 전 React를 배울 때, 제가 처음으로 접한 타사 라이브러리 중 하나는 React-Router였습니다. 즉, 라우팅은 현대 단일 페이지 애플리케이션의 핵심 측면 중 하나입니다. 나는 그것을 당연하게 여겼습니다.


나에게 React-Router는 마술 상자였습니다. 내부적으로 어떻게 작동하는지 전혀 몰랐습니다. 그래서 시간이 좀 지나서 자연스럽게 라우팅이 어떻게 이루어지는지 알게 됐을 때 좀 아쉬웠어요. 더 이상 마법은 필요 없어요 :(


하지만 "마법의 블랙박스"라는 개념을 뒤로한 것이 다행입니다. 정말 해로운 생각인 것 같아요. 모든 기술이 여러분과 저처럼 엔지니어들에 의해 만들어졌다는 사실을 이해하는 것은 저에게 많은 영감을 주며, 여러분에게도 같은 일이 일어나기를 바랍니다.


따라서 이 게시물에 저와 함께하여 블랙박스를 열고 내부 작동 방식을 이해하기 위해 처음부터 자체 유형 안전 라우터를 구축해 보겠습니다. 이 기사에서는 귀하가 이미 React를 알고 있고 TypeScript에 익숙하다고 가정합니다.

기능 및 제한사항

이 기사에서는 라우터의 어떤 기능을 다룰 것인지 간략하게 설명하겠습니다.


우리의 킬러 기능은 유형 안전성입니다. 즉, 경로를 미리 정의해야 하며 TypeScript는 URL 대신 말도 안되는 내용을 전달하지 않았는지 확인하거나 현재 경로에서 누락된 매개 변수를 가져오려고 시도합니다. 이를 위해서는 약간의 체조가 필요하지만 걱정하지 마십시오. 제가 안내해 드리겠습니다.


그 외에도 우리 라우터는 URL 탐색, 경로 일치, 경로 매개변수 구문 분석, 뒤로/앞으로 탐색, 탐색 차단 등 일반 라우터에서 기대할 수 있는 모든 것을 지원합니다.


이제 제한 사항에 대해 설명합니다. 우리 라우터는 브라우저에서만 작동하며(죄송합니다 React Native!) SRR을 지원하지 않습니다. SSR 지원은 상대적으로 쉬울 것입니다. 하지만 이 게시물은 이미 방대하기 때문에 다루지 않겠습니다.

술어

이제 우리가 만들고 싶은 것에 대한 비전이 생겼으니 구조와 용어에 대해 이야기해야 합니다. 유사한 개념이 꽤 많으므로 사전에 정의하는 것이 중요합니다.


우리 라이브러리에는 원시 경로와 구문 분석 경로의 두 가지 경로가 있습니다. 원시 경로/user/:id/info 또는 /login 과 같은 문자열입니다. 이는 URL용 템플릿입니다. 원시 경로에는 :id 와 같이 콜론으로 시작하는 섹션인 매개변수가 포함될 수 있습니다.


이 형식은 개발자가 사용하기 쉽지만 프로그램에는 그리 많지 않습니다. 우리는 이러한 경로를 보다 기계 친화적인 형식으로 변환하고 이를 구문 분석된 경로 라고 부릅니다.


하지만 사용자는 경로를 여는 것이 아니라 URL을 여는 것입니다. 그리고 URL은 리소스(우리의 경우 앱 내부 페이지)의 식별자입니다. http://example.app/user/42/info?anonymous=1#bio 처럼 보일 수 있습니다. 우리 라우터는 주로 URL의 두 번째 부분( /user/42/info?anonymous=1#bio ), 즉 path 라고 부르는 부분에 관심을 갖습니다.


경로는 경로 이름 ( /user/42/info ), 검색 매개변수 ( ?anonymous=1 ) 및 해시 ( #bio )로 구성됩니다. 해당 구성 요소를 별도의 필드로 저장하는 개체를 location 이라고 합니다.

API의 고급 개요

React 라이브러리를 구축할 때 저는 세 단계를 따르는 것을 좋아합니다. 우선, 명령형 API입니다. 해당 기능은 어느 곳에서나 호출할 수 있으며 일반적으로 React와 전혀 연결되지 않습니다. 라우터의 경우 navigate , getLocation 또는 subscribe 과 같은 기능이 됩니다.


그런 다음 해당 기능을 기반으로 useLocation 또는 useCurrentRoute 와 같은 후크가 만들어집니다. 그런 다음 해당 기능과 후크는 구성 요소를 구축하기 위한 기반으로 사용됩니다. 내 경험상 이것은 매우 잘 작동하며 쉽게 확장 가능하고 다용도의 라이브러리를 만들 수 있습니다.


라우터의 API는 defineRoutes 함수로 시작됩니다. 사용자는 모든 원시 경로의 맵을 함수에 전달해야 하며, 이 함수는 경로를 구문 분석하고 동일한 모양의 매핑을 반환합니다. URL 생성이나 경로 일치와 같은 모든 개발자용 API는 원시 경로가 아닌 구문 분석된 경로를 허용합니다.


 const routes = defineRoutes({ login: '/login', user: { me: '/user/me', byId: '/user/:id/info', } });


다음 단계는 구문 분석된 경로를 createRouter 함수에 전달하는 것입니다. 이것이 우리 라우터의 핵심입니다. 이 함수는 모든 함수, 후크 및 구성 요소를 생성합니다. 이는 이상해 보일 수 있지만 이러한 구조를 사용하면 허용되는 인수 및 소품의 유형을 routes 에 정의된 특정 경로 집합에 맞게 조정하여 유형 안전성을 보장하고 DX를 개선할 수 있습니다.


 const { Link, Route, useCurrentRoute, navigate, /* etc... */ } = createRouter(routes);


createRouter 앱의 어느 곳에서나 사용할 수 있는 함수(명령형 API), 구성 요소가 위치 변경에 반응할 수 있도록 하는 후크, 세 가지 구성 요소인 Link , RouteNotFound 반환합니다. 이는 대부분의 사용 사례를 처리하기에 충분하며 해당 API를 기반으로 자체 구성 요소를 구축할 수 있습니다.

재미와 이익을 위한 유형 수준 프로그래밍

우리는 프레젠테이션의 형식 안전 부분을 다루는 것부터 시작합니다. 앞서 언급했듯이 Typesafe 라우터를 사용하면 TypeScript는 다음과 같은 상황에 대해 미리 경고합니다.

 <Link href="/logim" />


또는 다음과 같습니다:

 const { userld } = useRoute(routes.user.byId);


그리고 이것들의 문제점을 즉시 확인할 수 없다면 확실히 유형 안전 라우터가 필요합니다 :)

TypeScript의 유형 시스템은 매우 강력합니다. 내 말은, 유형 수준 프로그래밍을 사용하면 체스 엔진 , 어드벤처 게임 , 심지어 SQL 데이터베이스 도 만들 수 있다는 뜻입니다.


두 문자열을 연결하는 등 값을 조작하는 '값 수준 프로그래밍'에 이미 익숙합니다.

 function concat(a, b) { return a + b; } concat('Hello, ', 'World!'); // 'Hello, World!'


하지만 유형으로도 할 수 있습니다!

 type Concat<A extends string, B extends string> = `${A}${B}`; type X = Concat<'Hello, ', 'World!'>; // ^? type X = "Hello, World!"


예, 일반 기능만큼 강력하지 않으며 모양도 다릅니다. 하지만 이를 통해 꽤 멋지고 유용한 작업을 수행할 수 있습니다.


유형 수준 프로그래밍을 사용하여 원시 경로에서 매개변수를 추출하고 개발자가 Link 또는 URL을 구성하는 함수에 잘못된 값을 전달하려고 시도하지 않는지 확인할 수 있는 새로운 유형을 구축할 것입니다.


TS를 사용한 유형 수준 프로그래밍은 금세 읽기 어렵게 될 수 있습니다. 다행스럽게도 이러한 복잡성을 모두 숨기고 다음과 같은 깔끔한 코드를 작성할 수 있는 프로젝트가 이미 여러 개 있습니다.


 export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


꽤 깔끔하죠? 그건 그렇고, 원시 경로에서 매개변수를 구문 분석하는 데 필요한 모든 코드입니다. 이 프로젝트에서는 핫스크립트 라이브러리를 사용합니다. 이는 복잡성과 유형 수준 코드의 양을 줄이는 데 도움이 됩니다.


하지만 필수는 아닙니다. 모험심이 있다면 이러한 모든 유형을 직접 구현해 볼 수 있습니다. 타사 유형 라이브러리를 사용하지 않고 유사한 기능을 구현하는 Chicane 라우터 에서 영감을 얻을 수 있습니다.


따라 가려면 좋아하는 스타터(저는 Vite 사용)를 사용하여 새 React 프로젝트를 만들고 거기에서 코딩을 시작하는 것이 좋습니다. 이렇게 하면 라우터를 즉시 테스트할 수 있습니다.


Next.js와 같은 프레임워크는 이 프로젝트를 방해할 수 있는 자체 라우팅을 제공하므로 대신 '바닐라' React를 사용합니다. 어려움이 있는 경우 여기에서 전체 코드를 찾을 수 있습니다.


타사 패키지 설치부터 시작하세요. 유형 수준 유틸리티용 핫스크립트 와 URL/원시 경로에서 매개변수 구문 분석을 위한 regexparam이 있습니다 .


 npm install hotscript regexparam


우리 유형의 첫 번째 건물 벽돌은 원시 경로입니다. 원시 경로는 / 로 시작해야 합니다. TS에서 어떻게 코딩하겠습니까? 이와 같이:

 export type RawRoute = `/${string}`;


쉽지요? 그러나 defineRoutes 단일 원시 경로를 허용하지 않고 중첩된 매핑을 허용하므로 이를 코딩해 보겠습니다. 다음과 같이 작성하고 싶을 수도 있습니다.

 export type RawRoutesMap = { [key: string]: RawRoute | RawRoutesMap };


이것은 작동합니다. 그러나 이 유형은 무한히 깊어질 수 있으며 TS는 경우에 따라 이를 계산하는 데 어려움을 겪습니다. TS의 삶을 더 쉽게 만들기 위해 허용되는 중첩 수준을 제한하겠습니다. 20개의 중첩 수준이면 모든 앱에 충분하며 TS는 이를 쉽게 처리할 수 있습니다.


하지만 재귀 유형의 깊이를 어떻게 제한합니까? 나는 이 SO 답변 에서 이 트릭을 배웠습니다. 다음은 요구 사항에 맞게 수정된 버전입니다.

 export type RecursiveMap<T, MaxDepth extends number> = { [key: string]: RecursiveMap_<T, MaxDepth, []>; }; type RecursiveMap_<T, MaxDepth extends number, Stack extends unknown[]> = MaxDepth extends Stack["length"] ? T : T | { [key: string]: RecursiveMap_<T, MaxDepth, [1, ...Stack]> };


이것은 첫 번째 복합 유형이므로 설명하겠습니다. 여기에는 두 가지 유형이 있습니다. RecursiveMap 진입점으로 작동하고 RecursiveMap_ 호출하여 추가 튜플 매개변수를 전달합니다. 이 튜플은 매핑의 깊이를 추적하는 데 사용되며, 호출할 때마다 이 배열에 하나의 요소를 추가합니다.


그리고 이 튜플의 길이가 MaxDepth 와 같아질 때까지 계속 호출합니다. TS에서 extends 리터럴이라고도 하는 특정 값(예: 구체적으로 number 가 아닌 42 )과 함께 사용되면 '같음'을 의미합니다.


그리고 MaxDepthStack["length"] 모두 특정 숫자이므로 이 코드는 MaxDepth === Stack["length"] 로 읽을 수 있습니다. 이 구조가 많이 사용되는 것을 볼 수 있습니다.


단순히 숫자를 추가하는 대신 튜플을 사용하는 이유는 무엇입니까? 글쎄, TypeScript에서 두 개의 숫자를 추가하는 것은 그리 쉬운 일이 아닙니다! 이를 위한 전체 라이브러리가 있고 Hotscript도 숫자를 추가할 수 있지만 (보이지 않더라도) 많은 코드가 필요하므로 과도하게 사용하면 TS 서버와 코드 편집기가 느려질 수 있습니다.


그래서 내 경험 법칙은 합리적으로 가능한 한 복잡한 유형을 피하는 것입니다.


이 유틸리티 유형을 사용하면 다음과 같이 간단하게 매핑을 정의할 수 있습니다.

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


이것이 원시 경로 유형에 대한 전부입니다. 대기열의 다음은 구문 분석된 경로입니다. 구문 분석된 경로는 몇 가지 추가 필드와 하나의 함수가 포함된 JavaScript 객체입니다. 그 모습은 다음과 같습니다.

 export type ParsedRoute<R extends RawRoute> = { keys: RouteParam<R>[]; build(...params: PathConstructorParams<R>): Path<R>; raw: R; ambiguousness: number, pattern: RegExp; };


keys 필드에서 이것을 풀어보겠습니다. 이는 단순히 이 경로에 필요한 매개변수 배열입니다. 수행 방법은 다음과 같습니다.

 import { Pipe, Strings, Tuples } from "hotscript"; export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


Hotscript에는 함수를 호출하는 두 가지 방법이 있습니다: Call 또는 Pipe . Call 단일 함수를 호출해야 할 때 유용하지만 우리의 경우에는 그 중 4개가 있습니다! Pipe 입력을 받아들이고 이를 제공된 튜플의 첫 번째 함수로 파이프합니다.


반환된 값은 두 번째 함수에 입력으로 전달됩니다. 우리의 경우 원시 경로 /user/:userId/posts/:postId 있으면 다음과 같이 변환됩니다.

 export type Beep = Pipe< "/user/:userId/posts/:postId", [ Strings.Split<"/">, // ["user", ":userId", "posts", ":postId"] Tuples.Filter<Strings.StartsWith<":">>, // [":userId", ":postId"] Tuples.Map<Strings.TrimLeft<":">>, // ["userId", "postId"] Tuples.ToUnion // "userId" | "postId" ] >;


보다? 이것이 유형 수준 프로그래밍의 마법입니다! 이제 해당 build 기능을 다루겠습니다. 이는 경로 매개변수(예: userIdpostId )와 선택적 검색 매개변수/해시를 허용하고 이를 하나의 경로로 결합합니다. PathConstructorParams 구현을 살펴보세요.

 // Allows us to also accept number and // any other type which can be converted into string export type StringLike = { toString: () => string }; export type SearchAndHashPathConstructorParams = { hash?: string, search?: string | { [key: string]: string, } }; export type RouteParamsMap< Route extends RawRoute, Val extends string | StringLike = string, > = { [key in RouteParam<Route>]: Val }; export type PathConstructorParams<R extends RawRoute> = | [RouteParamsMap<R, StringLike>] | [RouteParamsMap<R, StringLike>, SearchAndHashPathConstructorParams];


함수 매개변수는 배열로 정의됩니다(이는 나중에 ...확산됩니다.

build 함수), 여기서 첫 번째 요소는 RouteParamsMap 이고 두 번째 요소는 선택 사항인 SearchAndHashPathConstructorParams 입니다. build 값을 반환하는 것은 어떻습니까? 우리는 이미 그 경로를 확립했지만 TypeScript로 어떻게 설명합니까?


글쎄, 이것은 RouteParam 과 매우 유사하지만 좀 더 많은 유형의 체조가 필요합니다!

 import { Fn } from "hotscript"; interface ReplaceParam extends Fn { return: this["arg0"] extends `:${string}` ? string : this["arg0"]; } // Leading slashes will be removed by Split<"/">, so we need to // add them back after our manipulations type Pathname< Route extends RawRoute, > = `/${Pipe< Route, [ Strings.Split<"/">, Tuples.Map<ReplaceParam>, Tuples.Join<"/"> ] >}${Route extends `${string}/` ? '/' : ''}`; export type Path< Route extends RawRoute, > = Pathname<Route> | `${Pathname<Route>}?${string}` | `${Pathname<Route>}#${string}`;


여기서 하는 일은 경로를 세그먼트로 분할하고, 각 세그먼트에 매핑하고, 각각에 대해 사용자 지정 함수인 ReplaceParam 호출하는 것입니다. 현재 세그먼트가 매개변수인지 확인하여 string 로 대체하거나 세그먼트를 있는 그대로 반환합니다. ReplaceParam '함수'는 약간 이상해 보일 수 있지만 이것이 바로 Hotscript를 사용하여 사용자 정의 함수를 정의하는 방법입니다.


경로는 경로, 물음표 뒤에 오는 경로(검색 매개변수와 해시가 포함된 URL 포함) 또는 해시 기호(검색 매개변수는 없지만 해시가 포함된 URL 포함)로 구성된다는 점을 명시적으로 명시합니다.


또한 일치하는 경로, 즉 URL에서 캡처된 매개변수를 사용하여 구문 분석된 경로를 설명하는 유형이 필요합니다.

 // Interface (and not type) because we need to use `this` export interface RouteWithParams<R extends RawRoute> { route: ParsedRoute<R>, params: RouteParamsMap<R>, // TS can't properly infer type of route object with simple // check like currentRoute.route === routes.user.byId, so we // need our custom type guard matches: <T extends RawRoute>(route: ParsedRoute<T>) => this is RouteWithParams<T>, }


마지막 유형은 ParsedRoutesMap 입니다. RawRoutesMap 과 유사하지만 파싱된 경로에 대한 것입니다.

 // This accepts RawRoutesMap and transforms it into // mapping of parsed routes of same shape export type ParsedRoutesMap<RM extends RawRoutesMap> = { [Key in keyof RM]: RM[Key] extends RawRoute ? ParsedRoute<RM[Key]> : RM[Key] extends RawRoutesMap ? ParsedRoutesMap<RM[Key]> : never; };


그리고 그 메모에서 우리는 유형으로 마무리합니다. 몇 가지가 더 있지만 더 간단하므로 구현 과정에서 다루겠습니다. 유형 수준 프로그래밍을 더 시도하고 싶다면 유형 수준 Typescript를 확인하여 자세히 알아보고 유형 문제를 해결해 보세요(좋은 리소스 목록 도 있음).

경로 파서

마지막으로, 일반적인 값 수준 코딩으로 돌아왔습니다. defineRoutes 구현하여 본격적으로 시작해 보겠습니다.

 export const typedKeys = <const T extends {}> (obj: T) => { return Object.keys(obj) as Array<keyof T>; }; export const defineRoutes = <const T extends RawRoutesMap>(routesMap: T): ParsedRoutesMap<T> => { const entries = typedKeys(routesMap).map((key) => { const entry = routesMap[key]; if (typeof entry === 'string') { return [key, parseRoute(entry)] as const; } else { // Nested map return [key, defineRoutes(entry)] as const; } }); return Object.fromEntries(entries); };


여기에는 복잡한 것이 없습니다. parseRoute 함수에 대해 더 자세히 살펴보겠습니다.

 import { parse, inject, type RouteParams as RegexRouteParams } from "regexparam"; export class InvalidRoute extends Error { }; export class InvalidRouteParams extends Error { }; const parseRoute = <const R extends RawRoute>(route: R): ParsedRoute<R> => { if (!route.startsWith('/')) { throw new InvalidRoute('route should start with slash (/)') } const { keys, pattern } = parse(route); const hasRequiredParams = keys.length > 0; const parsedRoute: ParsedRoute<R> = { build(...args) { const params = ( hasRequiredParams ? args[0] : undefined ) as RouteParamsMap<R, StringLike> | undefined; const searchAndHash = ( hasRequiredParams ? args[1] : args[0] ) as SearchAndHashPathConstructorParams | undefined; if (hasRequiredParams) { if (!params) { throw new InvalidRouteParams( `Parameters for route ${route} weren't provided` ); } const missingKeys = keys.filter(k => !(k in params)); if (missingKeys.length) { throw new InvalidRouteParams( `Missing parameters for route ${route}: ${missingKeys.join(', ')}` ); } } else if (args.length > 1) { throw new InvalidRouteParams( `Route ${route} doesn't accept any parameters, received ${args[0]}` ); } let path = hasRequiredParams ? inject(route, params as RegexRouteParams<R>) : route; if (searchAndHash && searchAndHash.search) { if (typeof searchAndHash.search === 'string') { path += searchAndHash.search.startsWith('?') ? searchAndHash.search : '?' + searchAndHash.search; } else { path += '?' + new URLSearchParams(searchAndHash.search).toString(); } } if (searchAndHash && searchAndHash.hash) { path += searchAndHash.hash.startsWith('#') ? searchAndHash.hash : '#' + searchAndHash.hash; } return path as Path<R>; }, raw: route, keys: keys as RouteParam<R>[] || [], ambiguousness: keys.length, pattern: pattern, }; return parsedRoute; };


parseRoute 도 비록 눈에 띄게 길기는 하지만 매우 간단합니다. 경로를 구문 분석하고 매개변수를 추출하기 위해 regexparam 라이브러리를 사용합니다. 이를 통해 경로에 필요한 매개변수 배열을 얻을 수 있고 나중에 URL을 경로와 일치시키는 데 사용할 정규식을 생성할 수 있습니다.


우리는 이 객체를 구성하는 데 사용된 원래 원시 경로 및 모호성 수준(경로의 매개변수 수에 불과함)과 함께 이 정보를 저장합니다.

기록 래퍼

모든 라우터는 자신의 상태를 어딘가에 저장해야 합니다. 브라우저에 있는 앱의 경우 이는 실제로 메모리 내(루트 구성 요소 내부의 상태 변수 또는 구성 요소 트리 외부의 변수), 기록 API 또는 URL의 해시 부분이라는 4가지 옵션으로 요약됩니다.


예를 들어 React에서 게임을 코딩하는 경우 사용자에게 경로가 전혀 표시되지 않도록 하려면 메모리 내 라우팅을 선택할 수 있습니다. 경로를 해시에 저장하는 것은 React 앱이 더 큰 애플리케이션의 한 페이지일 뿐이고 원하는 대로 URL을 변경할 수 없는 경우 편리할 수 있습니다.


그러나 대부분의 경우 History API를 사용하는 것이 가장 좋습니다. SSR과 호환되고(다른 옵션은 그렇지 않음) 사용자에게 익숙한 동작 패턴을 따르며 가장 깔끔하게 보입니다. 이번 프로젝트에서도 우리는 그것을 사용할 것입니다. 그러나 한 가지 주목할만한 결함이 있습니다. 추가 래퍼 없이는 대부분 사용할 수 없습니다.


History AP를 사용하면 popstate 이벤트를 구독할 수 있으며, URL이 변경되면 브라우저에서 알려줍니다. 그러나 예를 들어 뒤로 버튼을 클릭하여 사용자가 변경을 시작한 경우에만 해당됩니다. 코드에서 URL 변경이 시작되면 이를 직접 추적해야 합니다.


내가 연구한 대부분의 라우터는 자체 래퍼를 사용합니다. React-router 및 Chicane 사용 기록 NPM 패키지, TanStack 라우터에는 자체 구현이 있고 wouter에는 본격적인 래퍼가 없지만 여전히 원숭이 패치 기록이 있습니다.


이제 자체 래퍼를 구현해 보겠습니다.

 export type HistoryLocation = Pick<Location, | 'origin' | 'href' | 'hash' | 'search' | 'pathname' >; export type NavigationBlocker = (isSoftNavigation: boolean) => boolean; export const createHistory = () => { const winHistory = window.history; const winLocation = window.location; const getLocation = (): HistoryLocation => { return { origin: winLocation.origin, href: winLocation.href, pathname: winLocation.pathname, search: winLocation.search, hash: winLocation.hash, }; }; /* Some magic code */ return /* something... */; };


우리가 사용할 두 가지 유형은 HistoryLocationNavigationBlocker 입니다. 첫 번째는 내장된 Location 유형( window.location 유형)의 약간 제한된 버전이고, 두 번째는 탐색 차단에 도달하면 다루겠습니다. 이 장의 모든 추가 코드는 createHistory 함수 내부에 들어갑니다.


기록 변경 사항에 대한 구독을 구현하는 것부터 시작해 보겠습니다. 이 프로젝트에서는 구독을 위해 React 스타일 함수를 사용할 것입니다. 콜백을 전달하는 subscribe 호출하면 구독을 취소할 때 호출해야 하는 또 다른 함수가 반환됩니다.

 const subscribers: Set<VoidFunction> = new Set(); const onChange = () => { subscribers.forEach(fn => { try { fn(); } catch (err) { console.error('Error while handling location update', err); } }) }; const subscribe = (listener: VoidFunction) => { subscribers.add(listener); return () => { subscribers.delete(listener); }; };


다음 단계는 프로그래밍 방식으로 수행된 변경을 포함하여 위치 변경에 반응하는 것입니다. 어떻게 하시겠습니까? 물론 원숭이 패치를 사용하면 됩니다. 조금 더러워 보일 수도 있지만(실제로도 그렇습니다) 불행히도 더 나은 옵션은 없습니다.

 const origPushState = winHistory.pushState.bind(winHistory); const origReplaceState = winHistory.replaceState.bind(winHistory); winHistory.pushState = (data, unused, url) => { // tryNavigate will be covered later tryNavigate(() => { origPushState(data, unused, url); onChange(); }); }; winHistory.replaceState = (data, unused, url) => { tryNavigate(() => { origReplaceState(data, unused, url); onChange(); }); }; // This event is emmited when user initiates navigation // or when calling history.go, history.back and history.forward window.addEventListener('popstate', onChange);


그리고 기록 구현에서 마지막으로 누락된 주요 부분은 탐색 차단입니다. 탐색 요청을 가로채고 조건부로 취소할 수 있는 기능입니다. 탐색 차단의 일반적인 예는 사용자가 양식의 진행 상황을 잃지 않도록 방지하는 것입니다.

 let blockers: NavigationBlocker[] = []; const beforeUnloadHandler = (event: Event) => { const blocked = blockers.some(blocker => blocker(false)); if (blocked) { event.preventDefault(); // @ts-ignore For older browsers event.returnValue = ''; return ''; } }; const tryNavigate = (cb: VoidFunction) => { const blocked = blockers.some(blocker => blocker(true)); if (blocked) return; cb(); }; const addBlocker = (blocker: NavigationBlocker) => { blockers.push(blocker); if (blockers.length === 1) { addEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } return () => { blockers = blockers.filter(b => b !== blocker); if (blockers.length === 0) { removeEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } } };


우리 구현에서 차단기는 이 탐색을 차단해야 하는지 여부를 나타내는 부울을 반환하는 함수입니다. 탐색 차단과 관련하여 탐색에는 두 가지 유형이 있으며 이를 다르게 처리해야 합니다.


한편으로는 사용자가 앱의 한 페이지에서 앱의 다른 페이지로 이동하는 소프트 탐색이 있습니다. 우리는 이를 완전히 제어하여 차단하거나, 사용자 지정 UI를 표시하거나(사용자의 의도를 확인하기 위해) 탐색을 차단한 후 작업을 수행할 수 있습니다.


반면에 사용자가 다른 사이트로 이동하거나 탭을 모두 닫는 경우인 하드 탐색이 있습니다. 보안 문제가 발생하므로 브라우저는 JavaScript가 이 탐색을 수행해야 하는지 여부를 결정하도록 허용할 수 없습니다. 그러나 브라우저에서는 JavaScript가 사용자에게 추가 확인 대화 상자를 표시할지 여부를 나타낼 수 있습니다.


소프트 탐색을 차단할 때 추가 UI(예: 사용자 정의 확인 대화 상자)를 표시하고 싶을 수도 있지만 하드 탐색의 경우 사용자가 페이지에 남아 있기로 결정하고 , 그 시점에서는 쓸모없고 혼란스럽습니다.


기록에서 탐색 차단 기능을 호출하면 소프트 탐색을 수행 중인지 나타내는 부울 값을 제공합니다.


그리고 이 모든 것을 포함하여 히스토리 객체를 반환하기만 하면 됩니다.

 return { subscribe, getLocation, push: winHistory.pushState, replace: winHistory.replaceState, go: (distance: number) => tryNavigate(() => winHistory.go.call(winHistory, distance)), back: () => tryNavigate(() => winHistory.back.call(winHistory)), forward: () => tryNavigate(() => winHistory.forward.call(winHistory)), addBlocker, };

1단계: 필수 API

우리는 마침내 여기에 있습니다. 명령형 API는 모든 추가 후크 및 구성 요소의 기반이 되며 개발자가 필요에 따라 사용자 정의 후크를 구축할 수 있게 해줍니다. 우선, 경로 맵을 평면 배열로 변환해야 합니다. 이렇게 하면 모든 경로를 반복하는 것이 훨씬 더 쉬울 것이며 경로 일치 부분 작업을 시작할 때 유용할 것입니다.


우리는 유형 유틸리티( ParsedRoutesMap ParsedRoute 의 통합으로 변환)와 함수( routesMap 구문 분석된 경로의 배열로 변환)가 모두 필요합니다. 다음 유형부터 시작해 보겠습니다.

 export type Values<T extends {}> = T[keyof T]; type FlattenRouteMap<T> = T extends ParsedRoute<any> | RawRoute ? T : T extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap ? AllRoutesFromMap<T> : never; export type AllRoutesFromMap< RM extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap > = FlattenRouteMap<Values<RM>>;


이를 두 가지 유형으로 분할하는 것이 불필요해 보일 수 있지만 매우 중요한 이유가 하나 있습니다. 자신을 호출하는 단일 유형으로 구현하면 TS는 유형이 지나치게 깊고 무한할 수 있다고 불평합니다. 그래서 우리는 이를 서로 호출하는 두 가지 유형으로 분할하여 이 문제를 해결합니다.


값 수준 함수의 경우 전달된 값이 구문 분석된 경로인지 확인하기 위해 유형 가드도 필요합니다.

 export const isParsedRoute = <T extends `/${string}` = `/${string}`>( route: any ): route is ParsedRoute<T> => { return !!route && typeof route === 'object' && typeof route.raw === 'string' && typeof route.build === 'function'; } export const getAllRoutes = <T extends RawRoutesMap>( routesMap: ParsedRoutesMap<T> ): ParsedRoute<AllRoutesFromMap<T>>[] => { type PossibleRawRoute = AllRoutesFromMap<T>; return typedKeys(routesMap).flatMap((k) => { const val = routesMap[k]; if (isParsedRoute<PossibleRawRoute>(val)) { return [val] as const; } // At this point we know that val isn't ParsedRoute, so it has to be map of routes // but TS can't infer that, so we help him a little by casting val to correct type return getAllRoutes(val as ParsedRoutesMap<T>); }); };


이제 라우터 구현을 시작해 보겠습니다. 기록과 마찬가지로 이 장과 다음 두 장에서는 달리 명시하지 않는 한 모든 코드가 createRouter 함수에 들어갑니다.

 import { useSyncExternalStore, ComponentType, useMemo, MouseEventHandler, ComponentProps, useEffect } from 'react'; export const createRouter = <T extends RawRoutesMap>(routesMap: ParsedRoutesMap<T>) => { // Type for any possible route from passed routesMap type RouteType = AllRoutesFromMap<T>; // Similar to above, but for matched routes, ie includes URL parameters type BindedRouteWithParams = RouteWithParams<RouteType>; // Some of our functions will accept route filter, // which can be single parsed route, array or object type RouteFilter<T extends RouteType> = | ParsedRoute<T> | ParsedRoute<T>[] | Record<string, ParsedRoute<T>>; const history = createHistory(); const routes = getAllRoutes(routesMap); };


우선, 현재 위치를 알려진 경로 중 하나와 일치시키도록 라우터를 가르쳐 보겠습니다. 몇 가지 유틸리티는 createRouter 함수 내부가 아닌 전역 범위 또는 별도의 파일로 이동할 수 있습니다.

 export const filterOutFalsy = <T>(obj: T[]): Exclude<T, undefined>[] => { return obj.filter(Boolean) as Exclude<T, undefined>[]; }; export class RouteMatchingConflict extends Error { }; // This will be used later export class RouteMismatch extends Error { };


그리고 이 코드는 createRouter 함수에 들어갑니다.

 const extractRouteParams = <T extends RawRoute>( pathname: string, parsedRoute: ParsedRoute<T> ) => { const match = parsedRoute.pattern.exec(pathname); if (!match) return undefined; // Extract all route parameters from match array // and construct object from them return Object.fromEntries(parsedRoute.keys.map((key, index) => { return [key, match[index + 1]]; })) as RouteParamsMap<T>; }; const findMatchingRoute = ( location: HistoryLocation ): BindedRouteWithParams | undefined => { const matchingRoutes = filterOutFalsy(routes.map(route => { const params = extractRouteParams<RawRoute>( location.pathname, route ); if (!params) return undefined; return { route, params, matches<T extends RawRoute>(r: ParsedRoute<T>) { return route === r; }, }; })); if (matchingRoutes.length === 0) return undefined; if (matchingRoutes.length === 1) return matchingRoutes[0]; // At this point we have multiple matching routes :/ // Gotta decide which one we prefer let lowestAmbiguousnessLevel = Infinity; let lowestAmbiguousnessMatches: BindedRouteWithParams[] = []; matchingRoutes.forEach((match) => { if (match.route.ambiguousness === lowestAmbiguousnessLevel) { lowestAmbiguousnessMatches.push(match); } else if (match.route.ambiguousness < lowestAmbiguousnessLevel) { lowestAmbiguousnessLevel = match.route.ambiguousness; lowestAmbiguousnessMatches = [match]; } }); if (lowestAmbiguousnessMatches.length !== 1) { throw new RouteMatchingConflict( `Multiple routes with same ambiguousness level matched pathname ${location.pathname}: ${lowestAmbiguousnessMatches.map(m => m.route.raw).join(', ')}` ); } return lowestAmbiguousnessMatches[0]; }; let currentRoute = findMatchingRoute(history.getLocation()); // This function will be later returned from createRouter function const getCurrentRoute = () => currentRoute;


여기에서는 알려진 모든 경로를 살펴보고 각 경로를 현재 위치와 일치시키려고 노력합니다. 경로의 정규식이 URL과 일치하면 URL에서 경로 매개변수를 가져오고, 그렇지 않으면 null 얻습니다. 일치하는 각 경로에 대해 RouteWithParams 객체를 생성하고 이를 배열에 저장합니다. 이제 일치하는 경로가 0개 또는 1개 있으면 모든 것이 간단합니다.


그러나 현재 위치와 일치하는 경로가 두 개 이상인 경우 우선 순위가 더 높은 경로를 결정해야 합니다. 이를 해결하기 위해 ambiguousness 필드를 사용합니다. 기억하실 수 있듯이 이 경로에는 여러 매개변수가 있으며 ambiguousness 가장 낮은 경로가 우선순위를 갖습니다.


예를 들어 /app/dashboard/app/:section 경로가 두 개 있는 경우 http://example.com/app/dashboard 위치는 두 경로 모두와 일치합니다. 하지만 이 URL이 / /app/:section /app/dashboard 경로에 해당해야 한다는 것은 매우 분명합니다.


하지만 이 알고리즘은 완벽하지는 않습니다. 예를 들어 /app/:user/settings/:section/app/dashboard/:section/:region 는 모두 URL http://example.com/app/dashboard/settings/asia 와 일치합니다. 그리고 둘의 모호성 수준이 동일하기 때문에 라우터는 어느 항목의 우선순위를 결정할 수 없습니다.


이제 위치 변경에 반응하고 currentRoute 변수를 업데이트하기 위해 이 코드를 함께 붙여야 합니다.

 const areRoutesEqual = <A extends RawRoute, B extends RawRoute>( a: RouteWithParams<A> | undefined, b: RouteWithParams<B> | undefined ): boolean => { if (!a && !b) return true; // Both are undefined if ((!a && b) || (a && !b)) return false; // Only one is undefined if (!a!.matches(b!.route)) return false; // Different routes // Same routes, but maybe parameters are different? const allParamsMatch = a.route.keys.every(key => a.params[key] === b!.params[key]); return allParamsMatch; }; history.subscribe(() => { const newRoute = findMatchingRoute(history.getLocation()); if (!areRoutesEqual(newRoute, currentRoute)) { currentRoute = newRoute; notifyRouteChange(); // Will be covered later } });


이제 우리 라우터는 위치 변경에 반응하고 사용자는 항상 현재 경로를 얻을 수 있습니다. 하지만 경로 변경 사항을 구독하는 기능이 없으면 그다지 유용하지 않으므로 이를 추가해 보겠습니다. 접근 방식은 히스토리 래퍼에서 사용한 것과 매우 유사합니다.

 const subscribers: Set<VoidFunction> = new Set(); const subscribe = (cb: VoidFunction) => { subscribers.add(cb); return () => void subscribers.delete(cb); }; const notifyRouteChange = () => { subscribers.forEach(cb => { try { cb(); } catch (err) { console.error('Error in route change subscriber', err); } }); };


탐색을 수행하기 위해 우리는 history.pushhistory.replace 에 대한 간단한 래퍼인 navigatenavigateUnsafe 함수를 노출합니다.

 // This function accepts any string path (no type-safety) const navigateUnsafe = ( path: string, { action = 'push' }: { action?: 'push' | 'replace' } = {} ) => { history[action]({}, '', path) }; // And this function accepts only paths that correspond to one of routes const navigate = ( path: Path<RouteType>, options: { action?: 'push' | 'replace' } = {} ) => { navigateUnsafe(path, options); };


자, 이제 진짜 라우터입니다! 매우 기본적이지만 그럼에도 불구하고 작동합니다. 아직 구현해야 할 몇 가지 후크와 구성 요소가 있지만 여기서부터는 훨씬 쉬워집니다.

2단계: 후크

후크의 경우 현재 위치와 현재 경로를 반환하는 간단한 후크부터 시작할 수 있습니다. 그 자체로는 매우 쉽지만 useSyncExternalStore는 이를 한 줄로 바꿔줍니다. 이전에 명령형 API를 설계한 방식을 통해 이러한 후크에 대한 코드를 대폭 줄일 수 있었습니다.

 const useLocation = () => { return useSyncExternalStore(history.subscribe, history.getLocation); }; const useCurrentRoute = () => { return useSyncExternalStore(subscribe, getCurrentRoute); };


특정 경로/경로 집합에서만 렌더링되어야 하는 구성 요소를 코딩할 때 useCurrentRoute 사용하여 현재 경로를 가져오고 기준과 일치하는지 확인한 다음 해당 매개 변수를 사용하거나 오류를 발생시킬 수 있습니다.


그러나 이는 매우 일반적인 작업이므로 사용자가 이에 대한 후크를 직접 작성하도록 하는 것은 범죄가 될 수 있습니다. 라우터는 이를 기본적으로 제공해야 합니다.

 function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: true): RouteWithParams<T>; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict: false): RouteWithParams<T> | undefined; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: boolean): RouteWithParams<T> | undefined { const currentRoute = useCurrentRoute(); const normalizedFilter = Array.isArray(filter) ? filter : isParsedRoute(filter) ? [filter] : Object.values(filter); const isMatching = !!currentRoute && normalizedFilter.some(route => currentRoute.matches(route)); if (isMatching) return currentRoute as RouteWithParams<T>; else { if (strict === false) return undefined; throw new RouteMismatch( `Current route doesn't match provided filter(s)` ); } }


이 후크에는 엄격한 버전과 완화된 버전의 두 가지 버전이 있습니다. 사용자가 두 번째 매개변수로 true 전달하는 경우(또는 true 가 기본값이므로 아무것도 전달하지 않는 경우) 현재 경로가 제공된 필터 중 하나와 일치하지 않으면 이 후크는 오류를 발생시킵니다.


이렇게 하면 후크가 일치하는 경로를 반환할지 아니면 전혀 반환하지 않을지 확인할 수 있습니다. 두 번째 매개변수가 false인 경우, 예외를 발생시키는 대신 현재 경로가 필터와 일치하지 않으면 후크는 단순히 정의되지 않은 값을 반환합니다.


이 동작을 TypeScript에 설명하기 위해 함수 오버로딩 이라는 기능을 사용합니다. 이를 통해 다양한 유형의 여러 함수 정의를 정의할 수 있으며 TypeScript는 사용자가 그러한 함수를 호출할 때 사용할 하나를 자동으로 선택합니다.


경로 매개변수 외에도 일부 데이터가 검색 매개변수로 전달될 수 있으므로 이를 문자열에서 매핑으로 구문 분석하는 후크를 추가하겠습니다. 이를 위해 내장 브라우저 API URLSearchParams 를 사용합니다.

 const useSearchParams = () => { const location = useLocation(); return useMemo(() => { return Object.fromEntries( (new URLSearchParams(location.search)).entries() ); }, [location.search]); };


이 섹션의 마지막 후크는 매우 간단한 useNavigationBlocker 입니다. 이 후크는 콜백을 수락하고 history.addBlocker 에 대한 호출을 효과로 래핑하여 차단기가 변경되면 다시 연결합니다.

 const useNavigationBlocker = (cb: NavigationBlocker) => { useEffect(() => { return history.addBlocker(cb); }, [cb]); };


이제 구성 요소를 살펴보겠습니다.

3단계: 구성 요소

라우팅 라이브러리를 언급할 때 가장 먼저 떠오르는 구성 요소는 무엇입니까? 나는 그것이 Route 이거나 적어도 비슷한 것일 것이라고 확신합니다. 이전에 본 것처럼 우리 후크는 모든 무거운 작업을 수행하는 잘 설계된 명령형 API로 인해 매우 간단했습니다.


구성요소도 마찬가지입니다. 사용자는 몇 줄의 코드로 쉽게 구현할 수 있습니다. 하지만 우리는 심각한 라우팅 라이브러리이므로 상자에 배터리를 포함시키자. :)

 type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> }; const Route = ({ component: Component, match }: RouteProps) => { const matchedRoute = useRoute(match, false); if (!matchedRoute) return null; return (<Component />); };


글쎄요, 그건 쉬웠어요! NotFound 구성요소를 어떻게 구현하는지 추측하고 싶으신가요? :)

 type NotFoundProps = { component: ComponentType }; const NotFound = ({ component: Component }: NotFoundProps) => { const currentRoute = useCurrentRoute(); if (currentRoute) return null; return (<Component />); };


그리고 라우터에 필요한 마지막 구성 요소는 Link 인데, 이는 좀 더 까다롭습니다. <a href="/app/dashboard" /> 는 항상 하드 탐색을 시작하고 유형 안전성을 제공하지 않으므로 사용할 수 없습니다. 따라서 다음 문제를 해결해 보겠습니다.

 type LinkProps = Omit<ComponentProps<"a">, 'href'> & ( // Our link accepts either type-strict href // or relaxed unsafeHref { href: Path<RouteType>, unsafeHref?: undefined } | { href?: undefined, unsafeHref: string } ) & { action?: 'push' | 'replace' }; const Link = ({ action = 'push', onClick, href, unsafeHref, ...props }: LinkProps) => { const hrefToUse = (href ?? unsafeHref)!; const targetsCurrentTab = props.target !== '_blank'; const localOnClick: MouseEventHandler<HTMLAnchorElement> = (event) => { if (onClick) { onClick(event); if (event.isDefaultPrevented()) { // User-defined click handler cacnelled navigation, we should exit too return; } } const inNewTab = !targetsCurrentTab || event.ctrlKey || event.shiftKey || event.metaKey || event.button === 1; if (!isExternal && !inNewTab) { event.preventDefault(); navigateUnsafe(hrefToUse, { action }); } }; const isExternal = useMemo(() => { if (!hrefToUse) return false; return new URL(hrefToUse, window.location.href).origin !== location.origin; }, [hrefToUse]); return <a {...props} href={hrefToUse} onClick={localOnClick} /> };


navigate 기능과 유사하게 Link 구성 요소는 전달된 URL을 유형 확인하지만 임의의 문자열 URL(탈출 해치 또는 외부 링크용)을 제공할 수도 있습니다. <a> 의 동작을 재정의하려면 자체 onClick 리스너를 연결하고 그 안에 원래 onClick ( Link 구성 요소에 전달됨)을 호출해야 합니다.


그런 다음 개발자가 탐색을 이미 중단하지 않았는지 확인합니다(만약 중단된 경우 이벤트를 무시해야 합니다). 모든 것이 정상이면 링크가 외부 링크가 아닌지, 현재 탭에서 열려야 하는지 확인합니다. 그런 다음에만 내장된 하드 탐색을 취소하고 대신 자체 navigateUnsafe 함수를 호출할 수 있습니다.


이제 createRouter 함수에서 모든 함수, 후크 및 구성 요소(기록에서 직접 다시 내보낸 몇 가지 함수와 함께)를 반환하면 됩니다.

 return { // Directly re-exported from history go: history.go, back: history.back, forward: history.forward, addBlocker: history.addBlocker, getLocation: history.getLocation, subscribeToLocation: history.subscribe, // Imperative API subscribe, getCurrentRoute, navigate, navigateUnsafe, // Hooks useLocation, useCurrentRoute, useRoute, useSearchParams, useNavigationBlocker, // Components Link, Route, NotFound, };


이것으로 라우터가 완성되었습니다. 이제 우리가 방금 만든 아주 작은 라우터를 선보일 수 있는 아주 작은 앱을 만들 수 있습니다! 그런데 이 라우터에 대한 전체 코드(예제 앱의 코드 포함)는 여기에서 찾을 수 있습니다.

퍼즐 조각 맞추기

그렇다면 이 모든 코드는 어떻게 하나로 묶일까요? 내가 직접 그렇게 말하면 아주 깔끔하게! 메모 작성 앱을 만들고 있다고 상상해 보세요. 먼저 다음과 같이 경로를 정의하고 라우터를 생성하는 것부터 시작합니다.

 export const routes = defineRoutes({ // Yep, this will be very minimal app root: '/', newNote: '/new', note: '/note/:noteId', }); const router = createRouter(routes); // Export functions and component you will be using export const { navigate, Link, Route, NotFound, useRoute, useNavigationBlocker } = router;


그런 다음 정의한 경로를 페이지 구성 요소와 연결합니다.

 function App() { return ( <div className="app"> <div className="links"> {/* This is how you build URL for Link */} <Link href={routes.root.build({})}>View all notes</Link> <Link href={routes.newNote.build({})}>Create new</Link> </div> <Route match={routes.root} component={NotesListPage} /> <Route match={routes.newNote} component={NewNotePage} /> <Route match={routes.note} component={NoteDetailsPage} /> <NotFound component={NotFoundPage} /> </div> ) }


NoteDetailsPage 에서는 URL에서 노트 ID를 가져와야 하므로 useRoute 후크를 사용합니다.

 export const NoteDetailsPage = () => { const { getNote } = useNotes(); const { params } = useRoute(routes.note); const note = getNote(params.noteId); return note ? (<> <h1>{note.title}</h1> <div>{note.text}</div> </>) : (<h1>Not found</h1>); };


그리고 새 메모를 작성할 때 사용자가 메모를 저장하지 않고 다른 곳으로 이동할 경우 사용자의 의도를 확인하고 싶을 것입니다.

 export const NewNotePage = () => { const saveNote = () => { isSubmittingNoteRef.current = true; const note = createNote(title, text); // And this is programmatic redirect navigate(routes.note.build({ noteId: note.id })); isSubmittingNoteRef.current = false; }; const [title, setTitle] = useState(''); const [text, setText] = useState(''); const { createNote } = useNotes(); const isSubmittingNoteRef = useRef(false); useNavigationBlocker((isSoftNavigation) => { const dirty = title !== '' || text !== ''; if (!dirty || isSubmittingNoteRef.current) return false; if (isSoftNavigation) { const confirmation = confirm('Do you want to leave?'); return !confirmation; } else { return true; } }); return <> <h1>New note</h1> <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <textarea placeholder="Text" value={text} onChange={e => setText(e.target.value)} /> <button onClick={saveNote}>Save</button> </>; };

가능한 개선

우리 라우터는 실제로 라우팅하지만 TanStack 라우터, 반응 라우터 또는 Next.js 라우터와 같은 프로덕션 지원 솔루션에는 적합하지 않습니다. 제 말은 단지 ~500줄의 코드라는 뜻입니다. 그다지 많지는 않습니다. 그런데 정확히 무엇이 빠졌나요?


우선, 서버사이드 렌더링입니다. 오늘날 모든 앱에 SSR이 필요한 것은 아니지만 모든 라우팅 라이브러리가 이를 지원할 것으로 예상됩니다. 서버 측 렌더링을 문자열(SSR 스트리밍이 아님!)에 추가하려면 현재 위치를 메모리에 저장하고(서버에 History API가 없기 때문에) 이를 createRouter 함수에 연결하는 다른 history 생성해야 합니다.


스트리밍 SSR을 구현하는 것이 얼마나 어려울지는 모르겠지만, Suspense 지원과 긴밀하게 연결될 것으로 예상됩니다.


둘째, 이 라우터는 동시 렌더링과 잘 통합되지 않습니다. 주로 useSyncExternalStore 사용하기 때문에 비차단 전환 과 호환되지 않습니다. 찢어짐을 방지하기 위해 이런 방식으로 작동합니다. 즉, UI의 일부가 특정 저장소 값으로 렌더링되었지만 UI의 나머지 부분은 다른 값으로 렌더링되는 상황입니다.


이 때문에 라우터는 일시 중단된 모든 위치 업데이트에 대해 폴백이 표시되므로 Suspense와 잘 통합되지 않습니다. 그건 그렇고, 이번 에서는 React의 동시성에 대해 다루었고, 이번 글 에서는 Suspense, 데이터 가져오기 및 Hook use 에 대해 이야기합니다.


하지만 이러한 단점에도 불구하고 이 기사가 흥미로웠기를 바라며 그 과정에서 자신만의 라우터를 구축하시기 바랍니다. :)