像素

React Router 概念

1. History 和 Locations

在 React Router 执行任何操作之前,它必须能够订阅浏览器历史堆栈的变化。

当用户在网站上导航时,浏览器会维护它们自己的历史堆栈。 这就是为什么浏览器的后退和前进按钮可以工作的原因。 在传统的网站(没有 JavaScript 的 HTML 文档)中,浏览器会在用户点击链接、提交表单或单击后退和前进按钮时向服务器发出请求。

例如,考虑以下操作:

  • 点击链接到/dashboard
  • 点击链接到/accounts
  • 点击链接到/customers/123
  • 点击后退按钮
  • 点击链接到/dashboard

历史堆栈的变化将如下所示,其中粗体条目表示当前的 URL:

  • /dashboard
  • /dashboard, /accounts
  • /dashboard, /accounts, /customers/123
  • /dashboard, /accounts, /customers/123
  • /dashboard, /accounts, /dashboard

2. History Object:

客户端路由可以让开发人员以编程方式操纵浏览器历史堆栈。 例如,我们可以编写以下代码来更改 URL,而不会造成浏览器默认行为,即发出到服务器的请求:

<a
  href="/contact"
  onClick={(event) => {
    // 阻止浏览器更改URL和请求新文档
    event.preventDefault();
    // 将一个条目推入浏览器历史堆栈并更改URL
    window.history.pushState({}, undefined, "/contact");
  }}
/>

这段代码更改了 URL,但对于用户界面(UI)没有任何影响。 需要编写更多的代码,在某个地方更改一些状态,以便使 UI 更改为 "/contact" 页面。 问题在于,浏览器不提供一种方法来“侦听 URL”并订阅此类更改。

并不完全正确。可以通过 pop 事件来监听 URL 的更改:

window.addEventListener("popstate", () => {
  // URL changed!
});

但是这仅在用户点击后退或前进按钮时触发。

当调用window.history.pushStatewindow.history.replaceState时,没有事件的触发。

这就是 React Router 专用的 history 对象发挥作用的地方。 **它提供了一种监听 URL 更改的方式**,无论是 push,pop 还是 replace 操作,都可以监听到。

let history = createBrowserHistory();
history.listen(({ location, action }) => {
  // 每当出现新位置时,就会调用此函数
  // 行动(PUSH,POP or REPLACE)
});

应用程序不需要设置自己的 history 对象--这是<Router>的工作。 它设置了一个这样的对象,订阅了历史堆栈的变化,并在 URL 更改时更新其状态。 这会导致应用程序重新渲染并显示正确的用户界面(UI)。 它需要在 state 上**放置一个位置(location)**,其他所有工作都从这个单一的对象中进行。

3. Locations

在浏览器中,通过 window.location 对象可以获取 URL 的相关信息,并且该对象有一些方法可以修改 URL:

window.location.pathname; // /getting-started/concepts/
window.location.hash; // #location
window.location.reload(); // 强制刷新页面,并从服务器获取最新内容
// 还有很多其他属性和方法...

在 React Router 应用程序中通常不会直接使用 window.location

React Router 提供了 location 这一概念,这个概念基于 window.location,但要简单得多。

{
  pathname: "/bbq/pig-pickins",
  search: "?campaign=instagram",
  hash: "#menu",
  state: null,
  key: "aefz24ie"
}

其中前三个 { pathname, search, hash } 就像 window.location 返回的对应信息一样。

而后面的两个属性 { state, key } 则是 React Router 特有的。

Location Pathname

这是 URL 中 origin 后面的部分,因此对于 https://example.com/teams/hotspurs,路径名称 (pathname) 是 /teams/hotspurs。 这是与路由匹配唯一的位置部分。

人们对 URL 的这一部分使用很多不同的术语:

  • 位置搜索(location search)
  • 搜索参数(search params)
  • URL 搜索参数(URL search params)
  • 查询字符串(query string)

在 React Router 中,称其为“Location Search”。 然而,Location Search 是 URLSearchParams 序列化后的版本。 因此有时也可以称其为"URL search params"。

