我将在这篇文章中探讨用于 React 的新路由器 TanStack Router
(1.0 版本于 2023 年 12 月发布)。
引发我好奇心的核心功能是,使用 TanStack Router
,你的路由是类型安全的,可以说,TanStack Router
是一个全新的 React 类型安全路由库。比如,你编写的代码导航到了一个不存在的路由,Typescript 会告诉你。
此外,TanStack Router
还可以成为一个实用的状态管理器,你可以通过 URL 与其他人共享!对于带有大量过滤器的搜索页面或带有许多可配置部件的仪表盘来说,TanStack Router
是一个很好的工具。如果你的用户需要共享链接或将其保存在浏览器的书签中,这会非常有用。
项目初始化
项目配置
新建一个项目:
yaml
# 1. 初始化React项目
pnpm create vite@latest tanstack-router-demo -- --template react-ts
# 2. 安装tanstack router 和 vite 插件
pnpm install @tanstack/react-router
pnpm install --save-dev @tanstack/router-vite-plugin
@tanstack/router-vite-plugin
插件会自动为你生成路由文件的定义,需要再 vite.config.ts
中进行设置,如下所示:
typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), TanStackRouterVite()],
});
路由
现在,来创建两个路由:一个主页和一个显示用户配置文件的页面。在创建这两个页面之前,我们需要定义路由的根目录。需要注意的是:路由必须位于 src/routes
文件夹中,然后创建文件,树形结构如下:
md
src
├── routes
│ ├── __root.tsx
│ ├── index.tsx
│ └── profile.tsx
使用 pnpm run dev
运行项目,你就会看到,一个包含路由定义的新文件src/routeTree.gen.ts
。 在 index.tsx
和 profile.tsx
中会生成一些内容:
tsx
// index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: () => <div>Hello /!</div>
})
tsx
// profile.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/profile')({
component: () => <div>Hello /profile!</div>,
});
你会发现 __root.tsx
文件还是空的。在该文件中使用 <Outlet />
组件定义路由的根:
tsx
import { Outlet, createRootRoute } from '@tanstack/react-router';
export const Route = createRootRoute({
component: () => <Outlet />,
});
Provider
上面已经定义了路由,但 React 如何才能知道它们呢?在 App.tsx
文件中:
tsx
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import './App.css';
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
function App() {
return <RouterProvider router={router} />;
}
export default App;
通过 createRouter
创建了路由器,并将自动生成的路由树传给了路由器,我们扩展了 Register
接口以包含我们的类型,还创建了一个 RouterProvider
,使应用程序的其他部分也能使用路由器。
导航
运行应用程序并导航至 http://localhost:5173/profile
,你将看到 "Hello /profile!"
。正常工作!
但如何在应用程序中导航到它们呢?让我们回到 __root.tsx
并添加几个链接:
tsx
import { Link, Outlet, createRootRoute } from '@tanstack/react-router';
export const Route = createRootRoute({
component: () => (
<>
<h1>My App</h1>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/profile">Profile</Link>
</li>
</ul>
<Outlet />
</>
),
});
现在,可以通过点击链接在两个页面之间浏览。注意,在配置 Link
组件的 to
时,你可以使用自动完成和类型安全功能。
激活路由
TanStack Router
的 Link
组件有一个属性 activeProps
,当链接处于激活状态时,可以使用它向链接传递一些 props
:
tsx
<Link to="/" activeProps={{ style: { fontWeight: 'bold' } }}>
Home
</Link>
现在,当你在主页上时,链接将以粗体显示。
自定义激活状态
你甚至可以通过向链接组件传递一个函数来进一步自定义活动状态,因为默认情况下,你可以使用一个 isActive
参数来检查链接是否处于活动状态。例如
tsx
<Link to="/profile">{({ isActive }) => <>Profile {isActive && '~'}</>}</Link>
在这种情况下,只有当链接处于活动状态时,会看到 ~
字符。重构一下 __root.tsx
文件:
tsx
import { Link, Outlet, createRootRoute } from '@tanstack/react-router';
const activeProps = {
style: {
fontWeight: 'bold',
},
};
export const Route = createRootRoute({
component: () => (
<>
<h1>My App</h1>
<ul>
<li>
<Link to="/" activeProps={activeProps}>
Home
</Link>
</li>
<li>
<Link to="/profile" activeProps={activeProps}>
{({ isActive }) => <>Profile {isActive && '~'}</>}
</Link>
</li>
</ul>
<Outlet />
</>
),
});
Path 参数和 Loader
Path 参数
比如,我们通常访问一个链接:https://www.example.com/user/yourusername
。yourusername
部分就是一个路径参数,用于告诉应用程序你想查看该特定用户的页面。
增加一个新路由
在 src/routes/pokemon/$id.tsx
中创建一个新文件。你已经看到路径中有一个 $id
,它将在 URL
中被替换为 id
。
获取 Path 参数
现在,我们可以通过 useParams 读取参数:
tsx
export const Route = createFileRoute('/pokemon/$id')({
component: Pokemon,
});
function Pokemon() {
const { id } = Route.useParams();
return <div>Pokemon {id}</div>;
}
如果你现在导航到 http://localhost:5173/pokemon/1
,应该会在屏幕上看到 "口袋妖怪 1"。
Loader
假设我们想在渲染页面之前从应用程序接口获取pokemon
数据。为此,我们可以使用 loader
。
获取数据
你可以在 src/api/pokemon.ts
文件中创建一个 API
:
tsx
type PokemonDetail = {
name: string;
id: number;
height: number;
weight: number;
sprites: {
front_default: string;
};
};
export async function getPokemon(id: string): Promise<PokemonDetail> {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
return await response.json();
}
这里写了一个函数 getPokemon,我们可以用它来获取特定 pokemon
的数据。
使用 loader
createFileRoute
函数的参数之一是 loader
。这是一个在渲染路由之前会被调用的函数,可用于获取数据。可以更新路由对象,使其包含 loader
:
tsx
import { createFileRoute } from '@tanstack/react-router';
import { getPokemon } from '../../api/pokemon';
export const Route = createFileRoute('/pokemon/$id')({
component: Pokemon,
loader: async ({ params }) => await getPokemon(params.id),
});
function Pokemon() {
const { id } = Route.useParams();
return <div>Pokemon {id}</div>;
}
如果现在刷新页面,就可以在网络选项卡中看到数据正从应用程序接口获取,Route 也提供了一个 useLoaderData
钩子在组件中使用数据。
tsx
const pokemon = Route.useLoaderData();
现在,你可以使用口袋妖怪对象在屏幕上渲染口袋妖怪数据,下面是一个例子:
tsx
function Pokemon() {
const { id } = Route.useParams();
const pokemon = Route.useLoaderData();
console.log(pokemon);
return (
<div>
<h2>
({id}) {pokemon.name}
</h2>
<img src={pokemon.sprites.front_default} alt={pokemon.name} />
<dl>
<dt>Height</dt>
<dd>{pokemon.height}</dd>
<dt>Weight</dt>
<dd>{pokemon.weight}</dd>
</dl>
</div>
);
}
导航到带参数的路由
当你试图导航到一个带参数的路由时,你不能直接设置一个类似 /pokemon/1
或 /pokemon/${pokemonId}
的 url
。例如,TanStack Router
会强制你以 /pokemon/$id
的形式传递路由定义,然后在 params
对象中指定参数:
❌ WRONG:
tsx
<Link to={`/pokemon/${pokemon.id}`}>{pokemon.name}</Link>
✅ CORRECT:
tsx
<Link to={'/pokemon/$id'} params={{ id: pokemon.id }}>
{pokemon.name}
</Link>
这能确保你传递正确的参数,同时还能确保自动完成和类型安全。
查询参数和校验器
我们将了解如何正确解析和处理查询参数(又称搜索参数),以及如何将它们有效地用作状态管理器。此外,想象一下只需传递 URL 就能共享状态,多方便啊!
定义和验证查询参数
想象这么一个场景:在一个搜索页面或表格中,有很多用于过滤数据的输入,或者在一个仪表盘中,有很多可配置的部件。你可以通过设置这些筛选器来创建自己的自定义视图,但只要你刷新页面,这些筛选器的状态就会被重置为初始值。
当然,你也可以将它们保存在本地存储或浏览器中的某个地方,但是... 如果你想将完全相同的配置发送给其他人呢?朋友?同事?直接将设置作为 URL 中的查询参数可能是最简单的方法,但手动处理这些设置又有点麻烦。
TanStack Router
在设计时就考虑到了这种情况,让你可以很好地控制查询参数(又称搜索参数)。
创建路由
我们先创建一个 /search.tsx
空页面,然后在 __root.tsx
中添加指向它的链接。
tsx
<Link to="/search" activeProps={activeProps}>
Search
</Link>
定义类型
现在,如果我们想让 typescript 帮助我们执行和验证正确的类型,就需要创建它们。
tsx
type ItemFilters = {
query: string;
hasDiscount: boolean;
categories: Category[];
};
type Category = 'electronics' | 'clothing' | 'books' | 'toys';
写校验函数
定义好这些参数后,我们应该告诉 TanStack Router
我们的 /search
路由包含这些参数。我们可以通过实现和 validateSearch
函数来做到这一点。
在 search.tsx
文件中:
tsx
export const Route = createFileRoute("/search")({
component: Search,
validateSearch: (search: Record<string, unknown>): ItemFilters => {
return {
query: search.query as string,
hasDiscount: search.hasDiscount === 'true',
categories: search.categories as Category[],
};
},
});
function Search() {
return (<div>Hello /search!</div>);
}
读取参数
可以在组件中使用 Route 对象,并从中获取一些钩子。更新你的搜索组件:
tsx
function Search() {
const { query, hasDiscount, categories } = Route.useSearch();
return <pre>{JSON.stringify({ query, hasDiscount, categories }, null, 2)}</pre>;
}
带参数导航
现在,如果你注意到了,___root.tsx
上应该有一个错误,意思是 Path
有一些必须提供的参数,修改一下:
tsx
<Link
to="/search"
activeProps={activeProps}
search={{
query: 'hello',
hasDiscount: true,
categories: ['electronics', 'clothing'],
}}
>
Search
</Link>
错误已消除!现在可以在浏览器上点击链接了。现在你可以看到一个页面,其中的参数已渲染为 JSON 格式。
用校验库来做校验
我们定义的这种验证函数是有效的,但我们可以通过使用现有的验证库(如 zod
或 valibot
)做得更好,这里我们以 valibot
为例。
先安装:
tsx
pnpm i valibot@0.29.0
定义 schema
现在可以用 valibot
架构替换你的类型:
tsx
import * as v from 'valibot';
const Category = v.union([v.literal('electronics'), v.literal('clothing'), v.literal('books'), v.literal('toys')]);
const ItemFilters = v.object({
query: v.optional(v.string()),
hasDiscount: v.optional(v.boolean()),
categories: v.optional(v.array(Category)),
});
type ItemFilters = v.Output<typeof ItemFilters>;
type Category = v.Output<typeof Category>;
这样,验证功能就会变得非常简单:
tsx
validateSearch: (search) => v.parse(ItemFilters, search),
查询参数作为应用程序状态
TanStack Router 很好的一个功能是,可以将查询参数用作应用程序状态。到目前为止,我们只能读取它们,但你也可以在应用程序中写入它们,并在 URL 中看到它们的实时更新。
只需要在使用导航函数的时候将我们的参数设置为新状态,与我们的输入同步:
tsx
function Search() {
const { query, hasDiscount, categories } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const updateFilters = (name: keyof ItemFilters, value: unknown) => {
navigate({ search: (prev) => ({ ...prev, [name]: value }) });
};
return (
<div>
<h2>Search</h2>
You searched for: <input
value={query}
onChange={(e) => {
updateFilters('query', e.target.value);
}}
/>
<input type="checkbox" checked={hasDiscount} onChange={(e) => updateFilters('hasDiscount', e.target.checked)} />
<select
multiple
onChange={(e) => {
updateFilters(
'categories',
Array.from(e.target.selectedOptions, (option) => option.value)
);
}}
value={categories}
>
{['electronics', 'clothing', 'books', 'toys'].map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<pre>{JSON.stringify({ query, hasDiscount, categories }, null, 2)}</pre>
</div>
);
}
现在,我们可以编辑所有查询参数,查看 URL 中的更改,并只需......向其他人发送链接即可轻松共享状态!
认证路由和守卫
还有一个问题:如何使用 guard 保护应用程序的某些路由(页面)?
定义一个守卫
创建登录页面
比如,我们希望 /profile
页面只向登录用户显示,首先创建一个基本登录页面 login.tsx
:
tsx
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { isAuthenticated, signIn, signOut } from '../utils/auth';
export const Route = createFileRoute('/login')({
component: Login,
});
function Login() {
const router = useRouter();
return (
<>
<h2>Login</h2>
{isAuthenticated() ? (
<>
<p>Hello user!</p>
<button
onClick={async () => {
signOut();
router.invalidate();
}}
>
Sign out
</button>
</>
) : (
<button
onClick={async () => {
signIn();
router.invalidate();
}}
>
Sign in
</button>
)}
</>
);
}
在定义三个认证函数 src/utils/auth.tsx
:
tsx
export function isAuthenticated() {
return localStorage.getItem('isAuthenticated') === 'true';
}
export async function signIn() {
localStorage.setItem('isAuthenticated', 'true');
}
export async function signOut() {
localStorage.removeItem('isAuthenticated');
}
保护路由
createFileRoute
函数需要一个名为 beforeLoad
的额外参数:
tsx
export const Route = createFileRoute('/login')({
beforeLoad: async () => {
if (!isAuthenticated()) {
throw redirect({ to: '/login' });
}
},
component: Login,
});
使用钩子中的数据(TanStack Router Context)
在一个更贴近实际工作中的例子,你可能会有这样一个钩子(但愿不是存储在 localStorage
上,而是使用来自 API 的数据),新建一个文件 src/hooks/useAuth.ts
:
tsx
export const useAuth = () => {
const signIn = () => {
localStorage.setItem('isAuthenticated', 'true');
};
const signOut = () => {
localStorage.removeItem('isAuthenticated');
};
const isLogged = () => localStorage.getItem('isAuthenticated') === 'true';
return { signIn, signOut, isLogged };
};
export type AuthContext = ReturnType<typeof useAuth>;
现在,我们需要使用上下文将这些信息告知 TanStack Router。
定义 Context
定义上下文的第一步显然是定义其类型,在 src/__rout.tsx
中(也可以放在独立的类型文件中):
tsx
type RouterContext = {
authentication: AuthContext;
};
之前是使用 createRootRoute
,现在可以用 createRootRouteWithContext
:
tsx
import {
Link,
Outlet,
createRootRouteWithContext,
} from "@tanstack/react-router";
import { AuthContext } from "../hooks/useAuth";
const activeProps = {
style: {
fontWeight: "bold",
},
};
type RouterContext = {
authentication: AuthContext;
};
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<>
<h1>My App</h1>
<ul>
<li>
<Link to="/" activeProps={activeProps}>
Home
</Link>
</li>
<li>
<Link to="/profile" activeProps={activeProps}>
{({ isActive }) => <>Profile {isActive && "~"}</>}
</Link>
</li>
<li>
<Link to="/pokemon" activeProps={activeProps}>
Pokemons
</Link>
</li>
<li>
<Link to="/search" activeProps={activeProps}>
Search
</Link>
</li>
<li>
<Link to="/login" activeProps={activeProps}>
Login
</Link>
</li>
</ul>
<Outlet />
</>
),
});
注意:别忘了在最后多加一个
()
。
更新 RouterProvider
现在你应该会在定义 RouterProvider
的文件 App.tsx
中看到一个错误,因为你需要传递新定义的上下文:
tsx
const router = createRouter({
routeTree,
context: { authentication: undefined! },
});
现在路由器知道了上下文,但它是空的...
tsx
function App() {
const authentication = useAuth();
return <RouterProvider router={router} context={{ authentication }} />;
}
读取 context
现在你可以像这样更新受保护的页面,比如 profile.tsx
:
tsx
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/profile")({
beforeLoad: ({ context }) => {
const { isLogged } = context.authentication;
if (!isLogged()) {
throw redirect({
to: "/login",
});
}
},
component: () => <div>Hello /profile!</div>,
});
不同之处在于,现在可以读取 { context }
,在其中找到钩子中的数据。
同时保护多个路由
保护多个路由的直接方法是创建一个同名文件,只定义 beforeLoad
函数。让我们创建几个需要保护的路由:
txt
src/
routes/
authenticated/
dashboard.tsx
profile.tsx
现在,新建 src/routes/_authenticated.ts
文件,内容如下:
tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context }) => {
const { isLogged } = context.authentication;
if (!isLogged()) {
throw redirect({ to: '/login' });
}
},
});
就是这样!/authenticated
文件夹内的所有文件都将受到保护。
删除 URL 中的 /authenticated
你很可能不想在 URL 中看到父路由,这也是合理的。只需在路由中添加下划线 _
,并将其重命名为 _authenticated
。现在,你可以导航到 https://localhost:5173/dashboard
,如果你已登录,就可以访问该页面!
嵌套路由和404页面
嵌套路由
在基于文件的方法中,将页面嵌套在某些文件夹内会自动创建嵌套路由。这是一个很棒的功能,可以让你以更有条理的方式组织你的项目。
例如,src/routes/admin/dashboard.tsx
中的文件可以在 /admin/dashboard
中访问。
不过,在某些情况下,你可能不希望某些文件夹成为 URL 的一部分。那应该怎么做?
隐藏路径(Hidden path)
这是最简单的方法,假设你只想将一些页面分组,但不想在 URL 中显示文件夹名称:确保重新命名你的文件夹,并用括号括起来。
例如,如果你有一个文件夹 src/routes/(admin)/dashboard.tsx
,路由将是 /dashboard
。
通用布局(可见)
在某些情况下,将路由分组到文件夹中不仅是为了组织代码,也是为了将所有路由封装到一个通用布局中,例如导航栏或某种框架。
举个例子:
tsx
// src/routes/(hidden-folder)/layouts/visibleLayout.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout')({
component: () => (
<div>
<p>This layout is visible in the URL 👀</p>
<Link to="/layouts/visibleLayout/foo">Foo</Link> <Link to="/layouts/visibleLayout/bar">Bar</Link>
<hr />
<Outlet />
</div>
),
});
这是 URL
中可见的布局,它将封装子路由。
tsx
// src/routes/(hidden-folder)/layouts/visibleLayout/foo.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout/foo')({
component: () => <div>Hello /(hidden-folder)/layouts/visibleLayout/foo!</div>,
});
tsx
// src/routes/(hidden-folder)/layouts/visibleLayout/bar.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout/bar')({
component: () => <div>Hello /(hidden-folder)/layouts/visibleLayout/bar!</div>,
});
在这种情况下,路由将是 /layouts/visibleLayout/foo
和 /layouts/visibleLayout/bar
,它们将共享两个链接,以便在它们之间导航。
通用布局(隐藏)
如果想拥有一个通用布局,但又不想让它在 URL 中可见,可以使用与之前相同的方法,但要确保在文件夹名称和布局名称前添加下划线 _
。
tsx
src/routes/(hidden-folder)/layouts/_hiddenLayout.tsx
src/routes/(hidden-folder)/layouts/_hiddenLayout/foo.tsx
src/routes/(hidden-folder)/layouts/_hiddenLayout/bar.tsx
在这种情况下,路由将是 layouts/foo
和 layouts/bar
。
圆点符号
将所有路由嵌套在文件夹内对某些人来说很方便,但如果有多层嵌套,而这些嵌套只是为了获得某个网址,则可能会造成混乱。
例如,你可能想要实现 /admin/new/dashboard/overview
路由,但又不想在项目结构中包含所有这些文件夹。
你的文件名可以是:
tsx
src/routes/admin.new.dashboard.overview.tsx
你不必在平面结构和嵌套结构之间做出选择,你可以随意混合它们。
404 页面
对页面进行分组和嵌套很酷,但如果用户点击的网址没有映射到任何路由,会发生什么情况呢?你可以自定义 404 页面!
你可能会在 App.tsx
中使用 createRouter
函数的一个属性来创建你的路由器 defaultNotFoundComponent
。
顾名思义,你可以在这里传递一个组件,当用户访问一个无法映射到任何现有路由的路由时,该组件将被渲染。
专用 404 页面
我们刚才看到的是一个全局 404 页面,但也可以为特定路径创建专用 404 页面。在我们的布局示例中,假设用户访问 /layouts/visibleLayout/unknown
时,我们需要一个自定义 404 页面。
tsx
// src/routes/(hidden-folder)/layouts/visibleLayout.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout')({
component: () => (
<div>
<p>This layout is visible in the URL 👀</p>
<Link to="/layouts/visibleLayout/foo">Foo</Link> <Link to="/layouts/visibleLayout/bar">Bar</Link>
<hr />
<Outlet />
</div>
),
notFoundComponent: () => <div>I'm the Not found page, inside /visibleLayout</div>,
});
在路由中添加一个 notFoundComponent
属性后,工作就完成了!
访问 /non-existing-page
将呈现你在 createRouter
函数中定义的默认 notFoundComponent
,而访问 /layouts/visibleLayout/non-existing-page
将呈现你在上面定义的自定义 404 页面。