React Router 客户端路由实战:为 React 应用构建多页面导航
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
本文是 The Odin Project 开源全栈课程(react_router.md)的技术指南。它系统讲解如何在 React 应用中引入 React Router 实现客户端路由:从安装接入、Link导航、嵌套路由与Outlet、动态段(URL 参数)、404 兜底页,到受保护路由、Outlet上下文传值与路由组件测试。学完本文,你将能独立为一个 SPA 项目搭建完整的多页面导航体系,并正确编写、测试和部署带路由的 React 应用。
客户端路由:为什么需要 React Router
课程进行到这里,我们构建的都是单页应用(SPA)。但对于任何稍具规模的应用,页面不止一个。幸运的是,浏览器允许客户端 JavaScript 通过History API接管用户的导航行为,而 React Router 正是把这一底层能力封装成 React 组件与 Hook 的成熟路由库。
什么是客户端路由
客户端路由(Client-side routing)是指由 JavaScript 承担应用路由处理职责的路由方式。它让 SPA 在用户导航时无需刷新页面:当用户点击导航栏元素时,URL 发生变化,页面视图在客户端内被相应地替换。
原文用一个"烤鸡"的比喻来说明传统多页应用(MPA)与客户端路由的差别:
- MPA 像传统烤箱:访问任何 URL(设置烤箱)→ 等待加载(烹饪)→ 页面可用(开吃)。如果发现问题需要改配方,你得"从座位上站起来"——每次点击链接导航,浏览器都会重新加载整个页面,重新经历一遍完整的请求—响应流程。
- 客户端路由像桌上的微波炉:你永远不需要离开当前页面。链接请求被你所写的 JavaScript 拦截,而不是直接发给服务器,因此导航切换不再触发整页刷新。
交互体验与可访问性代价
客户端路由让应用拥有更接近原生 App 的交互体验——由于路由由你控制,你可以跨路由切换制作流畅的 CSS 动画。但也要注意一个关键代价:浏览器整页刷新时会自动通知屏幕阅读器朗读新内容;而在客户端路由场景下,你需要手动通知屏幕阅读器路由已经更新。所幸借助成熟的路由库,这些问题通常可以被妥善解决。
快速上手:创建项目并接入路由
让我们从一个最小应用开始理解 React Router 的接入方式。先创建两个模拟页面组件。
创建Profile.jsx:
const Profile = () => { return ( <div> <h1>Hello from profile page!</h1> <p>So, how are you?</p> </div> ); }; export default Profile;同时把App.jsx替换为带导航的主页:
const App = () => { return ( <div> <h1>Hello from the main page of the app!</h1> <p>Here are some examples of links to other pages</p> <nav> <ul> <li> <a href="profile">Profile page</a> </li> </ul> </nav> </div> ); }; export default App;安装 React Router 并创建路由配置
安装依赖(本课程使用基于对象的路由配置方式,即 data router 风格):
npm install react-router然后在入口文件main.jsx中创建浏览器路由并渲染:
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import App from "./App"; import Profile from "./Profile"; const router = createBrowserRouter([ { path: "/", element: <App />, }, { path: "profile", element: <Profile />, }, ]); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode> );运行npm run dev,分别访问首页/和/profile,两条路由都能正常渲染。这段代码背后发生了什么:
- 从 React Router 导入
createBrowserRouter和RouterProvider; createBrowserRouter接收一个路由数组,生成路由配置;- 配置数组中的每个对象包含两个必填键:
path(路径)与对应的element(要渲染的组件); - 生成的路由配置被传给
RouterProvider组件完成渲染。
用 Link 替代 a 标签,实现无刷新导航
细心的读者会发现:上一步中点击导航栏链接时,浏览器仍然会为了跳转 URL 而整页重载——这显然不是我们想要的效果。React Router 为此导出了自定义的Link组件,用它替代常规的a标签:
import { Link } from "react-router"; const App = () => { return ( <div> <h1>Hello from the main page of the app!</h1> <p>Here are some examples of links to other pages</p> <nav> <ul> <li> <Link to="profile">Profile page</Link> </li> </ul> </nav> </div> ); }; export default App;Link的核心区别在于:它以to属性声明目标路径,点击时由 React Router 拦截导航行为,不再触发浏览器整页刷新,从而让导航体验变得平滑。
嵌套路由、Outlet 与动态段
嵌套路由:让子组件随父组件一起渲染
如果希望根据不同的 URL 在页面某一区域渲染不同内容,就需要嵌套路由。把子路由放进父路由的children数组中,子组件就会和父组件一起被渲染。先创建两个组件Popeye.jsx与Spinach.jsx:
import { Link } from "react-router"; const Popeye = () => { return ( <> <p>Hi, I am Popeye! I love to eat Spinach!</p> <Link to="/">Click here to go back</Link> </> ); }; export default Popeye;import { Link } from "react-router"; const Spinach = () => { return ( <> <p>Hi, I am Spinach! Popeye loves to eat me!</p> <Link to="/">Click here to go back</Link> </> ); }; export default Spinach;重写路由配置:
import { StrictMode } from "react" import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import App from "./App"; import Profile from "./Profile"; import Spinach from "./Spinach"; import Popeye from "./Popeye"; const router = createBrowserRouter([ { path: "/", element: <App />, }, { path: "profile", element: <Profile />, children: [ { path: "spinach", element: <Spinach /> }, { path: "popeye", element: <Popeye /> }, ], }, ]); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode>, );Outlet:子路由的渲染插槽
要让子组件真正出现在父组件页面中,需要在父组件里放置Outlet组件。Outlet是一个占位插槽,当访问对应子路径时,它会被替换为匹配的子组件。重写Profile.jsx:
import { Outlet } from "react-router"; const Profile = () => { return ( <div> <h1>Hello from profile page!</h1> <p>So, how are you?</p> <hr /> <h2>The profile visited is here:</h2> <Outlet /> </div> ); }; export default Profile;现在访问/profile页面,并在 URL 末尾手动加上/popeye或/spinach,<Outlet />就会分别被替换为对应的子组件。
索引路由:子路由的默认内容
如果希望在访问/profile且未附加任何子路径时,渲染一个默认组件,可以给 children 添加一条索引路由(index: true)。创建DefaultProfile.jsx:
const DefaultProfile = () => { return <p>Oh, nothing to see here!</p>; }; export default DefaultProfile;把它作为/profile的子路由加入配置:
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import App from "./App"; import Profile from "./Profile"; import DefaultProfile from "./DefaultProfile"; import Spinach from "./Spinach"; import Popeye from "./Popeye"; const router = createBrowserRouter([ { path: "/", element: <App />, }, { path: "profile", element: <Profile />, children: [ { index: true, element: <DefaultProfile /> }, { path: "spinach", element: <Spinach /> }, { path: "popeye", element: <Popeye /> }, ], }, ]); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode>, );访问/profile,你会看到Outlet位置渲染出了默认内容。
动态段(URL 参数)与 useParams
硬编码每一条子路由显然不够灵活。更多时候,我们希望根据 URL 中的变化值动态渲染内容,这就要用到动态段。把路径中的某一段写成:name形式:
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import App from "./App"; import Profile from "./Profile"; const router = createBrowserRouter([ { path: "/", element: <App />, }, { path: "profile/:name", element: <Profile />, }, ]); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode>, );冒号(:)会把后面的路径段变成动态段:它会匹配 URL 中该位置不断变化的值(比如name),这些值也被称为"URL 参数"(URL params),简称 params。通过useParamsHook 即可在组件中读取它们。重写Profile.jsx:
import { useParams } from "react-router"; import DefaultProfile from "./DefaultProfile"; import Spinach from "./Spinach"; import Popeye from "./Popeye"; const Profile = () => { const { name } = useParams(); return ( <div> <h1>Hello from profile page!</h1> <p>So, how are you?</p> <hr /> <h2>The profile visited is here:</h2> {name === "popeye" ? ( <Popeye /> ) : name === "spinach" ? ( <Spinach /> ) : ( <DefaultProfile /> )} </div> ); }; export default Profile;useParams返回一个包含所有动态段键值对的对象,这里解构出name,再根据其值条件渲染不同组件——注意,之前那条index: true索引路由此时已不再生效,因为/profile本身不再匹配任何动态段,未传参数时会走到兜底分支或报错。
处理无效 URL:errorElement 与 404 页
动态段方案下,访问/profile(不带任何名字)已经没有实际意义——不指明是哪个 profile,页面该显示谁呢?此时应用会报错。为了让用户访问错误或未使用的路径时看到友好的默认页面,可以在路由配置中传入errorElement。
创建基础的"页面不存在"组件:
import { Link } from "react-router"; const ErrorPage = () => { return ( <div> <h1>Oh no, this route doesn't exist!</h1> <Link to="/"> You can go back to the home page by clicking here, though! </Link> </div> ); }; export default ErrorPage;把errorElement加入配置,然后访问/profile或任何未声明的路径验证效果:
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import App from "./App"; import Profile from "./Profile"; import ErrorPage from "./ErrorPage"; const router = createBrowserRouter([ { path: "/", element: <App />, errorElement: <ErrorPage />, }, { path: "profile/:name", element: <Profile />, }, ]); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode>, );这样,任何未匹配的 URL 都会渲染ErrorPage,用户可以通过其中的Link返回首页,而不会再看到裸奔的报错界面。
重构:把路由配置抽到独立文件
路由配置继续膨胀之前,把它抽到独立文件routes.jsx中,可以让main.jsx更清爽:
import App from "./App"; import Profile from "./Profile"; import ErrorPage from "./ErrorPage"; const routes = [ { path: "/", element: <App />, errorElement: <ErrorPage />, }, { path: "profile/:name", element: <Profile />, }, ]; export default routes;main.jsx只需导入并创建路由:
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import routes from "./routes"; const router = createBrowserRouter(routes); createRoot(document.getElementById("root")).render( <StrictMode> <RouterProvider router={router} /> </StrictMode> );这个重构还有一个重要收益:路由数组可以被测试文件直接导入,测试中可以基于同一份配置创建测试专用的 router(详见下文测试章节),保证测试环境与生产环境的路由行为一致。
通过 Outlet 传递状态:context 与 useOutletContext
嵌套路由时,如果父组件持有数据(比如 state)需要传给Outlet渲染出的任意子组件,就需要用到context。Outlet内置了一个contextprop,可以向其中传入任意值——包括数组或对象。
在任何由该Outlet渲染的组件(包括"孙组件")内部,都可以调用useOutletContext()Hook 取回传给contextprop 的值;如果传入的是数组或对象,还能直接解构使用。例如:
// 父组件:把 state 通过 context 传给子组件 <Outlet context={{ user, setUser }} /> // 子组件中读取 const { user, setUser } = useOutletContext();这与 React 自带的 Context API(createContext/useContext,见本仓库课程 managing_state_with_context_api.md)思路一脉相承,但useOutletContext是 React Router 针对路由插槽场景提供的专用 Hook,更适合"把数据顺着路由层级向下传递"的需求。在本课程的后续项目中(如购物车项目),这类跨页面共享状态的需求会很常见。
受保护路由与编程式导航
实际应用中,经常需要判断某条路由该不该渲染。最典型的场景是认证:根据用户是否登录来决定渲染哪些路由。若已登录,展示用户信息;否则重定向到登录页。
实现方式有很多种,其中最简单的一种是根据条件动态构建路由配置——在调用createBrowserRouter之前,用if判断或三元表达式把需要保护的路由有条件地加入路由数组(或替换element),从而实现"登录才渲染"的效果。
与此同时,你还会经常需要编程式地把用户重定向到另一个 URL(例如登录成功后跳回原页面)。这时可以使用useNavigateHook,它既能导航到任意 URL,也能在用户历史记录中前进或后退(对应navigate(-1)等用法)。它与Link的分工是:Link面向声明式的用户点击,useNavigate面向代码逻辑中的命令式跳转。
测试使用 React Router 的组件
测试这类组件时有个关键前提要牢记:你的应用从不会直接渲染这些组件,而是通过 router(例如<RouterProvider>)来渲染它们。因此,测试中渲染这些组件时,也必须把它们放进路由上下文里,否则useNavigate、useParams这类 Hook 或<Link>组件会直接抛错。
具体选择哪种方式取决于被测组件的依赖程度:
- 仅包含
<Link>之类的轻量组件(不测试导航、不依赖其他路由特性):用轻量的MemoryRouter包裹即可。MemoryRouter把路由状态保存在内存中,无需浏览器地址栏,非常适合测试环境。 - 依赖路由行为的组件(如使用 outlet context、参数匹配、
errorElement或重定向):更合理的做法是像应用本身那样渲染一个<RouterProvider>。由于测试运行在 Node/jsdom 环境中而非浏览器,可以使用createMemoryRouter,并直接复用从routes.jsx导出的同一份路由配置。
这与本仓库 React 测试课程(introduction_to_react_testing.md)的实践一致:测试依赖jsdom在内存中模拟 DOM,配合@testing-library/react的render/screen查询与@testing-library/user-event模拟用户交互。值得注意的是,课程作业特别提醒:不要直接去测试react-router本身,它是外部库,其维护者已经充分测试过了;你只需在正确的路由上下文里测试自己的组件。
部署提醒:SPA 路由与服务器重写
客户端路由还有一个常被忽略的实战细节——部署时的服务器配置。SPA 的所有路由在客户端解析,但服务器上并不存在/profile/popeye这样的真实文件。如果用户直接访问或刷新该 URL,服务器会返回 404,除非你把所有请求重写回index.html,让 React Router 接管后续解析。
本仓库的购物车项目(project_shopping_cart.md)给出了各主流平台的配置示例:
Netlify:在项目
public/目录添加_redirects文件,内容为/* /index.html 200,将所有路由重定向到首页;Vercel:在项目根目录添加
vercel.json,通过 rewrites 把所有路径指向/index.html:{ "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } ] }Cloudflare Pages:截至撰写时无需额外配置,默认行为即可让
react-router正确处理 SPA 重定向。
总结与知识检查
到这里,你已经掌握了用 React Router 构建 React 多页面应用的核心基础:客户端路由的原理、createBrowserRouter+RouterProvider的接入方式、Link无刷新导航、嵌套路由与Outlet、索引路由、动态段与useParams、errorElement兜底、useOutletContext传值、受保护路由与useNavigate,以及如何在测试与部署场景下正确处理路由。
React Router 还有大量超出本课范围的高级特性(如 history/match 对象等),但掌握以上基础足以支撑你完成整个 React 课程的学习。建议的巩固练习:为上文构建的应用新增几条路由动手实践;如果觉得本课信息量较大,可以删掉重写一遍加深印象;同时浏览 React Router 官方文档中本课涉及的概念,并留意其他实用特性,作为日后查阅的参考。
最后,用以下知识检查问题自测(回答不出的部分可回到对应小节复习):
- 客户端路由意味着什么?(见客户端路由一节)
- 如何搭建一个基础路由?(见快速上手一节)
- 为了启用客户端路由,应该用什么替代
a标签?(见Link 替代 a 标签一节) - 如何创建嵌套路由?(见嵌套路由一节)
- 动态段或 URL 参数是什么意思?(见动态段与 useParams一节)
- 如何处理无效 URL 导致的错误?(见errorElement 与 404 页一节)
- 如何通过
<Outlet />从父组件向子组件传数据?(见Outlet 传递状态一节) - 如何创建受保护路由?(见受保护路由一节)
- 如何测试使用 React Router 的组件?(见路由组件测试一节)
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考