例如:给定这样一个 location:

let location = {
  pathname: "/bbq/pig-pickins",
  search: "?campaign=instagram&popular=true",
  hash: "",
  state: null,
  key: "aefz24ie",
};

我们可以将 location.search 转换为 URLSearchParams

let params = new URLSearchParams(location.search);
params.get("campaign"); // "instagram"
params.get("popular"); // "true"
params.toString(); // "campaign=instagram&popular=true"

当精度不重要时,常见地通过交替使用这些术语来引用序列化的字符串版本为“search”,而引用解析版本为“search params”。

Location Hash

URL 中的哈希标记表示当前页面上的滚动位置。 在 window.history.pushState API 被引入之前,Web 开发人员仅使用 URL 的哈希部分进行客户端路由,它是我们可以在不向服务器发出新请求的情况下操作的唯一部分。 但是,今天我们可以将其用于设计目的。

Location State

浏览器允许通过向 pushState 传递一个值来持久化关于导航的信息。 当用户点击后退时,history.state 上的值会改变为之前“push”的值。

window.history.pushState("look ma!", undefined, "/contact");
window.history.state; // "look ma!"
// 用户点击后退
window.history.state; // undefined
// 用户点击前进
window.history.state; // "look ma!"

在 React Router 应用中,不需要直接读取 history.state

React Router 利用了这个浏览器特性,稍微抽象了一下,并将值显示在 location 上,而不是历史记录上。

可以将 location.state 看作是 location.hashlocation.search, 只不过它不会将值放入 URL 中,而是隐藏起来。

使用location.state的用例包括:

  • 告诉下一页用户来自哪里,并分支 UI。

  • 从列表中向下一个屏幕发送部分记录,以便它可以立即呈现部分数据,然后再获取其余数据。

  • 可以通过<Link>或 navigate 设置location.state

<Link to="/pins/123" state={{ fromDashboard: true }} />;

let navigate = useNavigate();
navigate("/users/123", { state: partialUser });

下一个页面上,可以使用 useLocation 访问它:

let location = useLocation();
location.state;

**location.state 值将被序列化**,因此类似new Date()这样的东西将被转换为字符串。

Location Key

每个 Location 都有一个唯一的 key。 这对于高级情况非常有用,例如基于位置的滚动管理、客户端数据缓存等。 由于每个新位置都有一个唯一的 key,因此可以构建抽象对象,在其中存储信息, 如 plain object 、new Map() ,甚至 locationStorage。

例如,一个非常基本的客户端数据缓存可以按 Location Key(和取回 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(() => {
    // 从缓存初始化
    return cached || null;
  });

  let [state, setState] = useState(() => {
    // 如果缓存,避免读取
    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;
          // 设置缓存
          cache.set(cacheKey, data);
          setData(data);
        });
      return () => controller.abort();
    }
  }, [state, cacheKey]);

  useEffect(() => {
    setState("loading");
  }, [URL]);

  return data;
}

4. 匹配

在初始渲染时,当历史堆栈发生变化时,React Router 会将 location 与路由配置匹配,以提供一组要渲染的匹配项。

5. 定义 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。 路由配置中一个分支下的所有部分都会被添加在一起,以创建路由的最终路径模式。

6. 匹配参数

注意:teamId 段。这就是路径模式的动态段(动态路由),这意味着它不静态匹配 URL(实际字符),而是动态匹配它。 任何值都可以填写:teamId。两者/teams/123/teams/cupcakes 都会匹配。 调用解析值 URL 参数。 因此,在这种情况下,teamId 参数将是 "123" 或 "cupcakes"。

7. 对路由排序

在我们的路由配置中,如果将所有分支的段加起来,我们将得到以下路径模式,这些模式是应用程序响应的:

[
  "/",
  "/teams",
  "/teams/:teamId",
  "/teams/:teamId/edit",
  "/teams/new",
  "/privacy",
  "/tos",
  "/contact-us",
]

