普通视图
TanStack Router 实战: 如何设置基础认证和受保护路由
本指南涵盖了在 TanStack Router 应用程序中实现基础认证模式和保护路由的方法。
快速开始
通过创建一个上下文感知(context-aware)的路由器,实现认证状态管理,并使用 beforeLoad 进行路由保护来设置身份验证。本指南侧重于使用 React Context 进行核心认证设置。
创建认证上下文 (Authentication Context)
创建 src/auth.tsx:
import React, { createContext, useContext, useState, useEffect } from 'react'
interface User {
id: string
username: string
email: string
}
interface AuthState {
isAuthenticated: boolean
user: User | null
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthState | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// 在应用加载时恢复认证状态
useEffect(() => {
const token = localStorage.getItem('auth-token')
if (token) {
// 使用你的 API 验证 token
fetch('/api/validate-token', {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => response.json())
.then((userData) => {
if (userData.valid) {
setUser(userData.user)
setIsAuthenticated(true)
} else {
localStorage.removeItem('auth-token')
}
})
.catch(() => {
localStorage.removeItem('auth-token')
})
.finally(() => {
setIsLoading(false)
})
} else {
setIsLoading(false)
}
}, [])
// 在检查认证时显示加载状态
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
Loading...
</div>
)
}
const login = async (username: string, password: string) => {
// 替换为你的认证逻辑
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
setIsAuthenticated(true)
// 存储 token 以便持久化
localStorage.setItem('auth-token', userData.token)
} else {
throw new Error('Authentication failed')
}
}
const logout = () => {
setUser(null)
setIsAuthenticated(false)
localStorage.removeItem('auth-token')
}
return (
<AuthContext.Provider value={{ isAuthenticated, user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
配置路由器上下文 (Router Context)
1. 设置路由器上下文
更新 src/routes/__root.tsx:
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
interface AuthState {
isAuthenticated: boolean
user: { id: string; username: string; email: string } | null
login: (username: string, password: string) => Promise<void>
logout: () => void
}
interface MyRouterContext {
auth: AuthState
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => (
<div>
<Outlet />
<TanStackRouterDevtools />
</div>
),
})
2. 配置路由器
更新 src/router.tsx:
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({
routeTree,
context: {
// auth 将从 App 组件向下传递
auth: undefined!,
},
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
3. 连接应用与认证
更新 src/App.tsx:
import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'
function InnerApp() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
)
}
export default App
创建受保护路由
1. 创建认证布局路由
创建 src/routes/_authenticated.tsx:
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
// 保存当前位置,以便登录后重定向
redirect: location.href,
},
})
}
},
component: () => <Outlet />,
})
2. 创建登录路由
创建 src/routes/login.tsx:
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
export const Route = createFileRoute('/login')({
validateSearch: (search) => ({
redirect: (search.redirect as string) || '/',
}),
beforeLoad: ({ context, search }) => {
// 如果已认证,则进行重定向
if (context.auth.isAuthenticated) {
throw redirect({ to: search.redirect })
}
},
component: LoginComponent,
})
function LoginComponent() {
const { auth } = Route.useRouteContext()
const { redirect } = Route.useSearch()
const navigate = Route.useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
await auth.login(username, password)
// 使用路由器导航跳转到重定向 URL
navigate({ to: redirect })
} catch (err) {
setError('Invalid username or password')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="max-w-md w-full space-y-4 p-6 border rounded-lg"
>
<h1 className="text-2xl font-bold text-center">Sign In</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
)
}
3. 创建受保护的仪表盘 (Dashboard)
创建 src/routes/_authenticated/dashboard.tsx:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const { auth } = Route.useRouteContext()
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<button
onClick={auth.logout}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
>
Sign Out
</button>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Welcome back!</h2>
<p className="text-gray-600">
Hello, <strong>{auth.user?.username}</strong>! You are successfully
authenticated.
</p>
<p className="text-sm text-gray-500 mt-2">Email: {auth.user?.email}</p>
</div>
</div>
)
}
添加认证持久化
更新你的 AuthProvider 以在页面刷新时恢复认证状态:
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// 在应用加载时恢复认证状态
useEffect(() => {
const token = localStorage.getItem('auth-token')
if (token) {
// 使用你的 API 验证 token
fetch('/api/validate-token', {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => response.json())
.then((userData) => {
if (userData.valid) {
setUser(userData.user)
setIsAuthenticated(true)
} else {
localStorage.removeItem('auth-token')
}
})
.catch(() => {
localStorage.removeItem('auth-token')
})
.finally(() => {
setIsLoading(false)
})
} else {
setIsLoading(false)
}
}, [])
// 在检查认证时显示加载状态
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
Loading...
</div>
)
}
// ... provider 的其余逻辑
}
生产环境检查清单
在部署认证功能之前,请确保你已经:
- 使用适当的认证中间件保护了 API 端点
- 在生产环境中设置了 HTTPS(安全 Cookie 所需)
- 为 API 端点配置了环境变量
- 实现了适当的 token 验证和刷新机制
- 为基于表单的认证添加了 CSRF 保护
- 测试了认证流程(登录、登出、持久化)
- 为网络故障添加了适当的错误处理
- 为认证操作实现了加载状态
常见问题
认证上下文不可用
问题: 出现 useAuth must be used within an AuthProvider 错误。
解决方案: 确保 AuthProvider 包裹了整个应用,且 RouterProvider 位于其内部。
页面刷新后用户登出
问题: 页面刷新时认证状态重置。
解决方案: 如上文持久化部分所示,添加 token 持久化逻辑。
受保护路由在重定向闪烁
问题: 受保护的内容在重定向到登录页面之前短暂显示。
解决方案: 使用 beforeLoad 而不是组件级别的认证检查:
export const Route = createFileRoute('/_authenticated/dashboard')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
component: DashboardComponent,
})
Flexbox 布局中的滚动失效问题:为什么需要 `min-h-0`?
React 19 时代的 StrictMode:原理、未来准备与最佳实践
什么是 StrictMode?
StrictMode 是 React 提供的开发者工具,它不渲染任何可见的 UI,而是通过为后代组件提供额外的检查来帮助开发者编写高质量代码。它的核心目标是:
- 识别不安全的生命周期:为并发模式扫清障碍。
- 警告过时的 API 使用:确保代码能平滑升级到新版本。
- 检测意外的副作用:通过故意重复调用来暴露 Bug。
StrictMode 的“反直觉”行为
在开发模式下(仅限开发环境,生产环境不生效),StrictMode 会强制执行以下操作:
- 重复渲染(Double Render) :组件函数体会被执行两次。
-
重复 Effect(Double Effect) :执行
Mount→Unmount→Mount的完整周期。
核心解密:为什么要“卸载再挂载”?
很多开发者认为重复请求是 StrictMode 的“副作用”,但实际上这是 React 为了未来特性所做的强制演习。
1. 为 React 19+ 的 Activity (Offscreen) API 做准备
React 正在引入一种能力(即将在 React 19 及后续版本完善的 <Activity>),允许组件在切换页面时不被销毁,而是“休眠”在后台。
- 场景:用户从 Tab A 切到 Tab B,再切回 Tab A。
- 目标:Tab A 的状态(滚动位置、表单内容)瞬间恢复,无需重新请求。
-
StrictMode 的良苦用心:现在的
Mount -> Unmount -> Mount就是在模拟“休眠 -> 唤醒”的过程。如果你的组件在Unmount时清理不彻底(如未取消订阅),这种 Bug 在 StrictMode 下会立即暴露,防止未来上线 Activity 功能时页面崩溃。
2. 确保并发渲染(Concurrent Rendering)的稳定性
React 19 全面拥抱并发。渲染过程可能会被中断、暂停或废弃。如果不保证渲染函数的“纯度”(Idempotency),多次计算可能会导致 UI 状态不一致。StrictMode 的重复执行正是为了确保无论渲染多少次,结果都是确定的。
典型的“重复请求”现象
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
在 StrictMode 下,常见的请求流程是:
- 组件首次挂载 → 执行
useEffect→ 发起第 1 次网络请求 - React 立即卸载组件 → 执行
cleanup - React 重新挂载组件 → 再次执行
useEffect→ 发起第 2 次网络请求(内容相同)
最佳实践与解决方案
在 React 19 时代,我们强烈建议保留 StrictMode。与其关掉它,不如优化你的数据获取策略。
方案 1:使用数据请求库(React 19 推荐)
这是现代 React 开发的“标准答案”。使用 TanStack Query (React Query) 、SWR 或 React 19 的 Server Components (RSC) 。
// 使用 React Query,库内部会自动处理去重和缓存
// StrictMode 下的多次挂载不会导致重复的网络请求
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
-
优势:直接物理隔离了
useEffect的副作用问题,代码更声明式,完美适配 StrictMode。
方案 2:使用 AbortController(手动挡推荐)
如果你必须在 useEffect 中请求,请使用 AbortController 进行竞态处理和取消。这不仅解决了 StrictMode 的视觉干扰,更防止了网络竞态条件(Race Condition)。
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
const res = await apiClient.request({
method: 'POST',
url: '/get/data',
signal: abortController.signal // 绑定信号
});
// 处理成功逻辑
} catch (error) {
if (error.name !== 'AbortError') {
// 真正的错误处理
console.error(error);
}
}
};
fetchData();
// Cleanup: 第一次卸载时取消请求 A,保留请求 B
return () => {
abortController.abort();
};
}, []);
避坑:慎用 useRef 标记
一种常见的“歪门邪道”是使用 useRef 拦截请求:
// ❌ 不推荐:可能导致依赖更新失效
const hasRequested = useRef(false);
useEffect(() => {
if (hasRequested.current) return;
hasRequested.current = true;
fetchData();
}, [userId]); // 如果 userId 变化,ref 依然是 true,会导致请求被拦截
这种方法虽然能在这个场景下生效,但如果依赖项数组(Dependency Array)不为空,很容易导致后续合法的请求被错误拦截,增加维护成本。
总结
在 React 19 中,StrictMode 不仅仅是一个调试工具,它是代码质量的体检员。
- 不要移除它:它帮助你确保代码兼容未来的“后台保活”特性和并发渲染。
- 正确处理副作用:重复请求不是 Bug,而是提示你需要清理副作用(Cleanup)或使用更现代的数据流方案(如 React Query 或 RSC)。
-
拥抱标准:适应
StrictMode的检查,意味着你的代码已经做好了迎接 React 未来新特性的准备。