paint-brush
如何用 500 行代码编写自己的类型安全 React 路由器经过@olegwock
543 讀數
543 讀數

如何用 500 行代码编写自己的类型安全 React 路由器

经过 OlegWock39m2024/03/11
Read on Terminal Reader

太長; 讀書

因此,加入我这篇文章,我们将从头开始构建我们自己的类型安全路由器,以打开那个黑匣子并了解其内部工作原理。本文假设您已经了解 React 并且熟悉 TypeScript。
featured image - 如何用 500 行代码编写自己的类型安全 React 路由器
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。对于路由器来说,这将是诸如navigategetLocationsubscribe之类的函数。


然后,基于这些函数,创建useLocationuseCurrentRoute等钩子。然后这些函数和钩子被用作构建组件的基础。根据我的经验,这非常有效,并且可以创建一个易于扩展和多功能的库。


Router的API以defineRoutes函数开始。用户应该将所有原始路线的映射传递给该函数,该函数解析路线并返回具有相同形状的映射。所有面向开发人员的 API(例如 URL 生成或路由匹配)都将接受已解析的路由,而不是原始路由。


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


下一步是将解析后的路由传递给createRouter函数。这是我们路由器的核心。该函数将创建所有函数、挂钩和组件。这可能看起来不寻常,但这样的结构允许我们将接受的参数和 props 的类型定制为在routes中定义的一组特定路由,确保类型安全(并改进 DX)。


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


createRouter将返回可在应用程序中的任何位置使用的函数(命令式 API)、允许组件对位置更改做出反应的钩子以及三个组件: LinkRouteNotFound 。这足以涵盖大多数用例,您可以基于这些 API 构建自己的组件。

类型级编程既有趣又有利可图

我们首先解决我们的演讲中的类型安全部分。正如我之前提到的,使用类型安全路由器,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 ] >;


很整洁,是吧?顺便说一句,这就是解析原始路由中的参数所需的所有代码。在这个项目中,我们将使用hotscript库 - 它将帮助我们降低复杂性和类型级代码的数量。


但这不是必需的:如果您喜欢冒险,您可以尝试自己实现所有这些类型。您可以在Chicane 路由器中找到一些灵感,它在不使用第三方类型库的情况下实现了类似的功能。


如果您打算继续操作,我建议您使用您最喜欢的启动器(我使用Vite )创建一个新的 React 项目并开始在那里编码。这样,您就可以立即测试您的路由器。


请注意,像 Next.js 这样的框架提供了自己的路由,这可能会干扰该项目,请改用“vanilla”React。如果您有任何困难,可以在这里找到完整的代码。


首先安装第三方软件包:用于类型级实用程序的hotscript和用于从 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特定值一起使用时,也称为文字(例如,具体为42 ,而不是number ),它意味着“等于”。


由于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 中,有两种调用函数的方法: CallPipe 。当您需要调用单个函数时, 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 与路由进行匹配。


我们存储此信息以及用于构造此对象的原始原始路由和歧义级别(这只是路由中的一些参数)。

历史包装

每个路由器都必须在某处存储其状态。对于浏览器中的应用程序,这实际上可以归结为 4 个选项:内存中(在根组件内的状态变量中或在组件树外部的变量中)、History API 或 URL 的哈希部分。


如果您根本不想向用户显示您有路由,例如,如果您正在使用 React 编写游戏,那么内存中路由可能是您的选择。当你的 React 应用程序只是一个更大的应用程序中的一页,并且你不能随意更改 URL 时,将路由存储在哈希中会很方便。


但对于大多数情况,使用 History API 将是最佳选择。它与 SSR 兼容(其他选项则不兼容),遵循用户习惯的行为模式,并且看起来最干净。在这个项目中,我们也将使用它。但它有一个显着的缺陷:如果没有额外的包装器,它基本上无法使用。


使用 History AP,您可以订阅popstate事件,当 URL 发生变化时浏览器会通知您。但前提是更改是由用户发起的,例如单击后退按钮。如果 URL 更改是从代码发起的,您需要自己跟踪它。


我研究的大多数路由器都使用自己的包装器:react-router和chicane使用history NPM包,TanStack路由器有自己的实现,wouter没有完整的包装器,但仍然需要猴子修补history


那么,让我们实现我们自己的包装器。

 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/dashboard路由,而不是/app/:section


但这个算法并不是万无一失的。例如,路由/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); } }); };


为了执行导航,我们将公开navigatenavigateUnsafe函数,它们是history.pushhistory.replace的简单包装:

 // 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,则当前路由与过滤器不匹配时,挂钩将简单地返回 undefined,而不是抛出异常。


为了向 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 路由器、react-router 或 Next.js 路由器等生产就绪解决方案相媲美。我的意思是它只有大约 500 行代码,这并不多。但究竟缺少什么?


首先,服务器端渲染。如今,并非所有应用程序都需要 SSR,但所有路由库都应该支持它。将服务器端渲染添加到字符串中(不是流式 SSR!)将涉及创建不同的history ,该历史记录会将当前位置存储在内存中(因为服务器上没有历史记录 API)并将其插入createRouter函数。


我不知道实现流式 SSR 有多难,但我认为这与 Suspense 的支持密切相关。


其次,该路由器不能很好地与并发渲染集成。主要是因为我们使用了useSyncExternalStore ,因为它与非阻塞转换不兼容。它以这种方式工作以避免撕裂:一种情况,UI 的一部分使用特定的存储值呈现,但 UI 的其余部分使用不同的存储值呈现。


因此,路由器无法与 Suspense 很好地集成,因为对于每个暂停的位置更新,都会显示回退。顺便说一句,我在这篇文章中介绍了 React 中的并发性,在这篇文章中,我讨论了 Suspense、数据获取和use hook。


但即使有这些缺点,我希望您觉得这篇文章很有趣,并在此过程中构建了您自己的路由器:)