通过 React Router V6 源码,掌握前端路由
The following article is from 字节前端 ByteFE Author DYBOY
在 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
方法更新 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()[4] 方法。
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 的封装,部分实现细节也是值得借鉴。
阅读源码可能确实比较枯燥,但是如果能够潜心阅读,仔细推敲每一个让你疑惑的问题点,并学习其精妙的设计与实现,相信能够对我们的编码技能有一定的促进作用。
参考资料
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