考虑 URL /teams/new。在列表中哪个模式与 URL 匹配?

没错,有两个!

  • /teams/new
  • /teams/:teamId

React Router 在这里必须做出决策,只能选择一个。 许多路由器(包括客户端和服务器端)会按照定义顺序处理模式。 首先匹配成功则胜出。在这种情况下,我们将匹配/并呈现组件,这绝不是我们想要的。 此类路由器要求我们完美地排序路由以获得预期结果。 这就是 React Router 在 v6 之前的工作方式,但现在它更加智能。

查看这些模式,直觉上知道我们希望/teams/new 与 URL /teams/new 匹配。 这是完全匹配!React Router 也知道这一点。 匹配时,它将根据 段数、静态段、动态段、星形模式等对路由进行排名,并选择最具体的匹配。

8. 没有 path 的路由:

奇怪路由:

<Route index element={<Home />} />
<Route index element={<LeagueStandings />} />
<Route element={<PageLayout />} />

它们甚至没有路径,它们怎么能成为路由呢? 这就是 React Router 中 "route" 这个词被比较宽泛地使用的地方。 <Home/><LeagueStandings/>索引路由(index routes), 而 <PageLayout/> 则是布局路由(layout route)。 这两者与路由匹配并没有太多关系。

9. 路由匹配

当路由与 URL 匹配时,它由一个匹配对象表示。 对于 <Route path=":teamId" element={<Team/>}/> 进行的匹配如下所示:

{
  pathname: "/teams/firebirds",
  params: {
    teamId: "firebirds"
  },
  route: {
    element: <Team />,
    path: ":teamId"
  }
}

pathname 保存与此路由匹配的 URL 片段(在案例中是全部 URL)。 params 保存与动态路径参数相关联的解析值。

注意,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",
    },
  },
];

10. 渲染

最终的概念是渲染。考虑应用程序的入口这样:

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>
);

再以 /teams/firebirds 路径为例。 <Routes> 将会匹配路由配置中的location, 得到一组匹配项,然后呈现像这样的 React 元素树:

<App>
  <Teams>
    <Team />
  </Teams>
</App>

在父级路由元素内呈现的每个匹配项都是一个非常强大的抽象。 大多数网站和应用程序都共享这个特征:每个页面子部分都是盒子中的盒子,每个子盒子包含一个导航部分,该部分更改页面的子部分。

react-router v5 就是这样做的

11. Outlets

Outlets(插座)是 React Router v6 中的一个重要概念,用于在路由中处理嵌套组件。

每个 Route 表示一个 URL 匹配模式,当 URL 与之匹配时将渲染相应的组件。 但如果有多层嵌套,如何在父级和子级之间传递渲染控制呢? 这就需要用到 Outlet 组件。

在下面的代码中,Routes 组件会自动渲染第一个匹配的组件(此例中为 App), 而 Teams 组件需要在其父级 App 组件中加入 Outlet 组件才能被正确渲染。

function App() {
  return (
    <div>
      <GlobalNav />
      <Outlet />
      <GlobalFooter />
    </div>
  );
}

Outlet 组件将始终呈现下一个匹配组件。 这意味着 <Teams> 组件也需要一个 Outlet 来呈现其子级 <Team/>

例如,当 URL 为 /teams/firebirds/edit 时,使用 Outlet 的元素树将如下所示:

<App>
  <Teams>
    <EditTeam />
  </Teams>
</App>

Outlet 组件将根据 URL 匹配路由,动态渲染与父级组件匹配的子级组件。这使得处理复杂的嵌套组件变得简单而直观。

12. 索引路由

在路由配置中设置/teams 路径,如果 url 为/teams/firebirds,则元素树将为:

<app>
  <teams>
    <team />
  </teams>
</app>

但是如果 url 为/teams,则元素树将为:

<app>
  <teams>
    <leaguestandings />
  </teams>
</app>

什么是<route index element={<leaguestandings>}/>

它之所以出现在这里,是因为它是一个索引路由。

索引路由在其父级路由的路径上渲染

