查看原文
其他

通过 React Router V6 源码,掌握前端路由

DYBOY 字节前端 ByteFE 2024-03-31

在 React 前端项目中,涉及到前端路由,想必大家都用过了 react-router-dom[1] 这个包,因为常用,所以有必要弄清楚其中的实现细节,对前端路由会有一个更深入的认识,另外也有助于提升工作效率。

此文不赘述使用方法,相关内容可以参考tutorial 官方的指导手册[2]

客户端里的路由模式

相较于“服务端路由”每次从服务端获取 CSS、JS、HTML 资源,客户端路由即是在客户端内自行控制,与服务端解耦,页面数据异步获取,浏览器无刷新切换页面,能为用户提供更快的页面切换体验,同时也为前端 SPA 应用发展提供了基础。

在浏览器 Web 环境里有 “Hash” 和 “History” 两种客户端路由模式。

Hash 模式

Hash 模式点击会跳转定位到指定 DOM 位置,同时触发 hashchange 事件,支持在浏览器中操作前进后退,其本质还是在同一文档中操作,Server 端无感知前端路由变化。

Hash 值在 window.location.hash 中存储,因此 Hash 变化时,同时可看到浏览器的 window.location.pathname 不变。

History 模式

Window 对象中提供了 history 实例,同时可以通过 history 暴露的 API 操作路由历史堆栈。

也就是说,我们可以通过控制 history 对象来控制页面的路由跳转,浏览器不会刷新,但浏览器里的 URL 会变更,SEO 更友好。

History 的 API 具体用法可参阅:History API - MDN[3]

React Router v6 的架构设计

react-router-dom 是一个封装浏览器客户端路由方案的优质工具模块,基于 React 的应用开发者,可借助其快速开发实现“客户端路由”,同时提升用户体验。

react-router-dom 作为一款优秀的前端模块,更新到了 V6 版本,全面拥抱 React hooks 功能设计,通过阅读其源码,了解其设计思想,相信可以给大家在 路由设计Hooks 实践上带来一些收获。

文件结构

在项目管理上采用了基于 Yarn 的 Monorepo 方案:

项目设计

react-router-dom 是浏览器环境中的桥接层,react-router-native 则是 Hybrid 开发的桥接层,其核心实现都在 react-router 模块中,层层递进。

此外,react-router-dom-v5-compat 是用于 react-router-dom v5 版本兼容迁移到 v6 版本的处理方案,但个人更建议是直接使用/切换到 v6 版本,直接冲 🚀!

因此项目设计可以简单分为两层:

架构设计

因为我们常用 History 模式的前端路由,也就是 BrowserRouter,与此同时,可以理解为 HashRouter 只是调用的 Browser API 不一样,因此下面仅分析了 BrowserRouter 模式下的架构和设计。

react-router-dom@6.4 版本开始支持数据 API,即根据路规则预先获取网络数据,数据预加载和路由做了绑定。

虽然该功能是可选,但个人感觉大部分业务应该还是会自行在页面内控制,或者采用自有的一套灵活的预加载方案,目前无法定量评估方案好坏,因此,我们阅读的源码版本为 react-router-dom@6.3.0

react-router-dom 整体的功能架构设计如下图:

虽然还有 StaticRouter、MemoryRouter、NativeRouter,但是掌握了 BrowserRouter,其它的应当也很容易理解。

核心实现 & 组件

react-router-dom 的实践案例

要使用 react-router-dom,如下例举了一个简单的实践案例。

顶层组件使用 BrowserRouter 包裹:

借助 useRoutes Hooks 快速创建路由组件,不再像之前那些写大量的组件,这里直接做了官方的封装和“路由配置”的定义:

BrowserRouter

BrowserRouter 确定了是 Web 运行环境,然后利用工具方法 createBrowserHistory 创建了对 Window.history API 的自定义封装实例。

同时向自定义 history 实例上注册监听器,当路由发生变化时,会回调执行 setState 方法更新 actionlocation 信息,然后触发组件的更新和重新渲染。

Router

Router 是一个提供 Location 和 Navigation 的 Context 组件,不会参与实际的 DOM 渲染,只是存储相关路由的规格化数据。

useRoutes

以前我们总要写大段的配置,以及自行编写路由组件,各个业务甚至都定义了自己的路由配置(树状结构),这种通用化的代码实际是可以做统一封装。

useRoutes 功能上等同于 <Routes>,但它使用 JS 对象而不是 <Route> 元素来定义路由,useRoutes 的返回值是可用于呈现路由树的有效 React 元素,或因无匹配路由返回 null

路由配置

因此 react-router-dom 参考相关 issue 定义了 RouteObject 类型:

/**
 * A route object represents a logical route, with (optionally) its child
 * routes organized in a tree-like structure.
 */

export interface RouteObject {
  caseSensitive?: boolean// 大小写敏感
  children?: RouteObject[]; // 嵌套路由
  element?: React.ReactNode; // 组件 or 页面
  index?: boolean// 是否作为 outlet 的默认索引/渲染
  path?: string// 匹配路径
}

路由 Context

export interface RouteMatch<ParamKey extends string = string> {
  /** URL 上的 query 参数 Key => value */
  params: Params<ParamKey>;
  pathname: string;
  pathnameBase: string;
  /** 用于匹配的路由对象 */
  route: RouteObject;
}

interface RouteContextObject {
  outlet: React.ReactElement | null;
  matches: RouteMatch[];
}

