---
title: 路由
sidebar_position: 1
---

# 路由

Modern.js 的路由基于 [React Router 6](https://reactrouter.com/en/main)，并提供了多种类型的路由模式。根据不同 [入口](/guides/concept/entries) 类型，将路由分为三种模式，分别是**约定式路由**，**自控式路由**和**其他路由方案**。

:::note
本小节提到的路由，都是客户端路由，即 SPA 路由。

:::

## 约定式路由

以 `routes/` 为约定的入口，Modern.js 会自动基于文件系统，生成对应的路由结构。

Modern.js 支持了业界流行的约定式路由模式：**嵌套路由**，使用嵌套路由时，页面的路由 与 UI 结构是相呼应的，我们将会详细介绍这种路由模式。

```bash
/user/johnny/profile                  /user/johnny/posts
+------------------+                  +-----------------+
| User             |                  | User            |
| +--------------+ |                  | +-------------+ |
| | Profile      | |  +------------>  | | Posts       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+
```

### 路由文件约定

在`routes/` 目录下，目录名会作为路由 url 的映射，Modern.js 有两个文件约定 `layout.[jt]sx` 和 `page.[jt]sx`（后面简写为 `.tsx`）。这两个文件决定了应用的布局层次，其中 `layout.tsx` 中作为布局组件，`page.tsx` 作为内容组件，是整条路由的叶子节点（一条路由有且仅有一个叶子节点，且必须以叶子节点结尾）。

例如以下目录结构：

```bash
.
└── routes
    ├── page.tsx
    └── user
        └── page.tsx
```

会产出下面两条路由：

- `/`
- `/user`

当添加 `layout.tsx` 后， 假设有以下目录

:::info
这里 `routes/layout.tsx` 会作为 `/` 路由下所有组件的布局组件使用， `routes/user/layout.tsx` 会作为 `/user` 路由下所有路由组件的布局组件使用。

:::

```bash
.
└── routes
    ├── layout.tsx
    ├── page.tsx
    └── user
        ├── layout.tsx
        └── page.tsx
```

当路由为 `/` 时，会有以下 UI 布局：

```tsx
<Layout>
  <Page />
</Layout>
```

同样，`routes/user/layout.tsx` 会作为 `/user` 路由下所有组件的布局组件使用。当路由为 `/user` 时， 会有以下 UI 布局：

```tsx
<Layout>
  <UserLayout>
    <UserPage />
  </UserLayout>
</Layout>
```

#### Layout

`<Layout>` 组件是指 `routes/` 目录下所有 `layout.tsx` 文件，它们表示对应路由片段的布局，使用 `<Outlet>` 表示子组件。

```tsx title=routes/layout.tsx
import { Link, Outlet, useLoaderData } from '@modern-js/runtime/router';

export default () => {
  return (
    <>
      <Outlet></Outlet>
    </>
  );
};
```

:::note
`<Outlet>` 是 React Router 6 中新的 API，详情可以查看 [Outlet](https://reactrouter.com/en/main/components/outlet#outlet).

:::

为了方便介绍 `<Layout>` 与 `<Outlet>` 的关系，以下面的文件目录举例：

```bash
.
└── routes
    ├── blog
    │   └── page.tsx
    ├── layout.tsx
    ├── page.tsx
    └── user
        ├── layout.tsx
        └── page.tsx
```

1. 当路由为 `/` 时，`routes/layout.tsx` 中的 `<Outlet>` 代表的是 `routes/page.tsx` 中导出的组件，生成以下 UI 结构：

```tsx
<Layout>
  <Page />
</Layout>
```

2. 当路由为 `/blog` 时，`routes/layout.tsx` 中的 `<Outlet>` 代表的是 `routes/blog/page.tsx` 中导出的组件，生成以下 UI 结构：

```tsx
<Layout>
  <BlogPage />
</Layout>
```

3. 当路由为 `/user` 时，`routes/layout.tsx` 中的 `<Outlet>` 代表的是 `routes/user/layout.tsx` 中导出的组件。`routes/user/layout.tsx` 中的 `<Outlet>` 代表的是 `routes/user/page.tsx` 中导出的组件。生成以下 UI 结构：

```tsx
<Layout>
  <UserLayout>
    <UserPage />
  </UserLayout>
</Layout>
```

总结而言，如果子路由的文件目录下存在 `layout.tsx`，上一级 `layout.tsx` 中的 `<Outlet>` 即为子路由文件目录下的 `layout.tsx` ，否则为子路由文件目录下的 `page.tsx`。

#### Page

所有的路由，理论上都应该由 `<Page>` 组件结束。在 `page.tsx` 文件内，如果开发者引入 `<Outlet>` 组件，不会有任何效果。

### 动态路由

通过 `[]` 命名的文件目录，生成的路由会作为动态路由。例如以下文件目录：

```bash
└── routes
    ├── [id]
    │   └── page.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx
```

`routes/[id]/page.tsx` 文件会转为 `/:id` 路由。除了可以确切匹配的 `/blog` 路由，其他所有 `/xxx` 都会匹配到该路由。

在组件中，可以通过 [useParams](/apis/app/runtime/router/router#useparams) 获取对应命名的参数。

在 loader 中，params 会作为 [loader](/guides/basic-features/data-fetch#loader-函数) 的入参，通过 `params.xxx` 可以获取。

### 动态可选路由

通过 `[$]` 命名的文件目录，生成的路由会作为动态路由。例如以下文件目录：

```bash
└── routes
    ├── user
    │   └── [id$]
    │       └── page.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx
```

`routes/user/[id$]/page.tsx` 文件会转为 `/user/:id?` 路由。`/user` 下的所有路由都会匹配到该路由，并且 `id` 参数可选存在。通常在区分**创建**于**编辑**时，可以使用该路由。

在组件中，可以通过 [useParams](/apis/app/runtime/router/router#useparams) 获取对应命名的参数。

在 loader 中，params 会作为 [loader](/guides/basic-features/data-fetch#loader-函数) 的入参，通过 `params.xxx` 可以获取。

### 通配路由

如果在 routes 目录下创建 `$.tsx` 文件，该文件会作为通配路由组件，当没有匹配的路由时，会渲染该路由组件。

:::note
`$.tsx` 可以认为是一种特殊的 `page` 路由组件，当前目录下有 `layout` 组件时，`$.tsx`，会作为 `layout` 的子组件渲染。
:::

例如以下目录结构：

```bash
└── routes
    ├── $.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx
```

当访问任何匹配不到的路径时，都会渲染 `routes/$.tsx` 组件，同样，`$.tsx` 中可以使用 [useParams](/apis/app/runtime/router/router#useparams) 捕获 url 的剩余部分。

```ts title="$.tsx"
import { useParams } from '@modern-js/runtime/router';
// 当 path 是 `/aaa/bbb` 时
const params = useParams();
params['*']; // => 'aaa/bbb'
```

`$.tsx` 可以加入到 `routes` 目录下的任意目录中，一个常见的使用示例是添加 `routes/$.tsx` 文件去定制任意层级的 404 页面。

### 无路径布局

当目录名以 \_\_ 开头时，对应的目录名不会转换为实际的路由路径，例如以下文件目录：

```bash
.
└── routes
    ├── __auth
    │   ├── layout.tsx
    │   ├── login
    │   │   └── page.tsx
    │   └── signup
    │       └── page.tsx
    ├── layout.tsx
    └── page.tsx
```

Modern.js 会生成 `/login` 和 `/sign` 两条路由，`__auth/layout.tsx` 组件会作为 `login/page.tsx` 和 `signup/page.tsx` 的布局组件，但`__auth` 不会作为路由路径片段。

当需要为某些类型的路由，做独立的布局，或是想要将路由做归类时，这一功能非常有用。

### 无布局路径

有些情况下，项目需要较为复杂的路由，但这些路由又不存在独立的 UI 布局，如果像普通文件目录那边创建路由会导致目录层级较深。

因此 Modern.js 支持了通过 `.` 来分割路由片段，代替文件目录。例如，当需要 `/user/profile/2022/edit` 时，可以直接创建如下文件：

```bash
└── routes
    ├── user.profile.[id].edit
    │      └── page.tsx
    ├── layout.tsx
    └── page.tsx
```

访问路由时，将得到如下 UI 布局：

```tsx
<RootLayout>
  <UserProfileEdit /> // routes/user.profile.[id].edit/page.tsx
</RootLayout>
```

### (WIP)Loading

`routes/` 下每一层目录中，开发者可以创建 `loading.tsx` 文件，默认导出一个 `<Loading>` 组件。

当路由目录下存在该组件和 `layout` 组件时，这一级子路由下所有的路由切换时，都会以该 `<Loading>` 组件作为 JS Chunk 加载时的 Fallback UI。例如以下文件目录：

```bash
.
└── routes
    ├── blog
    │   ├── [id]
    │   │   └── page.tsx
    │   └── page.tsx
    ├── layout.tsx
    ├── loading.tsx
    └── page.tsx
```

当定义 `loading.tsx` 时，就相当于以下布局：

```tsx title=当路由为"/"时
<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>
```

```tsx title=当路由为"/blog"时
<Layout>
  <Suspense fallback={<Loading />}>
    <BlogPage />
  </Suspense>
</Layout>
```

```tsx title=当路由为"/blog/123"时
<Layout>
  <Suspense fallback={<Loading />}>
    <BlogIdPage />
  </Suspense>
</Layout>
```

:::info
当目录的 Layout 组件不存在时，该目录下的 Loading 组件也不会生效。
Modern.js 建议必须有根 Layout 和根 Loading。

:::

当路由从 `/` 跳转到 `/blog` 时，如果 `blog/page` 组件的 JS Chunk 还未加载，则会先展示 `loading.tsx` 中导出的组件 UI。

同理，当路由从 `/` 或者 `/blog` 跳转到 `/blog/123` 时，如果 `blog/[id]/page` 组件的 JS Chunk 还未加载，也会先展示 `loading.tsx` 中导出的组件 UI。

### 路由重定向

可以通过创建 [`Data Loader`](/guides/basic-features/data-fetch) 文件做路由的重定向，如有文件 `routes/user/page.tsx`，想对这个文件对应的路由做重定向，可以创建 `routes/user/page.loader.ts` 文件：

```ts title="routes/user/page.loader.ts"
import { redirect } from '@modern-js/runtime/router';

export default () => {
  const user = await getUser();
  if (!user) {
    return redirect('/login');
  }
  return null;
};
```

### 错误处理

`routes/` 下每一层目录中，开发者同样可以定义一个 `error.tsx` 文件，默认导出一个 `<ErrorBoundary>` 组件。

当有路由目录下存在该组件时，组件渲染出错会被 `ErrorBoundary` 组件捕获。当目录未定义 `layout.tsx` 文件时，`<ErrorBoundary>` 组件不会生效。

`<ErrorBoundary>` 可以返回出错时的 UI 视图，当前层级未声明 `<ErrorBoundary>` 组件时，错误会向上冒泡到更上层的组件，直到被捕获或抛出错误。同时，当组件出错时，只会影响捕获到该错误的路由组件及子组件，其他组件的状态和视图不受影响，可以继续交互。

{/* Todo API 路由 */}

在 `<ErrorBoundary>` 组件内，可以使用 [useRouteError](/apis/app/runtime/router/router#userouteerror) 获取的错误的具体信息：

```tsx
import { useRouteError } from '@modern-js/runtime/router';
const ErrorBoundary = () => {
  const error = useRouteError();
  return (
    <div>
      <h1>{error.status}</h1>
      <h2>{error.message}</h2>
    </div>
  );
};
export default ErrorBoundary;
```

### 运行时配置

在每个根 `Layout` 组件中(`routes/layout.ts`)，可以动态地定义运行时配置：

```tsx title="src/routes/layout.tsx"
// 定义运行时配置
import type { AppConfig } from '@modern-js/runtime';

export const config = (): AppConfig => {
  return {
    router: {
      createRoutes() {
        return [
          {
            path: 'modern',
            element: <div>modern</div>,
          },
        ];
      },
    },
  };
};
```

### 渲染前的钩子

在有些场景下，需要在应用渲染前做一些操作，可以在 `routes/layout.tsx` 中定义 `init` 钩子，`init` 在客户端和服务端均会执行，基本使用示例如下：

```ts title="src/routes/layout.tsx"
import type { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  // do something
};
```

通过 `init` 钩子可以挂载一些全局的数据，在应用的其他地方可以访问 `runtimeContext` 变量：

:::note
该功能在应用需要页面前置的数据、自定义数据注入或是框架迁移（如 Next.js）时会非常有用。

:::

```ts title="src/routes/layout.tsx"
import { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  return {
    message: 'Hello World',
  };
};
```

```tsx title="src/routes/page.tsx"
import { useRuntimeContext } from '@modern-js/runtime';

export default () => {
  const { context } = useRuntimeContext();
  const { message } = context.getInitData();

  return <div>{message}</div>;
};
```

配合 SSR 功能时，浏览器端可以获取到 SSR 时 `init` 返回的数据，开发者可以自行判断是否要在浏览器端重新获取数据来覆盖 SSR 数据，例如：

```tsx title="src/routes/layout.tsx"
import { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  if (process.env.MODERN_TARGET === 'node') {
    return {
      message: 'Hello World By Server',
    };
  } else {
    const { context } = runtimeContext;
    const data = context.getInitData();
    // 如果没有获取到期望的数据
    if (!data.message) {
      return {
        message: 'Hello World By Client',
      };
    }
  }
};
```

### 预加载

在约定式路由下， Modern.js 会根据路由，自动地对路由进行分片，当用户访问具体的路由时，会自动加载对应的分片，这样可以有效地减少首屏加载的时间。但这也带来了一个问题，当用户访问一个路由时，如果该路由对应的分片还未加载完成，就会出现白屏的情况。
这种情况下你可以通过定义 `Loading` 组件，在静态资源加载完成前，展示一个自定义的 `Loading` 组件。

为了进一步提升用户体验，减少 loading 的时间，Modern.js 支持在 Link 组件上定义 `prefetch` 属性，可以提前对静态资源和数据进行加载：

```tsx
<Link prefetch="intent" to="page">
```

:::info
- 该功能目前仅在 Webpack 项目中支持，Rspack 项目暂不支持。
- 对数据的预加载目前只会预加载 SSR 项目中 [Data Loader](/guides/basic-features/data-fetch) 中返回的数据。

:::

`prefetch` 属性有三个可选值：

- `none`， 默认值，不会做 prefetch，没有任何额外的行为。
- `intent`，这是我们推荐大多数场景下使用的值，当你把鼠标放在 Link 上时，会自动开始加载对应的分片和 Data Loader 中定义的数据，当鼠标移开时，会自动取消加载。在我们的测试中，即使是快速点击，也能减少大约 200ms 的加载时间。
- `render`，当 Link 组件渲染时，就会加载对应的分片和 Data Loader 中定义的数据。

#### 常见问题

1. 使用 `render` 和不根据路由做静态资源分片的区别？

- 使用 `render` 可以指定哪些路由在首屏时，进行加载，同时你可以通过对渲染的控制，仅当 `Link` 组件进入到可视区域时，才对 `Link` 组件进行渲染。
- 使用 `render`，仅在空闲时对静态资源进行加载，不会与首屏静态资源抢占网络。
- 在 SSR 场景下，也会对数据进行预取。

import Motivation from '@site-docs/components/convention-routing-motivation'

<Motivation/>

import Practice from '@site-docs/components/routes-practice'

<Practice/>


## 自控式路由

以 `src/App.tsx` 为约定的入口，Modern.js 不会对路由做额外的操作，开发者可以自行使用 [React Router 6](https://reactrouter.com/en/main) 的 API 进行开发，例如：

```ts title="src/App.tsx"
import { BrowserRouter, Route, Routes } from '@modern-js/runtime/router';

export default () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<div>index</div>} />
        <Route path="about" element={<div>about</div>} />
      </Routes>
    </BrowserRouter>
  );
};
```

:::note
Modern.js 默认对约定式路由做了一系列资源加载及渲染上的优化，并且提供了开箱即用的 SSR 能力，而这些能力，在使用自控路由时，都需要开发者自行封装，推荐开发者使用约定式路由。
:::

## 其他路由方案

默认情况下，Modern.js 会开启内置的路由方案，即 React Router。

```js
export default defineConfig({
  runtime: {
    router: true,
  },
});
```

如上述配置，当开启 [`runtime.router`](/configure/app/runtime/router) 配置时，Modern.js 会从 `@modern-js/runtime/router` 命名空间导出 React Router 的 API 供开发者使用，保证开发者和 Modern.js 中使用同一份代码，并自动根据 router 配置包裹 `Provider` 组件。另外，这种情况下，React Router 的代码会被打包到 JS 产物中。

如果项目已经有自己的路由方案，或者不需要使用客户端路由，可以关闭这个功能。

```js
export default defineConfig({
  runtime: {
    router: false,
  },
});
```

如上述配置， 如果关闭了 [`runtime.router`](/configure/app/runtime/router) 配置，并直接使用 `react-router-dom` 进行项目路由管理时，还需要根据 React Router 文档自行包裹 `Provider`。


