你可能想知道 React Router 到底做了什么。它如何帮助你构建应用程序?到底什么是 **路由器** 呢?
如果您曾经有过这些问题,或者您只是想深入了解路由的基本原理,那么您来对地方了。本文档详细解释了 React Router 中实现的路由背后的所有核心概念。
请不要让这份文档让您感到不知所措!在日常使用中,React Router 非常简单。您不需要深入了解它就可以使用它。
React Router 不仅仅是将 URL 与函数或组件匹配:它还涉及构建一个完整的用户界面,该界面映射到 URL,因此它可能包含比您习惯的更多概念。我们将详细介绍 React Router 的三个主要工作。
但首先,一些定义!来自后端和前端框架的路由有很多不同的想法。有时一个词在一个上下文中的含义可能与另一个上下文不同。
以下是一些我们在谈论 React Router 时经常使用的词语。本指南的其余部分将详细介绍每个词语。
URL - 地址栏中的 URL。许多人将“URL”和“路由”互换使用,但这在 React Router 中不是路由,它只是一个 URL。
位置 - 这是一个 React Router 特定的对象,它基于内置浏览器的 window.location
对象。它代表“用户所在的位置”。它主要是 URL 的对象表示,但比这更复杂一些。
位置状态 - 一个与 位置 相关联的值,但不会编码在 URL 中。类似于哈希或搜索参数(编码在 URL 中的数据),但存储在浏览器的内存中,不可见。
历史堆栈 - 当用户导航时,浏览器会跟踪每个 位置,并将它们存储在一个堆栈中。如果您在浏览器中点击并按住后退按钮,您就可以看到浏览器的历史堆栈。
客户端路由 (CSR) - 一个普通的 HTML 文档可以链接到其他文档,浏览器会自行处理 历史堆栈。客户端路由使开发人员能够在不向服务器发送文档请求的情况下操作浏览器历史堆栈。
历史 - 一个对象,允许 React Router 订阅 URL 的更改,并提供 API 以编程方式操作浏览器 历史堆栈。
历史操作 - POP
、PUSH
或 REPLACE
之一。用户可能出于以下三种原因之一到达 URL。当向历史堆栈添加新条目时(通常是链接点击或程序员强制导航)会进行推送。替换类似,但它会替换堆栈上的当前条目,而不是推送新的条目。最后,当用户点击浏览器 chrome 中的后退或前进按钮时,会发生弹出。
路径模式 - 这些看起来像 URL,但可以包含用于将 URL 与路由匹配的特殊字符,例如 **动态片段** ("/users/:userId"
) 或 **星号片段** ("/docs/*"
)。它们不是 URL,而是 React Router 将匹配的模式。
动态片段 - 路径模式中的一个片段,它是动态的,这意味着它可以匹配片段中的任何值。例如,模式 /users/:userId
将匹配诸如 /users/123
之类的 URL。
路由器 - 有状态的顶级组件,它使所有其他组件和钩子正常工作。
路由配置 - 一棵 **路由对象** 树,这些对象将根据当前位置进行排名和匹配(带嵌套),以创建 **路由匹配** 的分支。
路由 - 一个对象或路由元素,通常具有 { path, element }
或 <Route path element>
的形状。path
是一个路径模式。当路径模式与当前 URL 匹配时,将渲染元素。
嵌套路由 - 因为路由可以有子路由,并且每个路由都通过 片段 定义了 URL 的一部分,所以单个 URL 可以匹配树中嵌套的“分支”中的多个路由。这通过 出口、相对链接 等实现自动布局嵌套。
相对链接 - 不以 /
开头的链接将继承渲染它们的最近路由。这使得链接到更深层的 URL 变得容易,而无需知道和构建整个路径。
父路由 - 具有子路由的路由。
布局路由 - 一个没有路径的 **父路由**,专门用于将子路由分组到特定布局中。
在 React Router 可以执行任何操作之前,它必须能够订阅浏览器 历史堆栈 中的更改。
浏览器在用户导航时维护自己的历史堆栈。这就是后退和前进按钮如何工作的原理。在传统的网站(没有 JavaScript 的 HTML 文档)中,浏览器将在用户每次点击链接、提交表单或点击后退和前进按钮时向服务器发送请求。
例如,考虑用户
/dashboard
/accounts
/customers/123
/dashboard
历史堆栈将按如下方式更改,其中 **粗体** 条目表示当前 URL
/dashboard
/dashboard
, /accounts
/dashboard
, /accounts
, /customers/123
/dashboard
, /accounts
, /customers/123
/dashboard
, /accounts
, /dashboard
使用 **客户端路由**,开发人员能够以编程方式操作浏览器 历史堆栈。例如,我们可以编写一些类似于此的代码来更改 URL,而不会使用浏览器默认行为向服务器发送请求。
<a
href="/contact"
onClick={(event) => {
// stop the browser from changing the URL and requesting the new document
event.preventDefault();
// push an entry into the browser history stack and change the URL
window.history.pushState({}, undefined, "/contact");
}}
/>
window.history.pushState
此代码更改了 URL,但对 UI 没有任何影响。我们需要编写更多代码来更改某个地方的某个状态,才能使 UI 更改为联系页面。问题是,浏览器没有提供一种方法来“监听 URL”并订阅此类更改。
嗯,这并不完全正确。我们可以通过 弹出 事件监听 URL 的更改。
window.addEventListener("popstate", () => {
// URL changed!
});
但这仅在用户点击后退或前进按钮时触发。当程序员调用 window.history.pushState
或 window.history.replaceState
时,没有事件。
这就是 React Router 特定的 history
对象发挥作用的地方。它提供了一种方法来“监听 URL”更改,无论 历史操作 是 **推送**、**弹出** 还是 **替换**。
let history = createBrowserHistory();
history.listen(({ location, action }) => {
// this is called whenever new locations come in
// the action is POP, PUSH, or REPLACE
});
应用程序不需要设置自己的历史对象,这是 <Router>
的工作。它会设置一个这样的对象,订阅 历史堆栈 中的更改,最后在 URL 更改时更新其状态。这会导致应用程序重新渲染,并显示正确的 UI。它唯一需要放在状态上的东西是一个 location
,其他所有内容都从这个单个对象中工作。
浏览器在 window.location
上有一个位置对象。它会告诉您有关 URL 的信息,但还有一些方法可以更改它。
window.location.pathname; // /getting-started/concepts/
window.location.hash; // #location
window.location.reload(); // force a refresh w/ the server
// and a lot more
window.location
React Router 没有使用 window.location
,而是引入了 位置 的概念,它以 window.location
为模型,但更简单。它看起来像这样
{
pathname: "/bbq/pig-pickins",
search: "?campaign=instagram",
hash: "#menu",
state: null,
key: "aefz24ie"
}
前三个:{ pathname, search, hash }
与 window.location
完全相同。如果你将这三个加起来,你将得到用户在浏览器中看到的 URL
location.pathname + location.search + location.hash;
// /bbq/pig-pickins?campaign=instagram#menu
最后两个,{ state, key }
,是 React Router 特定的。
位置路径名
这是 URL 中起源之后的部分,所以对于 https://example.com/teams/hotspurs
,路径名为 /teams/hotspurs
。这是位置中唯一与路由匹配的部分。
位置搜索
人们使用许多不同的术语来描述 URL 的这部分
在 React Router 中,我们称之为“位置搜索”。但是,位置搜索是 URLSearchParams
的序列化版本。所以有时我们也可能称之为“URL 搜索参数”。
// given a location like this:
let location = {
pathname: "/bbq/pig-pickins",
search: "?campaign=instagram&popular=true",
hash: "",
state: null,
key: "aefz24ie",
};
// we can turn the location.search into URLSearchParams
let params = new URLSearchParams(location.search);
params.get("campaign"); // "instagram"
params.get("popular"); // "true"
params.toString(); // "campaign=instagram&popular=true",
当需要精确表达时,将序列化字符串版本称为“搜索”,将解析版本称为“搜索参数”,但在精度不重要的情况下,通常可以互换使用这些术语。
位置哈希
URL 中的哈希表示 当前页面 上的滚动位置。在引入 window.history.pushState
API 之前,Web 开发人员仅使用 URL 的哈希部分进行客户端路由,这是我们无需向服务器发出新请求即可操作的唯一部分。但是,如今我们可以将其用于其设计目的。
位置状态
你可能想知道为什么 window.history.pushState()
API 被称为“推送状态”。状态?我们不是仅仅在更改 URL 吗?它不应该叫做 history.push
吗?好吧,我们在 API 设计时不在场,所以我们不确定为什么“状态”是重点,但它仍然是浏览器的一个很酷的功能。
浏览器允许我们通过将一个值传递给 pushState
来持久化导航信息。当用户点击后退时,history.state
上的值将更改为之前“推送”的值。
window.history.pushState("look ma!", undefined, "/contact");
window.history.state; // "look ma!"
// user clicks back
window.history.state; // undefined
// user clicks forward
window.history.state; // "look ma!"
history.state
React Router 利用了浏览器的这一功能,对其进行了抽象,并在 location
上而不是 history
上显示了这些值。
你可以将 location.state
视为与 location.hash
或 location.search
相同,只是它不会将值放在 URL 中,而是隐藏起来——就像一个只有程序员知道的超级秘密的 URL 部分。
位置状态的几个很好的用例是
你可以通过两种方式设置位置状态:在 <Link>
或 navigate
上
<Link to="/pins/123" state={{ fromDashboard: true }} />;
let navigate = useNavigate();
navigate("/users/123", { state: partialUser });
在下一页,你可以使用 useLocation
访问它
let location = useLocation();
location.state;
new Date()
的东西将被转换为字符串。
位置键
每个位置都有一个唯一的键。这对于高级用例(如基于位置的滚动管理、客户端数据缓存等)非常有用。因为每个新位置都有一个唯一的键,所以你可以构建抽象,将信息存储在普通对象、new Map()
甚至 locationStorage
中。
例如,一个非常基本的客户端数据缓存可以按位置键(和获取 URL)存储值,并在用户点击返回时跳过获取数据
let cache = new Map();
function useFakeFetch(URL) {
let location = useLocation();
let cacheKey = location.key + URL;
let cached = cache.get(cacheKey);
let [data, setData] = useState(() => {
// initialize from the cache
return cached || null;
});
let [state, setState] = useState(() => {
// avoid the fetch if cached
return cached ? "done" : "loading";
});
useEffect(() => {
if (state === "loading") {
let controller = new AbortController();
fetch(URL, { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
if (controller.signal.aborted) return;
// set the cache
cache.set(cacheKey, data);
setData(data);
});
return () => controller.abort();
}
}, [state, cacheKey]);
useEffect(() => {
setState("loading");
}, [URL]);
return data;
}
在初始渲染时,以及当 历史堆栈 发生变化时,React Router 将将 位置 与你的 路由配置 进行匹配,以生成一组要渲染的 匹配。
路由配置是 路由 的树,看起来像这样
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path=":teamId/edit" element={<EditTeam />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route element={<PageLayout />}>
<Route path="/privacy" element={<Privacy />} />
<Route path="/tos" element={<Tos />} />
</Route>
<Route path="contact-us" element={<Contact />} />
</Routes>
<Routes>
组件递归遍历其 props.children
,剥离它们的 props,并生成一个类似于这样的对象
let routes = [
{
element: <App />,
path: "/",
children: [
{
index: true,
element: <Home />,
},
{
path: "teams",
element: <Teams />,
children: [
{
index: true,
element: <LeagueStandings />,
},
{
path: ":teamId",
element: <Team />,
},
{
path: ":teamId/edit",
element: <EditTeam />,
},
{
path: "new",
element: <NewTeamForm />,
},
],
},
],
},
{
element: <PageLayout />,
children: [
{
element: <Privacy />,
path: "/privacy",
},
{
element: <Tos />,
path: "/tos",
},
],
},
{
element: <Contact />,
path: "/contact-us",
},
];
事实上,你可以使用钩子 useRoutes(routesGoHere)
来代替 <Routes>
。这就是 <Routes>
所做的全部工作。
如你所见,路由可以定义多个 段,例如 :teamId/edit
,或者只定义一个,例如 :teamId
。路由配置分支中的所有段都将加在一起,以创建一个路由的最终 路径模式。
注意 :teamId
段。我们称之为 动态段,它不是静态地(实际字符)与 URL 匹配,而是动态地匹配。任何值都可以填入 :teamId
。/teams/123
或 /teams/cupcakes
都将匹配。我们称解析后的值为 URL 参数。因此,在本例中,我们的 teamId
参数将是 "123"
或 "cupcakes"
。我们将在 渲染 部分看到如何在应用程序中使用它们。
如果我们将路由配置所有分支的所有段加起来,我们将得到应用程序响应的以下路径模式
[
"/",
"/teams",
"/teams/:teamId",
"/teams/:teamId/edit",
"/teams/new",
"/privacy",
"/tos",
"/contact-us",
];
现在,事情变得非常有趣。考虑 URL /teams/new
。列表中的哪个模式与 URL 匹配?
没错,有两个匹配!
/teams/new
/teams/:teamId
React Router 必须在这里做出决定,只能有一个匹配。许多路由器(客户端和服务器端)将简单地按照定义的顺序处理这些模式。第一个匹配的获胜。在本例中,我们将匹配 /
并渲染 <Home/>
组件。这绝对不是我们想要的。这些类型的路由器要求我们完美地排序路由才能获得预期结果。这就是 React Router 在 v6 之前的工作方式,但现在它变得更加智能。
查看这些模式,你直观地知道我们希望 /teams/new
与 URL /teams/new
匹配。这是一个完美的匹配!React Router 也知道这一点。在匹配时,它将根据段数、静态段、动态段、星号模式等对路由进行排名,并选择最具体的匹配。你永远不必考虑排序路由。
你可能已经注意到之前奇怪的路由
<Route index element={<Home />} />
<Route index element={<LeagueStandings />} />
<Route element={<PageLayout />} />
它们甚至没有路径,它们怎么能是路由呢?这就是 React Router 中“路由”一词使用得比较宽泛的地方。<Home/>
和 <LeagueStandings/>
是 索引路由,而 <PageLayout/>
是 布局路由。我们将在 渲染 部分讨论它们的工作原理。它们与匹配关系不大。
当路由与 URL 匹配时,它将由一个 匹配 对象表示。<Route path=":teamId" element={<Team/>}/>
的匹配看起来像这样
{
pathname: "/teams/firebirds",
params: {
teamId: "firebirds"
},
route: {
element: <Team />,
path: ":teamId"
}
}
pathname
包含与该路由匹配的 URL 部分(在本例中,它是全部)。params
包含从任何与之匹配的 动态段 解析的值。请注意,参数对象的键直接映射到段的名称::teamId
变成 params.teamId
。
因为我们的路由是树,所以单个 URL 可以匹配树的整个分支。考虑 URL /teams/firebirds
,它将是以下路由分支
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path=":teamId/edit" element={<EditTeam />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route element={<PageLayout />}>
<Route path="/privacy" element={<Privacy />} />
<Route path="/tos" element={<Tos />} />
</Route>
<Route path="contact-us" element={<Contact />} />
</Routes>
React Router 将从这些路由和 URL 中创建一个 匹配 数组,以便它可以渲染一个与路由嵌套相匹配的嵌套 UI。
[
{
pathname: "/",
params: null,
route: {
element: <App />,
path: "/",
},
},
{
pathname: "/teams",
params: null,
route: {
element: <Teams />,
path: "teams",
},
},
{
pathname: "/teams/firebirds",
params: {
teamId: "firebirds",
},
route: {
element: <Team />,
path: ":teamId",
},
},
];
最后一个概念是渲染。假设应用程序的入口看起来像这样
const root = ReactDOM.createRoot(
document.getElementById("root")
);
root.render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route element={<PageLayout />}>
<Route path="/privacy" element={<Privacy />} />
<Route path="/tos" element={<Tos />} />
</Route>
<Route path="contact-us" element={<Contact />} />
</Routes>
</BrowserRouter>
);
让我们再次以 URL /teams/firebirds
为例。<Routes>
将将 位置 与你的 路由配置 进行匹配,获取一组 匹配,然后渲染一个类似于这样的 React 元素树
<App>
<Teams>
<Team />
</Teams>
</App>
在父路由元素内部渲染的每个匹配都是一个非常强大的抽象。大多数网站和应用程序都具有这种特征:盒子套着盒子套着盒子,每个盒子都有一个导航部分,可以更改页面的子部分。
这个嵌套的元素树不会自动发生。<Routes>
将为你渲染第一个匹配的元素(在本例中是 <App/>
)。下一个匹配的元素是 <Teams>
。为了渲染它,App
需要渲染一个 出口。
function App() {
return (
<div>
<GlobalNav />
<Outlet />
<GlobalFooter />
</div>
);
}
Outlet
组件将始终渲染下一个匹配。这意味着 <Teams>
也需要一个出口来渲染 <Team/>
。
如果 URL 是 /contact-us
,则元素树将更改为
<Contact />
因为联系表单不在主 <App>
路由下。
如果 URL 是 /teams/firebirds/edit
,则元素树将变为
<App>
<Teams>
<EditTeam />
</Teams>
</App>
出口将用匹配的新子节点替换子节点,但父节点布局保持不变。这很微妙,但对于清理组件非常有效。
请记住 /teams
的 路由配置
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
如果 URL 是 /teams/firebirds
,则元素树将是
<App>
<Teams>
<Team />
</Teams>
</App>
但如果 URL 是 /teams
,则元素树将是
<App>
<Teams>
<LeagueStandings />
</Teams>
</App>
联赛排名?<Route index element={<LeagueStandings>}/>
是怎么冒出来的?它甚至没有路径!原因是它是一个 索引路由。索引路由在其父路由的 出口 中,在父路由的路径上渲染。
这样想,如果你不在任何子路由的路径上,<Outlet>
不会在 UI 中渲染任何内容。
<App>
<Teams />
</App>
如果所有球队都在左侧列表中,那么空出口意味着你在右侧有一个空白页面!你的 UI 需要一些东西来填充空间:索引路由来救援。
另一种看待索引路由的方式是,它是父路由匹配但其子路由都不匹配时的默认子路由。
根据用户界面,你可能不需要索引路由,但如果父路由中存在任何持久导航,你很可能需要一个索引路由来填充用户尚未点击任何项目时的空间。
这是我们尚未匹配的路由配置的一部分:/privacy
。让我们再次查看路由配置,突出显示匹配的路由
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path=":teamId/edit" element={<EditTeam />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route element={<PageLayout />}>
<Route path="/privacy" element={<Privacy />} />
<Route path="/tos" element={<Tos />} />
</Route>
<Route path="contact-us" element={<Contact />} />
</Routes>
渲染后的元素树将是
<PageLayout>
<Privacy />
</PageLayout>
<Outlet>
,你希望子路由元素在那里渲染。使用 {children}
将不会按预期工作。
PageLayout
路由确实有点奇怪。我们称之为 布局路由,因为它根本不参与匹配(尽管它的子路由参与了)。它只存在是为了让将多个子路由包装在同一个布局中变得更简单。如果我们不允许这样做,那么你将不得不以两种不同的方式处理布局:有时你的路由为你处理,有时你手动处理,在整个应用程序中重复大量的布局组件。
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path=":teamId/edit" element={<EditTeam />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route
path="/privacy"
element={
<PageLayout>
<Privacy />
</PageLayout>
}
/>
<Route
path="/tos"
element={
<PageLayout>
<Tos />
</PageLayout>
}
/>
<Route path="contact-us" element={<Contact />} />
</Routes>
所以,是的,布局“路由”的语义有点愚蠢,因为它与 URL 匹配无关,但它太方便了,不能不允许。
当 URL 更改时,我们称之为“导航”。在 React Router 中有两种导航方式
<Link>
navigate
这是主要的导航方式。渲染一个 <Link>
允许用户在点击它时更改 URL。React Router 将阻止浏览器的默认行为,并告诉 历史记录 将一个新条目推入 历史记录栈。 位置 更改,新的 匹配 将被渲染。
但是,链接是可以访问的,因为它们
<a href>
,因此所有默认的可访问性问题都得到解决(如键盘、可聚焦性、SEO 等)。嵌套路由 不仅仅是关于渲染布局;它们还支持“相对链接”。考虑我们之前提到的 teams
路由
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
</Route>
<Teams>
组件可以渲染以下链接
<Link to="psg" />
<Link to="new" />
它链接到的完整路径将是 /teams/psg
和 /teams/new
。它们继承了它们被渲染的路由。这使得你的路由组件不必真正了解应用程序中其他路由的任何信息。大量的链接只是深入一个 段。你可以重新排列整个 路由配置,这些链接很可能仍然可以正常工作。这在网站建设初期,设计和布局不断变化时非常有价值。
此函数从 useNavigate
钩子返回,允许你,程序员,随时更改 URL。你可以在超时时执行此操作
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate("/logout");
}, 30000);
}, []);
或者在表单提交后
<form onSubmit={event => {
event.preventDefault();
let data = new FormData(event.target)
let urlEncoded = new URLSearchParams(data)
navigate("/create", { state: urlEncoded })
}}>
与 Link
一样,navigate
也支持嵌套的“to”值。
navigate("psg");
你应该有充分的理由使用 navigate
而不是 <Link>
。这让我们非常难过
<li onClick={() => navigate("/somewhere")} />
除了链接和表单之外,很少有交互应该更改 URL,因为它会引入有关可访问性和用户期望的复杂性。
最后,应用程序将希望向 React Router 请求一些信息,以便构建完整的 UI。为此,React Router 有很多钩子
let location = useLocation();
let urlParams = useParams();
let [urlSearchParams] = useSearchParams();
让我们从头到尾把所有内容都放在一起!
你渲染你的应用程序
const root = ReactDOM.createRoot(
document.getElementById("root")
);
root.render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
<Route element={<PageLayout />}>
<Route path="/privacy" element={<Privacy />} />
<Route path="/tos" element={<Tos />} />
</Route>
<Route path="contact-us" element={<Contact />} />
</Routes>
</BrowserRouter>
);
<Routes>
递归其 子路由 以构建一个 路由配置,将这些路由与 位置 匹配,创建一些路由 匹配,并渲染第一个匹配的路由元素。
出口渲染路由 匹配 中的下一个匹配项。
用户点击一个链接
链接调用 navigate()
历史记录 更改 URL 并通知 <BrowserRouter>
。
<BrowserRouter>
重新渲染,从 (2) 开始!
就是这样!我们希望本指南能帮助你更深入地了解 React Router 中的主要概念。