// 路由 Context
export const RouteContext = React.createContext<RouteContextObject>({
  outlet: null,
  matches: [],
});

路由匹配

借助 React Hooks 定义了 useRoutes 方法,功能上等同于 <Routes> 组件,useRoutes 能够依据“路由配置对象”和当前路由做匹配,然后按匹配规则渲染对应的“组件”。

该 hooks 文件位置:packages/react-router/lib/hooks.tsx

其中 matchRoutes() 函数返回一个对象数组,每个匹配的路由对应一个对象,是 React Router 的 核心算法 函数,不难理解。

渲染

_renderMatches() 函数将 matchRoutes() 的结果渲染为 React 元素:

这个函数为每个匹配组路由组(嵌套路由)建立 RouteContext,children 即为需要渲染的 React 元素。

其中比较巧妙的设计是利用 reduceRight() 方法,从右往左开始遍历,也就是从子到父的嵌套路由顺序,将前一项的 React 节点作为下一个 React 节点的 outlet

其中 outlet 是一个非常核心的概念,其用于嵌套路由场景,outlet 的渲染实现方式可参考下文中的 useOutlet() Hooks。

举例

一个嵌套路由配置如图:

在 HomePage 组件中使用了 <Outlet /> 组件,useRoutes 的执行过程如下:

第一阶段:获取 pathname

第二阶段:获取匹配的路由 & 组件

第三阶段:渲染

其他常用 Hooks

useLocation

这个 Hooks 比较简单,从 LocationContext 中获取 location 对象:

因此可以通过该 Hooks 感知 location 的变化。

useNavigate

useNavigate() Hooks 会返回如下两种函数调用方式:

interface NavigateOptions {
  replace?: boolean;
  state?: any;
}

interface NavigateFunction {
  (to: To, options?: NavigateOptions): void;
  (delta: number): void;
}

function useNavigate(): NavigateFunction

第一种是跳转指定路由,第二个参数可以设置 replace(是否使用 history.replace) 和 state(状态数据);

第二种是如果第一个参数是数字,等同于 window.history.go()[4] 方法。

useNavigate() 的实现主要是从 NavigationContextRouteContext 以及 LocationContext 中获取相关路由数据、Location 和 navigator 实例,然后根据不同的入参调用相应的执行跳转逻辑。

useParams

useParams Hooks 从当前 URL 返回与 <Route path> 匹配的动态参数的键/值对对象。子路由继承父路由的所有参数。

也就是说从 path 路径中按照规则获取对应的 Key/Value

useOutlet

该 Hooks 通过 RouteContext 获取当前路由下的 outlet,如果存在则返回由 OutletContext 包裹的子路由 React 组件。

其他常用组件

Link

类比网页中的 <a href="xxx" /> 标签。

其实现如下:

有个疑惑是,不知道 reloadDocument 这个参数的实际作用,顾名思义的角度就是是否重载文档。

但是从 <Link /> 组件内 handleClick() 方法的实现上看,其似乎只是一个是否调用默认 click 事件的开关,不过实际生产的时候,倒是没怎么用到。

NavLink

<NavLink /> 组件(导航链接)用于导航栏,例如管理后台的顶部菜单,或者是左侧的菜单。

其内部主要是对 classNamestyle 两个属性做了注入,如果传递的是函数,则会注入 isActive 变量,用于确定当前路由是否激活。当匹配到的路由激活时,默认是 className 会拼接 active 类名。

Navigate

<Navigate /> 组件功能是“路由跳转”,可以理解为,当渲染该组件时,则立即跳转到指定路由。

其内部实现依赖 useNavigate() Hooks,换句话说,这个组件只是跳转事件的一个 JSX 封装形式。

Outlet

<Outlet /> 组件用于嵌套路由场景,在父路由元素(组件)中使用 <Outlet /> 来显式表明它们的子路由元素的渲染位置,在子路由匹配时显示嵌套 UI。

父路由使用精准匹配的情况下,但子路由没有显式声明索引的话(RouteObject.index),将不会渲染任何内容。

/**
 * Renders the child route's element, if there is one.
 *
 * @see https://reactrouter.com/docs/en/v6/api#outlet
 */

export function Outlet(props: OutletProps): React.ReactElement | null {
  return useOutlet(props.context);
}

<Outlet /> 组件的实现是基于 useOutlet() Hooks。

Routes

Routes 组件内的实现还是使用了 useRoutes() Hooks,因此在生产实践中还是推荐大家用“配置化路由”方式,来实现渲染路由组件,能提升路由的可维护性。

总结

React Router 目前更新到了 6.6.x 版本,其中的数据预加载和路由绑定方案,确实也是一个不错的方案,但在实际生产过程中,想要快速实现“大一统”也确实会遇到各种问题,因此大家还是需要辩证看待,按需取舍。

此外 @remix-run/router 这个模块是对 History 和 Navigator 的封装,部分实现细节也是值得借鉴。

阅读源码可能确实比较枯燥,但是如果能够潜心阅读,仔细推敲每一个让你疑惑的问题点,并学习其精妙的设计与实现,相信能够对我们的编码技能有一定的促进作用。


点击上方关注 · 我们下期再见 


参考资料

[1]

react-router-dom: https://www.npmjs.com/package/react-router-dom

[2]

tutorial 官方的指导手册: https://reactrouter.com/en/main/start/tutorial

[3]

History API - MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/History_API

[4]

window.history.go(): https://developer.mozilla.org/en-US/docs/Web/API/History/go

[5]

React Router Docs: https://reactrouter.com/en/main


继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存