这样想一下,如果不在子路由的路径上,<outlet>就不会在 ui 中呈现任何内容:

<app>
  <teams />
</app>

如果左侧有所有团队的列表,那么空的 outlet 意味着右侧有一个空白页! ui 需要用索引路由来填充这个空间。

另一种思考索引路由的方式是,当父路由匹配但其子路由没有匹配时,它是默认的子路由。

根据用户界面的不同,可能不需要索引路由,但如果父级路由中存在任何持续导航,当用户尚未点击其中任何一个项目时,很可能需要索引路由来填充空白的屏幕空间。

13. Layout Routes

这是尚未匹配的路由配置的一部分:/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 路由实际上有些奇怪.

称其为布局路由(Layout Routes),因为它根本不参与匹配(虽然它的子元素会). 它只是存在为了更简单地在相同的布局包裹多个子路由。 如果我们不允许这样做,那么你将不得不用两种不同的方式处理布局:有时你的路由为您处理,有时您需要手动进行大量布局组件在整个应用程序中的重复:

可以这样做,但是更建议使用布局路由:

<Routes>
  {/* ...略 */}
  <Route
    path="/privacy"
    element={
      <PageLayout>
        <Privacy />
      </PageLayout>
    }
  />
  <Route
    path="/tos"
    element={
      <PageLayout>
        <Tos />
      </PageLayout>
    }
  />
  <Route path="contact-us" element={<Contact />} />
</Routes>

14. 导航

当 url 改变时,称之为“导航”。

在 react router 中有两种导航方式:

  • <Link>
  • navigate

这是最常用的导航方法。 使用<link> 组件可让用户在单击链接时更改 url。 react router 将阻止浏览器的默认行为,并告诉浏览历史记录栈推入一个新条目。 location 发生变化后,新链接将渲染。

但是,这些链接仍然可以访问:

仍然呈现 <a href>,因此所有默认的辅助功能都可以得到满足(如键盘、焦点、seo 等) 如果右键或按下 command/ctrl+ 单击以“在新标签页中打开”,则不会阻止浏览器的默认行为。

嵌套路由不仅仅是关于呈现布局;它们还可以实现 “相对链接”

之前的「teams」路由:

<route path="teams" element={<teams />}>
  <route path=":teamid" element={<team />} />
</route>

<teams/> 组件可以像这样呈现链接:

<link to="psg" />
<link to="new" />

它链接到的完整路径将是 /teams/psg/teams/new

它们继承了它所呈现的路由。因此,你的路由组件不必真正了解应用程序中的其他路由。 大量链接只需深入一个段落即可。你可以重新排列整个路由配置,这些链接仍然可以正常工作。这在开始构建网站并且设计和布局会发生变化时非常有价值。

15. 导航函数

此功能是从 useNavigate 钩子返回的,它允许作为程序员在任何时候更改 URL。

例如,可以在超时后更改 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");

与链接和表单不同,除非有很好的理由需要更改 URL,否则很少使用导航函数,因为它会引入较大的无障碍性和用户预期方面的复杂性。

16. 访问数据:

最后,一个应用程序将要问 React Router 要求一些信息以便构建完整的 UI。

为此,React Router 有一堆钩子。

let location = useLocation();
let urlParams = useParams();
let [urlSearchParams] = useSearchParams();

17. 总结:

让我们从头开始把所有内容整合起来!

  1. 渲染应用:

    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>
    );
  2. <BrowserRouter> 创建一个历史记录,将初始location放入状态中,并订阅 URL。

  3. <Routes> 递归其子路由以构建路由配置,与location进行匹配,创建一些路由匹配,并呈现第一个匹配的路由元素。

  4. 在每个父级路由中呈现 <Outlet/>

  5. <Outlet/>会呈现路由匹配中的下一个元素。

  6. 用户点击链接

  7. 该链接调用 navigate()

  8. 历史记录更改 URL 并通知 <BrowserRouter>

  9. <BrowserRouter> 重新呈现,重新从 2 开始