Modern.js 的路由基于 React Router 6,并提供了多种类型的路由模式。根据不同 入口 类型,将路由分为三种模式,分别是约定式路由,自控式路由和其他路由方案。
本小节提到的路由,都是客户端路由,即 SPA 路由。
以 routes/
为约定的入口,Modern.js 会自动基于文件系统,生成对应的路由结构。
Modern.js 支持了业界流行的约定式路由模式:嵌套路由,使用嵌套路由时,页面的路由 与 UI 结构是相呼应的,我们将会详细介绍这种路由模式。
/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
作为内容组件,是整条路由的叶子节点(一条路由有且仅有一个叶子节点,且必须以叶子节点结尾)。
例如以下目录结构:
.
└── routes
├── page.tsx
└── user
└── page.tsx
会产出下面两条路由:
/
/user
当添加 layout.tsx
后, 假设有以下目录
这里 routes/layout.tsx
会作为 /
路由下所有组件的布局组件使用, routes/user/layout.tsx
会作为 /user
路由下所有路由组件的布局组件使用。
.
└── routes
├── layout.tsx
├── page.tsx
└── user
├── layout.tsx
└── page.tsx
当路由为 /
时,会有以下 UI 布局:
<Layout>
<Page />
</Layout>
同样,routes/user/layout.tsx
会作为 /user
路由下所有组件的布局组件使用。当路由为 /user
时, 会有以下 UI 布局:
<Layout>
<UserLayout>
<UserPage />
</UserLayout>
</Layout>
<Layout>
组件是指 routes/
目录下所有 layout.tsx
文件,它们表示对应路由片段的布局,使用 <Outlet>
表示子组件。
import { Link, Outlet, useLoaderData } from '@modern-js/runtime/router';
export default () => {
return (
<>
<Outlet></Outlet>
</>
);
};
<Outlet>
是 React Router 6 中新的 API,详情可以查看 Outlet.
为了方便介绍 <Layout>
与 <Outlet>
的关系,以下面的文件目录举例:
.
└── routes
├── blog
│ └── page.tsx
├── layout.tsx
├── page.tsx
└── user
├── layout.tsx
└── page.tsx
/
时,routes/layout.tsx
中的 <Outlet>
代表的是 routes/page.tsx
中导出的组件,生成以下 UI 结构:<Layout>
<Page />
</Layout>
/blog
时,routes/layout.tsx
中的 <Outlet>
代表的是 routes/blog/page.tsx
中导出的组件,生成以下 UI 结构:<Layout>
<BlogPage />
</Layout>
/user
时,routes/layout.tsx
中的 <Outlet>
代表的是 routes/user/layout.tsx
中导出的组件。routes/user/layout.tsx
中的 <Outlet>
代表的是 routes/user/page.tsx
中导出的组件。生成以下 UI 结构:<Layout>
<UserLayout>
<UserPage />
</UserLayout>
</Layout>
总结而言,如果子路由的文件目录下存在 layout.tsx
,上一级 layout.tsx
中的 <Outlet>
即为子路由文件目录下的 layout.tsx
,否则为子路由文件目录下的 page.tsx
。
所有的路由,理论上都应该由 <Page>
组件结束。在 page.tsx
文件内,如果开发者引入 <Outlet>
组件,不会有任何效果。
通过 []
命名的文件目录,生成的路由会作为动态路由。例如以下文件目录:
└── routes
├── [id]
│ └── page.tsx
├── blog
│ └── page.tsx
└── page.tsx
routes/[id]/page.tsx
文件会转为 /:id
路由。除了可以确切匹配的 /blog
路由,其他所有 /xxx
都会匹配到该路由。
在组件中,可以通过 useParams 获取对应命名的参数。
在 loader 中,params 会作为 loader 的入参,通过 params.xxx
可以获取。
通过 [$]
命名的文件目录,生成的路由会作为动态路由。例如以下文件目录:
└── routes
├── user
│ └── [id$]
│ └── page.tsx
├── blog
│ └── page.tsx
└── page.tsx
routes/user/[id$]/page.tsx
文件会转为 /user/:id?
路由。/user
下的所有路由都会匹配到该路由,并且 id
参数可选存在。通常在区分创建于编辑时,可以使用该路由。
在组件中,可以通过 useParams 获取对应命名的参数。
在 loader 中,params 会作为 loader 的入参,通过 params.xxx
可以获取。
如果在 routes 目录下创建 $.tsx
文件,该文件会作为通配路由组件,当没有匹配的路由时,会渲染该路由组件。
$.tsx
可以认为是一种特殊的 page
路由组件,当前目录下有 layout
组件时,$.tsx
,会作为 layout
的子组件渲染。
例如以下目录结构:
└── routes
├── $.tsx
├── blog
│ └── page.tsx
└── page.tsx
当访问任何匹配不到的路径时,都会渲染 routes/$.tsx
组件,同样,$.tsx
中可以使用 useParams 捕获 url 的剩余部分。
import { useParams } from '@modern-js/runtime/router';
// 当 path 是 `/aaa/bbb` 时
const params = useParams();
params['*']; // => 'aaa/bbb'
$.tsx
可以加入到 routes
目录下的任意目录中,一个常见的使用示例是添加 routes/$.tsx
文件去定制任意层级的 404 页面。
当目录名以 __ 开头时,对应的目录名不会转换为实际的路由路径,例如以下文件目录:
.
└── 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
时,可以直接创建如下文件:
└── routes
├── user.profile.[id].edit
│ └── page.tsx
├── layout.tsx
└── page.tsx
访问路由时,将得到如下 UI 布局:
<RootLayout>
<UserProfileEdit /> // routes/user.profile.[id].edit/page.tsx
</RootLayout>
routes/
下每一层目录中,开发者可以创建 loading.tsx
文件,默认导出一个 <Loading>
组件。
当路由目录下存在该组件和 layout
组件时,这一级子路由下所有的路由切换时,都会以该 <Loading>
组件作为 JS Chunk 加载时的 Fallback UI。例如以下文件目录:
.
└── routes
├── blog
│ ├── [id]
│ │ └── page.tsx
│ └── page.tsx
├── layout.tsx
├── loading.tsx
└── page.tsx
当定义 loading.tsx
时,就相当于以下布局:
<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>
<Layout>
<Suspense fallback={<Loading />}>
<BlogPage />
</Suspense>
</Layout>
<Layout>
<Suspense fallback={<Loading />}>
<BlogIdPage />
</Suspense>
</Layout>
当目录的 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
文件做路由的重定向,如有文件 routes/user/page.tsx
,想对这个文件对应的路由做重定向,可以创建 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>
组件时,错误会向上冒泡到更上层的组件,直到被捕获或抛出错误。同时,当组件出错时,只会影响捕获到该错误的路由组件及子组件,其他组件的状态和视图不受影响,可以继续交互。
在 <ErrorBoundary>
组件内,可以使用 useRouteError 获取的错误的具体信息:
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
),可以动态地定义运行时配置:
// 定义运行时配置
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
在客户端和服务端均会执行,基本使用示例如下:
import type { RuntimeContext } from '@modern-js/runtime';
export const init = (context: RuntimeContext) => {
// do something
};
通过 init
钩子可以挂载一些全局的数据,在应用的其他地方可以访问 runtimeContext
变量:
该功能在应用需要页面前置的数据、自定义数据注入或是框架迁移(如 Next.js)时会非常有用。
import { RuntimeContext } from '@modern-js/runtime';
export const init = (context: RuntimeContext) => {
return {
message: 'Hello World',
};
};
import { useRuntimeContext } from '@modern-js/runtime';
export default () => {
const { context } = useRuntimeContext();
const { message } = context.getInitData();
return <div>{message}</div>;
};
配合 SSR 功能时,浏览器端可以获取到 SSR 时 init
返回的数据,开发者可以自行判断是否要在浏览器端重新获取数据来覆盖 SSR 数据,例如:
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
属性,可以提前对静态资源和数据进行加载, prefetch
属性有三个可选值:
<Link prefetch="intent" to="page">
none
, 默认值,不会做 prefetch,没有任何额外的行为。intent
,这是我们推荐大多数场景下使用的值,当你把鼠标放在 Link 上时,会自动开始加载对应的分片和 Data Loader 中定义的数据,当鼠标移开时,会自动取消加载。在我们的测试中,即使是快速点击,也能减少大约 200ms 的加载时间。render
,当 Link 组件渲染时,就会加载对应的分片和 Data Loader 中定义的数据。render
和不根据路由做静态资源分片的区别?render
可以指定哪些路由在首屏时,进行加载,同时你可以通过对渲染的控制,仅当 Link
组件进入到可视区域时,才对 Link
组件进行渲染。render
,仅在空闲时对静态资源进行加载,不会与首屏静态资源抢占网络。以 src/App.tsx
为约定的入口,Modern.js 不会对路由做额外的操作,开发者可以自行使用 React Router 6 的 API 进行开发,例如:
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>
);
};
Modern.js 默认对约定式路由做了一系列资源加载及渲染上的优化,并且提供了开箱即用的 SSR 能力,而这些能力,在使用自控路由时,都需要开发者自行封装,推荐开发者使用约定式路由。
使用自控式路由时,如果开发者关闭了 runtime.router
配置,并直接使用 react-router-dom
,那还需要根据 React Router 文档自行包裹 Provider
。
默认情况下,Modern.js 会开启内置的路由方案,即 React Router。
export default defineConfig({
runtime: {
router: true,
},
});
Modern.js 从 @modern-js/runtime/router
命名空间暴露了 React Router 的 API 供开发者使用,保证开发者和 Modern.js 中使用同一份代码。另外,这种情况下,React Router 的代码会被打包到 JS 产物中。如果项目已经有自己的路由方案,或者不需要使用客户端路由,可以关闭这个功能。
export default defineConfig({
runtime: {
router: false,
},
});