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

3,503 阅读8分钟

作者:DYBOY

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

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

客户端里的路由模式

相较于“服务端路由”每次从服务端获取 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

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 方法更新 action 和 location 信息,然后触发组件的更新和重新渲染。

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() 方法。

useNavigate() 的实现主要是从 NavigationContext、RouteContext 以及 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 /> 组件(导航链接)用于导航栏,例如管理后台的顶部菜单,或者是左侧的菜单。

其内部主要是对 className 和 style 两个属性做了注入,如果传递的是函数,则会注入 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 的封装,部分实现细节也是值得借鉴。

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

参考资料


尾部关注.gif

扫码关注公众号 👆 追更不迷路