React Router 的原理是什么?怎样做路由守卫和懒加载?
React Router 监听浏览器地址变化,把当前 URL 和路由配置逐层匹配,再把匹配到的组件按嵌套关系渲染出来,子路由通过 Outlet 显示。本文以 React Router v8(8.5.1)文档为准:它有声明式、数据和框架三种模式;数据模式下可以在 middleware 或 loader 里 throw redirect 做路由守卫,用路由对象的 lazy 属性按需加载组件和 loader。
简答
用户在后台系统里从“订单”点到“设置”,浏览器地址从
/dashboard/orders变成/dashboard/settings,页面没有刷新,只有中间的内容换了。这就是 React Router 做的事:它监听地址变化,拿当前 URL 去和路由配置逐层匹配,再把匹配到的组件按父子关系渲染出来,子路由显示在父组件的<Outlet />位置。它不需要刷新页面,是因为底层用的是浏览器的 History API。点击
<Link>时,React Router 拦下默认跳转,改写地址,再重新匹配和渲染。地址本身怎么变、服务器为什么要配回退,是《hash 路由和 history 路由的原理是什么?服务器要怎样配置回退?》讲的内容。当前版本是 v8(写作时为 8.5.1),文档把用法分成声明式、数据和框架三种模式,后一种在前一种的基础上加功能。路由守卫在数据模式里推荐写在
middleware或loader里,没登录就throw redirect("/login"),这样组件根本不会渲染。懒加载用路由对象的lazy属性,进入这个路由时才去加载组件和 loader。要注意版本差别。v8 移除了
react-router-dom包,改从react-router导入,DOM 专用的 API 从react-router/dom导入;还要求 React 19.2.7 以上。老项目里常见的写法和 v6 文章不一定适用。
路由是怎样匹配和渲染的
先看声明式模式的写法,它最接近“路由就是一组组件”的直觉。下面这段配置要看父子路由是怎么拼成 URL 的:
import { BrowserRouter, Routes, Route, Outlet } from 'react-router';
function Dashboard() {
return (
<section>
<h1>后台</h1>
<Outlet />
</section>
);
}
createRoot(root).render(
<BrowserRouter>
<Routes>
<Route path="dashboard" element={<Dashboard />}>
<Route index element={<Home />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>,
);
为什么 settings 前面不用写 /dashboard?因为子路由的路径会自动带上父路由的路径,这段配置同时生成了 /dashboard 和 /dashboard/settings 两个地址。访问 /dashboard/settings 时,React Router 从外往里匹配,先命中 Dashboard,再命中 Settings,然后把 Settings 渲染进 Dashboard 里的 <Outlet />。index 路由是父路径本身被访问时显示的默认子页面。
所以“原理”可以拆成三步:监听地址变化,按配置逐层匹配,再按嵌套关系渲染。切换页面时外层的 Dashboard 保持不动,只有 Outlet 里的内容被替换。
三种模式有什么区别
React Router 文档把它称为多策略路由,三种模式的功能是叠加的。声明式模式用 <BrowserRouter>、<Routes>、<Route>,只负责匹配和渲染。数据模式用 createBrowserRouter 和 <RouterProvider>,在路由对象上加了 loader、action、middleware 等,数据加载和路由放在一起。框架模式在数据模式外面加了 Vite 插件,提供类型安全、自动代码分割以及 SPA、SSR、静态渲染等策略。
面试时讲守卫和懒加载,用数据模式最清楚,因为官方给这两件事提供了专门的位置。
路由守卫写在哪里
很多老写法是在组件里判断登录状态,没登录就渲染一个跳转。这样组件已经开始渲染,可能先闪一下页面或发出请求。数据模式下可以把判断提前到渲染之前。下面要看 authMiddleware 挂在哪一层:
import { createBrowserRouter, createContext, redirect } from 'react-router';
import { RouterProvider } from 'react-router/dom';
const userContext = createContext();
async function authMiddleware({ context }) {
const user = await getCurrentUser();
if (!user) {
throw redirect('/login');
}
context.set(userContext, user);
}
const router = createBrowserRouter([
{
path: '/',
Component: Root,
children: [
{
path: 'dashboard',
middleware: [authMiddleware],
loader: dashboardLoader,
Component: Dashboard,
},
{ path: 'login', Component: Login },
],
},
]);
为什么用 throw 而不是 return?因为抛出 redirect 会中断这次导航,后面的 loader 和组件都不会执行,直接转到 /login。middleware 挂在 dashboard 上,它下面的所有子路由都会先经过这道检查,不用每个页面各写一遍。检查通过后把用户存进 context,后面的 loader 可以直接读,不用再请求一次。
文档说明,客户端 middleware 在每次客户端导航时都会运行,不管这个路由有没有 loader。在 v7 里 middleware 需要打开 future.v8_middleware 开关,v8 起成为默认行为。如果项目还没用 middleware,也可以在 loader 开头做同样的判断并 throw redirect。
前端守卫只是体验层面的拦截,用户能改前端代码绕过它。真正的权限要由接口校验,这一点见《前端权限控制怎样设计路由、按钮和接口三层?》。
懒加载怎样写
路由对象的 lazy 属性返回一个 Promise,进入这个路由时才执行。下面要看组件和 loader 是怎样一起加载的:
createBrowserRouter([
{
path: '/app',
lazy: async () => {
const [Component, loader] = await Promise.all([
import('./app'),
import('./app-loader'),
]);
return { Component, loader };
},
},
]);
为什么不直接用 React.lazy?React.lazy 只能延迟加载组件,组件渲染出来以后才开始请求数据,形成“先下代码、再请求数据”的瀑布。lazy 属性能把 loader 也放进来,两份代码并行下载,下载完先跑 loader 再渲染。打包工具会把 import() 的模块拆成单独的文件,拆分的原理见《代码分割与懒加载适合哪些场景?》。
只用声明式模式、不用数据路由时,可以像普通 React 组件一样用 React.lazy 配合 <Suspense> 包住 element。Route 组件的 API 文档里虽然列出了 lazy 属性,但它和 loader 一样属于数据路由的功能,声明式模式下用不上。
常见追问
<Navigate> 组件和 useNavigate 怎么选?
官方文档说
<Navigate>是给类组件用的,因为类组件里不能调用 Hook,并建议优先用useNavigate。如果要在渲染前拦截,数据模式下更推荐在 middleware 或 loader 里throw redirect。
v6 项目升级到 v8 要注意什么?
官方路径是先升级到 v7 的最新版本,逐个打开
future.v8_*开关并修好问题,再升到 v8。v8 要求 Node 22.22 以上和 React 19.2.7 以上,并移除了react-router-dom,导入改成react-router和react-router/dom。
路由切换时页面状态为什么会保留或丢失?
父路由组件在子路由切换时不会卸载,所以它的状态会保留;
Outlet里换成另一个组件,旧组件就卸载了,状态也跟着没了。同一个组件只是参数变了,比如/user/1到/user/2,组件不会卸载,需要依赖参数重新取数据。