vault backup: 2026-04-29 22:28:21
This commit is contained in:
+314
-55
@@ -7,38 +7,84 @@ create time: 2026-04-29 22:07
|
||||
|
||||
## 概述
|
||||
|
||||
前端路由是单页应用(SPA)的核心基础设施。本文档以 React Router v7 为主介绍路由配置、嵌套路由、懒加载、动态路由和守卫拦截等实战模式。
|
||||
前端路由决定了"URL 变化 → 组件渲染"的映射关系。在单页应用(SPA)中,浏览器**不会重新加载整页**,而是由路由库拦截 URL 变更、动态切换组件——这也就是为什么 SPA 体验如此流畅。
|
||||
|
||||
> [!question] 思考:浏览器刷新和客户端跳转的本质区别
|
||||
> - **刷新**:向服务器发起完整 HTTP 请求,拿到新 HTML → 重建整个 DOM
|
||||
> - **客户端跳转**:只改变 URL + 执行 JS 渲染对应组件,无需请求页面资源
|
||||
>
|
||||
> 理解这个区别是掌握前端路由的前提。
|
||||
|
||||
本文档以 React Router v7 为主线,覆盖路由配置、嵌套路由、懒加载、数据加载、守卫拦截等核心模式。同时补充 TanStack Router 作为类型安全方案的对比。
|
||||
|
||||
## React Router v7 核心概念
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[BrowserRouter] --> B["哈希历史 vs HTML5 历史"]
|
||||
B --> C[RouterProvider]
|
||||
C --> D[Route 树]
|
||||
D --> E["Element / Component / ComponentFunction"]
|
||||
D --> F["Layout Route"]
|
||||
F --> G["Child Routes"]
|
||||
flowchart TD
|
||||
Start["URL 变更"] --> Match["路由匹配<br/>按 path 从上到下匹配"]
|
||||
Match --> BestMatch["最佳匹配胜出"]
|
||||
BestMatch --> Render["渲染组件树<br/>Layout Route + Child Routes"]
|
||||
Render --> Outlet["Outlet 占位"]
|
||||
Outlet --> Content["具体页面组件"]
|
||||
|
||||
style A fill:#F4DBD6,color:#000
|
||||
style E fill:#61DAFB,color:#000
|
||||
Sub1["声明式: Link"] -.-> Start
|
||||
Sub2["程序化: navigate()"] -.-> Start
|
||||
|
||||
subgraph Config["路由配置 Route Tree"]
|
||||
Root["path / RootLayout"]
|
||||
Root --> Index["index true HomePage"]
|
||||
Root --> About["path about AboutPage"]
|
||||
Root --> Dash["path dashboard DashboardLayout"]
|
||||
Dash --> Stats["path stats StatsPage"]
|
||||
Dash --> Settings["path settings SettingsPage"]
|
||||
end
|
||||
|
||||
Render --> Config
|
||||
|
||||
style Start fill:#F4DBD6,color:#000
|
||||
style Render fill:#61DAFB,color:#000
|
||||
style Config fill:#E8E8E8,color:#000
|
||||
```
|
||||
|
||||
### SPA 路由的工作流程
|
||||
|
||||
`浏览器操作` → `拦截 URL 变化` → `匹配 Route 树` → `渲染对应组件`
|
||||
|
||||
两条跳转路径:
|
||||
- **声明式导航**:用户点击 `<Link>` 组件,浏览器地址栏更新但不刷新
|
||||
- **程序化导航**:JS 调用 `navigate("/path")`,常用于登录后跳转、表单提交后重定向
|
||||
|
||||
---
|
||||
|
||||
## React Router vs TanStack Router 选型
|
||||
|
||||
| 维度 | React Router (v7) | TanStack Router |
|
||||
|------|-------------------|-----------------|
|
||||
| Bundle 大小 | ~25KB | ~20KB |
|
||||
| TypeScript 支持 | 需手动写类型 | 代码生成,全链路类型安全 |
|
||||
| Data APIs | loader / action(v7)| loader / action(更成熟)|
|
||||
| 学习曲线 | 低(熟悉 React 即懂) | 中(有自身 API 体系)|
|
||||
| 适用场景 | 大多数项目 | TS 重度项目、大型应用 |
|
||||
|
||||
> [!tip] 选型建议
|
||||
> - 中小型项目或团队 TS 经验一般 → **React Router**,上手快、文档丰富
|
||||
> - 大型企业级项目、强依赖类型安全 → **TanStack Router**,编译期捕获路由错误
|
||||
|
||||
### 基本结构
|
||||
|
||||
React Router v7 使用扁平化的 Route Tree,父子关系通过 `children` 数组表达。核心概念:**Layout Route**(不切换内容的父布局)和 **Page Route**(实际渲染页面的叶子节点)。
|
||||
|
||||
```tsx
|
||||
// app.tsx(v7 推荐入口)
|
||||
import { createRootRouteWithContext, createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
// 或传统 react-router-dom v6/v7
|
||||
// React Router DOM v7
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <RootLayout />,
|
||||
element: <RootLayout />, // 所有页面共用的外层 shell
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: "about", element: <AboutPage /> },
|
||||
{ index: true, element: <HomePage /> }, // "/" → 首页
|
||||
{ path: "about", element: <AboutPage /> }, // "/about"
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -48,81 +94,166 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] Index Route vs Path Route
|
||||
> - `index: true`:当 parent route 被匹配且**没有更深的子路径**时激活。例如 `/dashboard` 命中 Layout 但没带子路径 → index route 显示。
|
||||
> - `path: "xxx"`:当 URL 带有该段时才激活。例如 `/dashboard/stats` → stats route。
|
||||
|
||||
## Layout Route 与嵌套路由
|
||||
|
||||
Layout Route 是一种**不随 URL 变化而卸载**的父级路由。它的核心作用是让侧边栏、顶部导航等共用 UI 在页面切换时保持存活,避免重复初始化。
|
||||
|
||||
```tsx
|
||||
{
|
||||
// /dashboard 及其子路径共用此布局
|
||||
// /dashboard 及其所有子路径共用此布局
|
||||
path: "dashboard",
|
||||
element: <DashboardLayout />, // 侧边栏 + Header 常驻
|
||||
element: <DashboardLayout />, // 侧边栏 + Header 常驻不卸载
|
||||
children: [
|
||||
{ index: true, element: <DashboardHome /> }, // /dashboard → DashboardHome
|
||||
{ path: "stats", element: <StatsPage /> }, // /dashboard/stats
|
||||
{ path: "settings", element: <SettingsPage /> }, // /dashboard/settings
|
||||
{ path: "stats", element: <StatsPage /> }, // /dashboard/stats → StatsPage
|
||||
{ path: "settings", element: <SettingsPage /> }, // /dashboard/settings → SettingsPage
|
||||
{ path: "settings/:tab", element: <SettingsTab /> }, // /dashboard/settings/profile
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
```tsx
|
||||
// DashboardLayout.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
function DashboardLayout() {
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<Sidebar />
|
||||
<header><Navigation /></header>
|
||||
<main>
|
||||
<Outlet /> {/* v7 Outlet 替代了 v6的children渲染 */}
|
||||
{/* <Outlet /> 是嵌套内容的"占位槽" */}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] Outlet vs Children
|
||||
> - v6 用 `<Routes>` 内部定义子路由
|
||||
> - v7 用 `<Outlet />` 显式标注嵌套出口,更清晰
|
||||
> [!note] <Outlet /> 的本质
|
||||
> `<Outlet />` 是一个**占位组件**。当用户访问 `/dashboard/stats` 时:
|
||||
> 1. React Router 匹配到 `path: "dashboard"` 这个 layout route,渲染 `<DashboardLayout />`
|
||||
> 2. 在 `<DashboardLayout />` 内部遇到 `<Outlet />` → 继续向下匹配子路由
|
||||
> 3. 子路由 `<StatsPage />` 被渲染到 `<Outlet />` 的位置
|
||||
>
|
||||
> **关键理解**:没有 `<Outlet />`,子路由内容就无法显示!这是 v6/v7 中嵌套路由的唯一通信方式。
|
||||
|
||||
> [!warning] 常见坑:忘记加 Outlet
|
||||
> ```tsx
|
||||
> // ❌ 错误:layout route 的子路由永远不会渲染
|
||||
> function DashboardLayout() {
|
||||
> return <><Sidebar /><StatsPage /></>; // 硬编码了具体页面
|
||||
> }
|
||||
>
|
||||
> // ✅ 正确:用 Outlet 留出插槽
|
||||
> function DashboardLayout() {
|
||||
> return <><Sidebar /><Outlet /></>;
|
||||
> }
|
||||
> ```
|
||||
|
||||
## 路由参数获取
|
||||
|
||||
React Router 提供了一组 Hook,让我们可以在组件中读取 URL 信息:
|
||||
|
||||
```tsx
|
||||
import { useSearchParams, useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
function UserPage() {
|
||||
const { id } = useParams(); // /user/:id → { id: "123" }
|
||||
const [searchParams] = useSearchParams(); // ?q=hello&page=1
|
||||
const location = useLocation(); // { pathname: "/user/123", search: "?q=hello" }
|
||||
const navigate = useNavigate(); // navigate("/home") / navigate(-1)
|
||||
const location = useLocation(); // { pathname, search, hash }
|
||||
const navigate = useNavigate(); // 编程式导航
|
||||
|
||||
const query = searchParams.get("q");
|
||||
const query = searchParams.get("q"); // "hello"
|
||||
console.log(location.pathname); // "/user/123"
|
||||
|
||||
return <h1>User #{id}, searching for "{query}"</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
### v7 新增:useRouteContext + Data APIs
|
||||
### Hook 速查表
|
||||
|
||||
| Hook | 返回 | 典型用法 |
|
||||
|------|------|----------|
|
||||
| `useParams()` | `{ param: string }` | 从 URL 段中提取动态值(如 `/:id`) |
|
||||
| `useSearchParams()` | `[URLSearchParams, func]` | 读写查询字符串(如 `?q=xxx`) |
|
||||
| `useLocation()` | `{ pathname, search, hash }` | 当前 URL 各部分的细粒度访问 |
|
||||
| `useNavigate()` | `navigate(path, options?)` | 编程式跳转、后退、替换 history entry |
|
||||
|
||||
> [!tip] searchParams vs Location.search
|
||||
> - `searchParams.get("key")` 直接得到解码后的字符串,方便操作
|
||||
> - `location.search` 是原始字符串 `"?q=hello"`,需要手动解析
|
||||
> - 推荐优先使用 `useSearchParams()`,类型更安全。
|
||||
|
||||
### v7 Data Loader —— 数据预加载
|
||||
|
||||
v7 引入了 `loader` 和 `action`,让数据获取成为路由级别的一等公民:
|
||||
|
||||
```tsx
|
||||
// v7 data routers 支持 loader/action
|
||||
// Route 定义
|
||||
{
|
||||
path: "posts/:postId",
|
||||
// loader 在组件渲染前执行,返回的数据自动注入 component props
|
||||
loader: async ({ params }) => {
|
||||
const res = await fetch(`/api/posts/${params.postId}`);
|
||||
return res.json();
|
||||
if (!res.ok) throw new Error("Post not found");
|
||||
return res.json(); // 返回的对象将作为 props 传给组件
|
||||
},
|
||||
// loader 数据自动注入 component props
|
||||
action: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const content = formData.get("content") as string;
|
||||
await fetch(`/api/posts/${params.postId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
return { success: true }; // action 返回值可通过 useActionData() 读取
|
||||
},
|
||||
// errorElement 处理 loader/action 中抛出的错误
|
||||
errorElement: <PostErrorBoundary />,
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// PostPage.tsx —— 消费 loader 数据
|
||||
import { useLoaderData, useActionData } from "react-router-dom";
|
||||
|
||||
function PostPage() {
|
||||
const post = useLoaderData() as { title: string; body: string }; // loader 的返回值
|
||||
const actionResult = useActionData(); // action 的返回值
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{post.title}</h1>
|
||||
<p>{post.body}</p>
|
||||
{actionResult?.success && <div className="toast">保存成功!</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> [!question] loader vs useEffect 获取数据有什么区别?
|
||||
> - **loader**:先取数据 → 数据就绪后 → 才渲染页面。用户不会看到空状态或 loading 骨架屏(配合 Suspense 可以显示 fallback)。
|
||||
> - **useEffect + useState**:先渲染空白/loading → 请求发出 → 数据回来 → 更新 UI。会有闪烁过程。
|
||||
>
|
||||
> loader 本质是**服务端渲染(SSR)友好的异步数据模式**。即使不使用 SSR,它的"数据先于渲染"语义也能减少水合闪烁。
|
||||
|
||||
## 懒加载 Code Splitting
|
||||
|
||||
代码分割的核心目标:**减少首屏加载体积**。通过 `React.lazy()` + `<Suspense>`,将非关键路由的代码拆成独立 chunk,用户访问时才下载。
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
// 路由级代码分割
|
||||
// 路由级代码分割 —— 只在组件真正需要时才加载
|
||||
const AdminPage = lazy(() => import("./pages/AdminPage"));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
|
||||
// Route 配置中包裹 Suspense
|
||||
{
|
||||
path: "admin",
|
||||
element: (
|
||||
@@ -133,33 +264,127 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
}
|
||||
```
|
||||
|
||||
### 更优方案:loader + Suspense(v7 Data Router)
|
||||
|
||||
当 `loader` 和 `lazy()` 结合使用时,可以在数据加载之前就展示 fallback,实现"Loading → Fallback → Page"的自然过渡:
|
||||
|
||||
```tsx
|
||||
const ExplorePage = lazy(
|
||||
() => import("./pages/ExplorePage"),
|
||||
{ ssr: false } // v7 可选:标记为非 SSR 页面
|
||||
);
|
||||
|
||||
{
|
||||
path: "explore",
|
||||
loader: async () => {
|
||||
// 1. 先执行数据请求(此时显示 fallback)
|
||||
const categories = await fetchCategories();
|
||||
return { categories };
|
||||
},
|
||||
element: (
|
||||
<Suspense fallback={<ExploreFallback />}>
|
||||
{/* 2. chunk 加载完毕 + loader 数据就绪后渲染 */}
|
||||
<ExplorePage />
|
||||
</Suspense>
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 懒加载时机判断
|
||||
> - 首屏路由(首页、登录页)**不要**懒加载
|
||||
> - 低频访问页面(设置、管理员面板)适合懒加载
|
||||
> - 每个 chunk 建议不超过 100KB gzipped
|
||||
> - **不要懒加载**:首页、登录页等高频入口
|
||||
> - **适合懒加载**:设置页、管理员面板、低频功能模块
|
||||
> - **chunk 大小建议**:每个 chunk gzipped 后不超过 100KB
|
||||
> - **公共代码尽量提取**:避免多个 chunk 重复包含 React / utils
|
||||
|
||||
## 动态路由与 Splat 路由
|
||||
|
||||
```tsx
|
||||
// :param —— 单个段匹配
|
||||
{ path: "users/:userId", element: <UserProfile /> } // /users/42
|
||||
URL 中的可变部分是前端路由的核心灵活性来源。React Router 提供了两种参数匹配方式:
|
||||
|
||||
// * splat —— 贪婪匹配剩余所有
|
||||
{ path: "docs/*", element: <DocViewer /> } // /docs/a/b/c
|
||||
{ path: "*", element: <NotFoundPage /> } // 404 fallback
|
||||
```tsx
|
||||
// :param —— 匹配单个路径段
|
||||
{ path: "users/:userId", element: <UserProfile /> } // /users/42 ✅
|
||||
// /users/42/posts ❌(不匹配,需子路由)
|
||||
|
||||
// * splat —— 贪婪匹配剩余所有段
|
||||
{ path: "docs/*", element: <DocViewer /> } // /docs/a/b/c ✅
|
||||
{ path: "*", element: <NotFoundPage /> } // /anything ❌ → 404 fallback
|
||||
|
||||
// 多参数组合
|
||||
{
|
||||
path: "posts/:postId/comments/:commentId",
|
||||
element: <CommentDetail />, // /posts/1/comments/5
|
||||
}
|
||||
```
|
||||
|
||||
### Error Route —— 错误边界处理
|
||||
|
||||
v7 中每个路由都可以声明 `errorElement`,当 loader/action 抛出异常时自动渲染:
|
||||
|
||||
```tsx
|
||||
{
|
||||
path: "admin",
|
||||
loader: adminLoader, // 可能抛 401/403
|
||||
errorElement: <AdminErrorBoundary />, // 接管错误 UI
|
||||
}
|
||||
|
||||
// AdminErrorBoundary.tsx
|
||||
import { useRouteError } from "react-router-dom";
|
||||
|
||||
function AdminErrorBoundary() {
|
||||
const error = useRouteError() as Response;
|
||||
|
||||
if (error.status === 401) return <Navigate to="/login" replace />;
|
||||
if (error.status === 403) return <UnauthorizedPage />;
|
||||
|
||||
return <GenericErrorPage message={error.data?.message ?? "加载失败"} />;
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 全局 404 位置很重要
|
||||
> `{ path: "*" }` 必须放在路由列表**最末尾**。React Router 从上到下匹配,一旦命中即停止。如果提前放 `*`,后续所有路由都不会生效。
|
||||
|
||||
## Form Actions —— 数据变更流程
|
||||
|
||||
`action` 和 `loader` 是 v7 Data Router 的对称设计:`loader` 负责读取(GET),`action` 负责写入(POST/PATCH/DELETE)。
|
||||
|
||||
```tsx
|
||||
{
|
||||
path: "posts/:postId",
|
||||
action: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const title = formData.get("title") as string;
|
||||
|
||||
await fetch(`/api/posts/${params.postId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
// 提交后重定向到详情页(PRG 模式,避免重复提交)
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `/posts/${params.postId}` },
|
||||
});
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] PRG 模式(Post/Redirect/Get)
|
||||
> action 执行完毕后**必须 redirect**,否则用户刷新页面时会重复提交表单。这是后端开发的老经验,同样适用于 SPA 的 Data Router 场景。
|
||||
|
||||
## 路由守卫与权限控制
|
||||
|
||||
权限校验的演进路线:**客户端条件渲染 → Loader 前置检查**。推荐优先使用 loader guard,因为它在服务端渲染和预取场景下也能正确拦截。
|
||||
|
||||
```tsx
|
||||
// 方案1:条件渲染(简单场景)
|
||||
// ❌ 方案1:条件渲染(简单但不够优雅)
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 方案2:高阶包装
|
||||
// ❌ 方案2:HOC 包装
|
||||
const withAuth = (Component: React.FC) => {
|
||||
return (props: any) => {
|
||||
const { loading, authenticated } = useAuth();
|
||||
@@ -169,24 +394,58 @@ const withAuth = (Component: React.FC) => {
|
||||
};
|
||||
};
|
||||
|
||||
// 方案3:v7 Loader 守卫(服务端前置检查)
|
||||
// ✅ 方案3:v7 Loader Guard(推荐,可配合 errorElement 统一错误处理)
|
||||
{
|
||||
path: "admin",
|
||||
loader: () => {
|
||||
if (!isAuthenticated()) throw new Response("", { status: 401 });
|
||||
if (!isAdmin()) throw new Response("", { status: 403 });
|
||||
loader: async () => {
|
||||
const res = await fetch("/api/me");
|
||||
if (!res.ok) throw new Response("未登录", { status: 401 });
|
||||
const user = await res.json();
|
||||
if (!user.isAdmin) throw new Response("权限不足", { status: 403 });
|
||||
return user; // 用户信息注入 component props
|
||||
},
|
||||
element: <AdminPanel />,
|
||||
}
|
||||
```
|
||||
|
||||
## 导航 API 对比
|
||||
### 多级权限设计建议
|
||||
|
||||
| 方法 | 适用场景 | 是否保留历史记录 |
|
||||
|------|----------|------------------|
|
||||
| `navigate(path)` | 程序化跳转 | ✅ 有 history entry |
|
||||
| `<Link to="/">Home</Link>` | 声明式导航 | ✅ 预加载 prefetch |
|
||||
| `navigate(-1)` | 返回上一页 | ✅ |
|
||||
| `<Navigate to="/login" replace />` | 替换当前 entry | ❌ 不增加 history |
|
||||
| 层级 | 方式 | 说明 |
|
||||
|------|------|------|
|
||||
| URL 层 | `errorElement` + loader | 拦截非法访问,返回 401/403 |
|
||||
| UI 层 | `useLoaderData()` 判断 | 基于用户角色隐藏按钮/菜单项 |
|
||||
| API 层 | 后端 JWT / Session | **最终防线**,前端判断必须被后端验证 |
|
||||
|
||||
> [!warning] 前端权限 ≠ 安全保障
|
||||
> 所有前端守卫只影响用户体验,真正的安全必须由后端 API 保障。不要把敏感逻辑放在前端 loader 中。
|
||||
|
||||
## 导航 API 对比与选择
|
||||
|
||||
| 方式 | 代码示例 | 适用场景 | 保留历史 | Prefetch |
|
||||
|------|----------|----------|---------|----------|
|
||||
| `<Link>` | `<Link to="/about">` | 页面内跳转(推荐首选) | ✅ | 可配置 prefetch |
|
||||
| `navigate()` | `navigate("/home")` | JS 逻辑中触发跳转 | ✅ | ❌ |
|
||||
| `navigate(-1)` | — | "返回上一页"按钮 | ✅ | N/A |
|
||||
| `<Navigate replace />` | `<Navigate to="/login" replace />` | 登录后自动重定向,不污染 history | ❌ | N/A |
|
||||
| `router.navigate()` | TanStack Router 写法 | TS 项目、类型安全导航 | ✅ | 内置 |
|
||||
|
||||
### History Entry 管理技巧
|
||||
|
||||
```tsx
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 登录成功后替换当前 entry,用户点后退不会回到登录页
|
||||
navigate("/dashboard", { replace: true });
|
||||
|
||||
// 弹窗关闭后回到原来的位置
|
||||
navigate(-1);
|
||||
|
||||
// 带 state 跳转(常用于传递临时状态)
|
||||
navigate("/settings", { state: { fromTab: "security" } });
|
||||
// 子页面通过 useLocation().state 读取
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/REACT/3. 生态工具篇/09-状态管理]] — 路由守卫中获取用户信息需配合状态管理或 Context
|
||||
- [[hhs/REACT/3. 生态工具篇/10-TS + React]] — TanStack Router 的全链路类型安全依赖完善的 TS 基础
|
||||
|
||||
+129
-21
@@ -13,19 +13,19 @@ create time: 2026-04-29 22:08
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["需要全局状态吗?"] -->|"否"| B["useState / useReducer ✅"]
|
||||
A -->|"是"| C["状态类型?"]
|
||||
A["Do you need global state?"] -->|"No"| B["useState / useReducer ✅"]
|
||||
A -->|"Yes"| C["State type?"]
|
||||
|
||||
C --> D["纯 UI 状态<br/>(主题、菜单展开、模态框)"]
|
||||
C --> E["服务器数据 / 异步缓存"]
|
||||
C --> F["应用级业务状态<br/>(用户信息、购物车、权限)"]
|
||||
C --> D["Pure UI state<br/>(theme, menu, modal)"]
|
||||
C --> E["Server data / async cache"]
|
||||
C --> F["App-level business state<br/>(user info, cart, permissions)"]
|
||||
|
||||
D --> G["Context API ✅"]
|
||||
E --> H["TanStack Query / SWR ✅"]
|
||||
F --> I["状态规模?"]
|
||||
F --> I["State size?"]
|
||||
|
||||
I --> J["小型 (< 5 store)"]
|
||||
I --> K["中大型"]
|
||||
I --> J["Small (< 5 stores)"]
|
||||
I --> K["Medium-Large"]
|
||||
|
||||
J --> L["Zustand ✅"]
|
||||
K --> M["Redux Toolkit + RTK Query ✅"]
|
||||
@@ -62,14 +62,44 @@ function Profile() {
|
||||
|
||||
### Context 的性能局限
|
||||
|
||||
> [!question] 为什么 value 变化会导致所有消费组件重渲染?
|
||||
> 因为 Context.Provider.value 是一个引用类型。每次 `value={{ user, login }}` 都会创建一个新对象,React 比较的是引用地址而非内容——地址不同就判定为"值变了"。
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| value 变化时所有消费组件重渲染 | Context Value 引用每次都是新的 | 拆分多个 Context / 用 reducer 保持 dispatch 引用稳定 |
|
||||
| 不支持 selector | 没有 "只取子字段" 的机制 | 手动封装或使用第三方库 |
|
||||
| 不支持 selector | 没有"只取子字段"的机制 | 手动封装或使用第三方库 |
|
||||
| SSR hydration mismatch | 客户端与初始值不一致 | 延迟消费或用 useEffect 包裹 |
|
||||
|
||||
### Context 最佳实践 checklist
|
||||
|
||||
> [!tip] 使用 Context 时的关键要点
|
||||
> - ✅ 始终用 `useMemo` 包装 Provider value(防止每次渲染创建新对象)
|
||||
> - ✅ 函数型 props 用 `useCallback` 缓存(减少不必要的消费者重渲染)
|
||||
> - ✅ 将"低频更新"与"高频更新"拆分为独立 Context
|
||||
> - ❌ 避免在 Context 中存储大量频繁变化的数据(如输入框实时值)
|
||||
> - ❌ 不要把 Context 当作全局状态管理的全能替代方案
|
||||
|
||||
```tsx
|
||||
// ❌ 反模式 — value 每次都是新引用,所有 Consumer 会无条件重渲染
|
||||
function BadProvider() {
|
||||
const [user, login] = useAuth();
|
||||
return <AuthContext.Provider value={{ user, login }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
// ✅ 正确做法 — useMemo + useCallback 双重稳定化
|
||||
function GoodProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, login] = useAuth();
|
||||
const value = useMemo(() => ({ user, login }), [user, login]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
```
|
||||
|
||||
## Zustand —— 轻量级现代方案
|
||||
|
||||
> [!tip] Zustand 的核心理念:像用 state 一样用 store
|
||||
> 不需要 Provider、不需要 dispatch、不需要 reducer。直接 create → 直接 use,零样板代码。
|
||||
|
||||
```ts
|
||||
import { create } from "zustand";
|
||||
|
||||
@@ -84,8 +114,10 @@ const useStore = create<StoreState>((set, get) => ({
|
||||
count: 0,
|
||||
users: [],
|
||||
|
||||
// ✅ set 接受函数形式可以拿到上一次状态 — 避免闭包陷阱
|
||||
increment: () => set(state => ({ count: state.count + 1 })),
|
||||
|
||||
// ✅ 异步操作直接在 action 里写
|
||||
fetchUsers: async () => {
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
@@ -95,15 +127,42 @@ const useStore = create<StoreState>((set, get) => ({
|
||||
|
||||
// 组件中使用
|
||||
function Counter() {
|
||||
// ✅ 只订阅 count —— 其他状态变化不会触发此组件重渲染
|
||||
// ✅ selector 模式:只订阅 count,users/increment 变化不会触发此组件重渲染
|
||||
const count = useStore(s => s.count);
|
||||
const increment = useStore(s => s.increment);
|
||||
|
||||
return <button onClick={increment}>Count: {count}</button>;
|
||||
}
|
||||
|
||||
// 批量更新
|
||||
useStore.setState(({ count }) => ({ count: count + 1, flag: true }));
|
||||
// ⚠️ setState 直接修改(不通过 hook),适用于回调和非 React 环境
|
||||
useStore.setState(({ count }) => ({ count: count + 1 }));
|
||||
```
|
||||
|
||||
### Zustand 中间件
|
||||
|
||||
> [!example] persist 中间件:自动将状态同步到 localStorage
|
||||
> 刷新页面后数据不丢失,非常适合持久化用户偏好设置。
|
||||
|
||||
```ts
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface ThemeState {
|
||||
mode: "light" | "dark";
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const useThemeStore = create<ThemeState>()(
|
||||
// ✅ 多个中间件可以叠加使用
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: "light",
|
||||
toggle: () => set(state => ({ mode: state.mode === "light" ? "dark" : "light" })),
|
||||
}),
|
||||
{ name: "theme-storage" }, // localStorage key
|
||||
),
|
||||
);
|
||||
// ⚠️ persist 默认只序列化为 JSON,不支持 Date / RegExp / Function 等复杂类型
|
||||
```
|
||||
|
||||
### Zustand 的优势
|
||||
@@ -118,6 +177,9 @@ useStore.setState(({ count }) => ({ count: count + 1, flag: true }));
|
||||
|
||||
## Redux Toolkit —— 企业级方案
|
||||
|
||||
> [!question] Redux Toolkit vs 旧版 Redux:为什么要用 RTK?
|
||||
> 在 Redux Toolkit 出现之前,Redux 需要写 action types、action creators、switch-case reducer——样板代码极多。RTK 通过 `createSlice` + Immer,将模板代码减少 80% 以上,同时保留 Redux 的调试能力和可预测性。
|
||||
|
||||
```ts
|
||||
import { createSlice, configureStore, useDispatch, useSelector } from "@reduxjs/toolkit";
|
||||
|
||||
@@ -130,8 +192,9 @@ const counterSlice = createSlice({
|
||||
name: "counter",
|
||||
initialState: { value: 0, status: "idle" } as CounterSlice,
|
||||
reducers: {
|
||||
incremented: state => { state.value += 1; }, // ✅ Immer:直接 mutate!
|
||||
incremented: state => { state.value += 1; }, // ✅ Immer:直接 mutate!内部会自动产生不可变更新
|
||||
fetchedAsync: {
|
||||
// ✅ extraReducers builder pattern — 类型安全的事件监听
|
||||
pending: state => { state.status = "loading"; },
|
||||
fulfilled: (state, action) => {
|
||||
state.status = "succeeded";
|
||||
@@ -147,11 +210,13 @@ export const { incremented, fetchedAsync } = counterSlice.actions;
|
||||
const store = configureStore({
|
||||
reducer: { counter: counterSlice.reducer },
|
||||
});
|
||||
// configureStore 自动集成了 devtools、redux-thunk、reducer 组合 — 不需要手写
|
||||
```
|
||||
|
||||
```tsx
|
||||
function CounterComponent() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
// ✅ useSelector 自带 selector(浅比较)—— 只有 value 变化时才重渲染
|
||||
const count = useSelector((s: AppState) => s.counter.value);
|
||||
|
||||
return <button onClick={() => dispatch(incremented())}>{count}</button>;
|
||||
@@ -160,6 +225,9 @@ function CounterComponent() {
|
||||
|
||||
### RTK Query —— 内置数据获取
|
||||
|
||||
> [!tip] RTK Query 的定位:替代 Axios + useEffect + useState 的组合拳
|
||||
> 自动处理缓存、loading 状态、重试、增量更新——把数据获取变成声明式。
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
@@ -175,6 +243,34 @@ const api = createApi({
|
||||
});
|
||||
|
||||
export const { useGetUsersQuery, useUpdateUserMutation } = api;
|
||||
// ⚠️ 导出的是 hook — 组件中直接解构使用,无需手动 dispatch
|
||||
```
|
||||
|
||||
## 状态管理问题排查流程
|
||||
|
||||
> [!question] 💡 你的应用遇到了什么问题?对照以下流程图定位根因
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Performance Issue?"] --> B["Unnecessary Re-render?"]
|
||||
A --> C["State Lost / Out-of-sync?"]
|
||||
|
||||
B --> D["Reading full Context value?"]
|
||||
D -->|"Yes"| E["Switch to selector / Split Context ✅"]
|
||||
D -->|"No"| F["useSelector missing selector arg?"]
|
||||
F -->|"Missing"| G["Change to useSelector s => s.xxx ✅"]
|
||||
F -->|"OK"| H["Check React StrictMode double-execution"]
|
||||
|
||||
C --> I["Using Context for server data?"]
|
||||
I -->|"Yes"| J["Switch to TanStack Query / RTK Query ✅"]
|
||||
I -->|"No"| K["Zustand store missing persist?"]
|
||||
K -->|"Yes"| L["Add persist middleware ✅"]
|
||||
K -->|"No"| M["Store destroyed on route change?"]
|
||||
|
||||
H --> N["Expected behavior — confirm if it causes real bugs"]
|
||||
J --> O["Cache hit — no repeated requests"]
|
||||
L --> P["Restores state after refresh"]
|
||||
M --> Q["Check app mount lifecycle"]
|
||||
```
|
||||
|
||||
## 三框架横向对比
|
||||
@@ -194,20 +290,32 @@ export const { useGetUsersQuery, useUpdateUserMutation } = api;
|
||||
> [!warning] 以下做法应避免
|
||||
|
||||
```tsx
|
||||
// ❌ 把所有东西塞进一个 store
|
||||
// ❌ 把所有东西塞进一个 store — 导致 selector 粒度太粗、难以维护
|
||||
const useBigStore = create(() => ({
|
||||
user: ..., theme: ..., sidebarOpen: ..., notifications: ..., cart: ..., preferences: ..., // 20+ 个状态
|
||||
}));
|
||||
|
||||
// ✅ 按功能拆分为多个 store
|
||||
// ✅ 按功能拆分为多个 store — 职责单一,selector 精确
|
||||
const useUserStore = create(...)
|
||||
const useThemeStore = create(...)
|
||||
|
||||
// ❌ 在 state 中存储服务器返回的数据却不做缓存
|
||||
const [data, setData] = useState(fetch(...)) // 页面切换丢失,重复请求
|
||||
|
||||
// ✅ 使用 TanStack Query 等专用数据获取库处理缓存和失效
|
||||
const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ❌ 在 state 中存储服务器返回的数据却不做缓存
|
||||
const [data, setData] = useState(fetch(...)) // 页面切换丢失,重复请求
|
||||
// 问题:每次切到同一页面都会重新请求,且没有 loading / error 状态管理
|
||||
|
||||
// ✅ 使用 TanStack Query 等专用数据获取库处理缓存和失效
|
||||
const { data, isLoading, error } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
|
||||
```
|
||||
|
||||
> [!tip] 📋 状态管理最佳实践总结
|
||||
> 1. **职责分离原则** — UI 状态用 useState,全局状态用 Store,服务器数据用 Query
|
||||
> 2. **选择最小可行方案** — 能用 Context 解决的问题,不要引入 Zustand;能用 Zustand 的,不要上 Redux
|
||||
> 3. **按需订阅** — 无论哪个库,尽量用 selector 精确订阅,避免大面积重渲染
|
||||
> 4. **类型先行** — TypeScript 项目中,先定义 State interface 再写 Store,让编译器帮你守住边界
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[3. 生态工具篇/08-路由管理]] — 路由与状态的关系:URL 也是状态的一种表现形式
|
||||
- [[3. 生态工具篇/10-TS + React]] — TypeScript 类型定义在 Store 设计中的最佳实践
|
||||
|
||||
@@ -7,45 +7,55 @@ create time: 2026-04-29 22:09
|
||||
|
||||
## 概述
|
||||
|
||||
TypeScript 为 React 提供编译时类型检查和智能提示。本文档系统梳理 Props、State、Hook、Ref 等核心场景的类型定义方式,以及进阶的泛型推导模式。
|
||||
TypeScript 为 React 提供编译时类型检查和智能提示,将运行时错误提前到编码阶段。本文档从 Props、State、Hook、Ref 等核心场景切入,逐步深入到泛型推导、区分联合类型和 Schema 校验等进阶模式。
|
||||
|
||||
> [!question] 为什么 React + TS 值得投入?
|
||||
>
|
||||
> React 本身是纯 JS 库,但大型项目中「谁在改什么数据」「这个函数接收什么参数」往往成为协作瓶颈。TS 让你在写代码时就能获得精确的类型反馈,而不是等到测试环节才发现 `undefined is not a function`。
|
||||
|
||||
## Props 类型定义
|
||||
|
||||
### 基础方式
|
||||
|
||||
```tsx
|
||||
// 方式1:interface(推荐,可 extend)
|
||||
// ✅ 方式1:interface(推荐,可 extend)
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
const Button = ({ label, onClick }: ButtonProps) => <button onClick={onClick}>{label}</button>;
|
||||
const Button = ({ label, onClick }: ButtonProps) => (
|
||||
<button onClick={onClick}>{label}</button>
|
||||
);
|
||||
|
||||
// 方式2:type alias
|
||||
// ✅ 方式2:type alias(适合联合类型)
|
||||
type ButtonProps = { label: string; onClick?: () => void };
|
||||
|
||||
// ⚠️ 避免:函数参数解构后不再标注
|
||||
// 这样会导致每个参数无法被单独推断
|
||||
function Component({ a, b }) { ... } // any!
|
||||
// ❌ 避免:解构后不标注类型
|
||||
function BadComponent({ a, b }) { ... } // a 和 b 都是 any!
|
||||
```
|
||||
|
||||
> [!tip] interface vs type —— 何时用哪个?
|
||||
>
|
||||
> - **优先 interface**:它支持 `extends` / `implements`,更适合组件 Props 的层级扩展
|
||||
> - **选 type**:当你需要联合类型 (`A | B`)、交叉类型 (`A & B`) 或映射类型 (`Record<K,V>`) 时
|
||||
|
||||
### 合成事件类型
|
||||
|
||||
```tsx
|
||||
// ❌ 不要用 HTML 原生的 Event
|
||||
const handleChange = (e: Event) => {};
|
||||
|
||||
// ✅ 用 React 的合成事件类型
|
||||
// ✅ 使用 React 的合成事件类型
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.target.value; // string | number | string[]
|
||||
e.target.value; // string | number | string[](取决于 input type)
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault(); // 阻止表单默认提交
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
console.log(e.button); // 鼠标按键
|
||||
console.log(e.button); // 0=左键, 1=中键, 2=右键
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -53,18 +63,26 @@ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
};
|
||||
```
|
||||
|
||||
> [!warning] 常见误区:syntheticEvent 的池化问题
|
||||
>
|
||||
> React 17 之前合成事件会被复用(池化),异步访问时可能已被清空。在 `setTimeout` 或 Promise 回调中,请先读取到局部变量:
|
||||
> ```tsx
|
||||
> const value = e.target.value; // 先取值
|
||||
> setTimeout(() => console.log(value), 100);
|
||||
> ```
|
||||
|
||||
### Children 类型
|
||||
|
||||
```tsx
|
||||
// 通用 children 类型
|
||||
// 通用 children —— 接受任意合法 React 节点
|
||||
function Card({ children }: { children: React.ReactNode }) {}
|
||||
|
||||
// 严格类型 children(限制允许的子节点类型)
|
||||
// 严格类型 children —— 限制允许的子节点类型
|
||||
interface TabsProps {
|
||||
children: React.ReactElement<TabProps>; // 只能是 Tab 组件
|
||||
}
|
||||
|
||||
// 数组形式
|
||||
// 泛型 children —— 子节点携带的数据类型可参数化
|
||||
interface ListProps<T> {
|
||||
children: React.ReactElement<{ item: T }> [];
|
||||
}
|
||||
@@ -72,22 +90,42 @@ interface ListProps<T> {
|
||||
|
||||
## State 类型推导
|
||||
|
||||
### useState
|
||||
|
||||
```tsx
|
||||
interface User { name: string; role: "admin" | "user"; age: number }
|
||||
|
||||
// 完整泛型
|
||||
const [user, setUser] = useState<User>({ name: "", role: "user", age: 0 });
|
||||
|
||||
// 可选初始值
|
||||
// 可选初始值 —— 必须显式声明联合类型
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
// 使用时需判空
|
||||
user?.name; // 安全
|
||||
(user as User).name; // or
|
||||
user!?.name; // non-null assertion
|
||||
user?.name; // ✅ 可选链,安全
|
||||
(user as User).name; // or: 类型断言
|
||||
user!?.name; // non-null assertion(慎用)
|
||||
```
|
||||
|
||||
// useReducer 类型推导
|
||||
> [!tip] 利用构造函数推断
|
||||
>
|
||||
> 当 initialState 是个对象字面量时,可以用类构造函数的模式让 TS 自动推导出 State 类型,减少重复书写:
|
||||
> ```tsx
|
||||
> class UserStore {
|
||||
> name = "";
|
||||
> role: "admin" | "user" = "user";
|
||||
> age = 0;
|
||||
> }
|
||||
> const [user, setUser] = useState(new UserStore());
|
||||
> // 注意:这里 user 类型就是 new UserStore() 的实例类型
|
||||
> ```
|
||||
|
||||
### useReducer
|
||||
|
||||
```tsx
|
||||
interface State { items: Item[]; filter: string }
|
||||
type Action = { type: "SET_FILTER"; payload: string } | { type: "ADD_ITEM"; payload: Item };
|
||||
type Action =
|
||||
| { type: "SET_FILTER"; payload: string }
|
||||
| { type: "ADD_ITEM"; payload: Item };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
@@ -100,8 +138,10 @@ const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
## Ref 类型定义
|
||||
|
||||
### useRef
|
||||
|
||||
```tsx
|
||||
// 元素 ref
|
||||
// 元素 ref —— 最常用
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// mutable value ref(不触发 re-render)
|
||||
@@ -111,24 +151,35 @@ timerIdRef.current = setInterval(() => {}, 1000);
|
||||
// class component style ref object(用于挂载子组件引用)
|
||||
const childRef = useRef<ComponentInstance>(null);
|
||||
// 需要 useImperativeHandle 暴露方法给父级
|
||||
```
|
||||
|
||||
// forwardRef 中 ref 的正确用法
|
||||
const FancyInput = forwardRef<HTMLInputElement, { label: string }>(function FancyInput(props, ref) {
|
||||
> [!example] 为什么 ref.current 改变不会触发渲染?
|
||||
>
|
||||
> React 的更新机制只响应 setState 调用。ref 的设计初衷就是绕过 React 的响应式系统——比如在 useEffect 中保存上一次 props 的值、存储定时器 ID、或者调用子组件的原生 DOM API。如果你发现修改 ref 后需要 UI 同步更新,说明你可能应该用 state。
|
||||
|
||||
### forwardRef + useImperativeHandle
|
||||
|
||||
```tsx
|
||||
const FancyInput = forwardRef<HTMLInputElement, { label: string }>(
|
||||
function FancyInput(props, ref) {
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 只暴露 focus 和 blur 给父组件,隐藏其他原生方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => innerRef.current?.focus(),
|
||||
blur: () => innerRef.current?.blur(),
|
||||
}));
|
||||
|
||||
return <input ref={innerRef} label={props.label} />;
|
||||
});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Hook 类型推导
|
||||
|
||||
### 自定义 Hook 返回值
|
||||
|
||||
```tsx
|
||||
// 自定义 Hook 返回值类型
|
||||
interface UseCountReturn {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
@@ -143,8 +194,22 @@ function useCount(initial = 0): UseCountReturn {
|
||||
decrement: () => setCount(c => c - 1),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
// generic hook —— 最强大的类型推导场景
|
||||
> [!tip] 让返回值类型自动推导
|
||||
>
|
||||
> 如果不想手动编写返回类型接口,可以借助 TypeScript 4.7+ 的 `as const` 技巧或使用 `ReturnType` 工具类型:
|
||||
> ```tsx
|
||||
> function useMouse() {
|
||||
> const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
> useEffect(() => { /* ... */ }, []);
|
||||
> return pos; // TS 会自动推导出 { readonly x: number; readonly y: number }
|
||||
> }
|
||||
> ```
|
||||
|
||||
### 泛型 Hook —— 最强大的类型推导场景
|
||||
|
||||
```tsx
|
||||
function useAsync<T, Args extends any[]>(
|
||||
asyncFn: (...args: Args) => Promise<T>,
|
||||
deps: DependencyList
|
||||
@@ -171,15 +236,19 @@ function useAsync<T, Args extends any[]>(
|
||||
return { data, loading, error, invoke };
|
||||
}
|
||||
|
||||
// 使用:T 自动推导!
|
||||
// T 自动推导!无需手动指定
|
||||
const { data: users } = useAsync(fetchUsers, []); // data: User[] | null
|
||||
const { data: user } = useAsync(fetchUserById, [id]); // data: User | null
|
||||
const { data: config } = useAsync(fetchConfig, [env]); // data: Config | null
|
||||
```
|
||||
|
||||
> [!question] 为什么 `Args extends any[]`?
|
||||
>
|
||||
> 这让我们可以同时参数化「异步函数的返回值类型」和「异步函数的入参」。例如 `fetchUserById(id: string)` 的 `T = User`,`Args = [string]`,类型信息从内到外全自动推导,不需要在调用处再次声明。
|
||||
|
||||
## discriminated Union(区分联合类型)
|
||||
|
||||
```tsx
|
||||
// 类型守卫 —— React 状态管理中最常用的类型模式
|
||||
interface SuccessAction { type: "success"; data: User[] }
|
||||
interface ErrorAction { type: "error"; error: string }
|
||||
interface LoadingAction { type: "loading" }
|
||||
@@ -200,14 +269,238 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
```
|
||||
|
||||
> [!important] exhaustive check 防漏分支
|
||||
>
|
||||
> 添加一个 `default` 分支并用 `never` 类型确保所有联合成员都被覆盖:
|
||||
> ```tsx
|
||||
> function reducer(state: State, action: Action): State {
|
||||
> switch (action.type) {
|
||||
> case "success": return { ...state, status: "loaded" };
|
||||
> case "error": return { ...state, status: "failed" };
|
||||
> case "loading": return { ...state, status: "pending" };
|
||||
> default:
|
||||
> const _exhaustiveCheck: never = action;
|
||||
> throw new Error(`Unhandled action type: ${_exhaustiveCheck}`);
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
> 一旦新增了一个 Action 类型但没有在 switch 中处理,TS 编译器会立刻报错。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["Union Type"] -->|"switch / if"| B["Type Narrowing"]
|
||||
B --> C["discriminated union: type field"]
|
||||
A["Union Type"] -->|"switch on type field"| B["Type Narrowing"]
|
||||
B --> C["discriminated union — 最优解"]
|
||||
B --> D["typeof check"]
|
||||
B --> E["in operator"]
|
||||
B --> F["instanceof check"]
|
||||
|
||||
style C fill:#61DAFB,color:#000
|
||||
style A fill:#fff,color:#000
|
||||
```
|
||||
|
||||
## Context 类型定义
|
||||
|
||||
```tsx
|
||||
interface ThemeContextType {
|
||||
theme: "light" | "dark";
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
// 创建 context 时传入默认值(可为 null,配合非空断言使用)
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
// 封装类型安全的消费 Hook —— 比直接 useContext 更安全
|
||||
function useTheme(): ThemeContextType {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Provider 类型 —— 将 value 的类型与 Context 绑定
|
||||
function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === "light" ? "dark" : "light") }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 多 Context 时的组合策略
|
||||
>
|
||||
> Context 数量增多后,推荐使用 **多个小型 Context** 而非一个巨型 Context:每个 Context 负责一小块职责(认证、主题、语言),这样消费者只会在自己关注的 context 变化时重渲染。也可以用一个 Context 包裹另一个,形成嵌套结构。
|
||||
|
||||
## 泛型组件与 Polymorphic Components
|
||||
|
||||
### 泛型列表组件
|
||||
|
||||
```tsx
|
||||
interface SelectableTableProps<T> {
|
||||
data: T[];
|
||||
renderRow: (item: T, selected: boolean) => React.ReactNode;
|
||||
selectedIds: Set<string>;
|
||||
onSelect: (id: string) => void;
|
||||
getId: (item: T) => string;
|
||||
}
|
||||
|
||||
function SelectableTable<T>({
|
||||
data, renderRow, selectedIds, onSelect, getId,
|
||||
}: SelectableTableProps<T>) {
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{data.map(item => {
|
||||
const id = getId(item);
|
||||
const selected = selectedIds.has(id);
|
||||
return <tr key={id} onClick={() => onSelect(id)}>
|
||||
<td>{renderRow(item, selected)}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用:T 自动推导为 User
|
||||
<SelectableTable
|
||||
data={users}
|
||||
getId={(u: User) => u.id}
|
||||
renderRow={(u, sel) => <span style={{ background: sel ? "#eee" : "" }}>{u.name}</span>}
|
||||
selectedIds={selected}
|
||||
onSelect={setId}
|
||||
/>
|
||||
```
|
||||
|
||||
### HTML 标签聚合组件(Polymorphic)
|
||||
|
||||
```tsx
|
||||
// 基于 JSX.IntrinsicElements 实现 polymorphic component
|
||||
type PolymorphicComponent<P, D extends keyof JSX.IntrinsicElements> = {
|
||||
<As extends keyof JSX.IntrinsicElements = D>(
|
||||
props: P & { as?: As } & JSX.IntrinsicElements[As]
|
||||
): ReactNode;
|
||||
};
|
||||
|
||||
// Button 可以是 button/div/a 等任何 HTML 元素
|
||||
const Button: PolymorphicComponent<
|
||||
{ variant?: "primary" | "secondary" },
|
||||
"button"
|
||||
> = ({ variant = "primary", as: Tag = "button", ...props }) => (
|
||||
<Tag className={`btn btn-${variant}`} {...props} />
|
||||
);
|
||||
```
|
||||
|
||||
> [!warning] Polymorphic Component 的类型难点
|
||||
>
|
||||
> 上述写法是简化版。生产环境通常借助第三方库如 `@radix-ui/react-slot` 来处理复杂的泛型推导,因为要让 `as="a"` 时同时合并 `<a>` 的属性(`href`, `target` 等)而不产生冲突,泛型约束较为复杂。
|
||||
|
||||
## Form 校验与 Zod Schema
|
||||
|
||||
现代 React + TS 项目中,推荐使用 **Zod** 等 Schema 库将校验逻辑和类型推导合二为一:
|
||||
|
||||
```tsx
|
||||
import { z } from "zod";
|
||||
|
||||
// 1. 定义 Schema —— 类型从 schema 自动推导
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
type LoginFormValue = z.infer<typeof LoginSchema>;
|
||||
|
||||
// 2. React Hook Form + ZodResolver 无缝对接
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormValue>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
});
|
||||
|
||||
// 3. 模板中使用 —— errors 和 register 都有完整类型提示
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register("email")} placeholder="Email" />
|
||||
{/* errors.email?.message 有类型提示 */}
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
|
||||
<input {...register("password")} type="password" />
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
> [!tip] 为什么选择 Zod 而非 Yup?
|
||||
>
|
||||
> - Zod 用 TypeScript 原生语法定义,无需额外 import 类型(Yup 需要 `yupToZooooootTypes` 等桥接方案)
|
||||
> - Zod 零依赖、性能更优,且支持 `.parse()` 运行时校验与 `.infer()` 类型推导一体化
|
||||
> - Zod 的错误消息更可定制化
|
||||
|
||||
## 常见坑点与避坑指南
|
||||
|
||||
> [!failure] 坑1:`any` 的隐式传播
|
||||
>
|
||||
> `Array<any>`、`Record<string, any>` 会让整个链条失去类型保护。替代方案:
|
||||
> - 用 `unknown` 替代顶层 `any`(必须先类型守卫才能使用)
|
||||
> - 用泛型 `<T>` 传递具体的数据结构
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 危险
|
||||
> const data: Record<string, any> = {};
|
||||
> data.foo.bar.baz; // 编译通过,运行崩溃
|
||||
>
|
||||
> // ✅ 安全
|
||||
> const data: Record<string, unknown> = {};
|
||||
> if (typeof data.foo === "object" && data.foo !== null) {
|
||||
> data.foo.bar; // 需要额外的守卫
|
||||
> }
|
||||
> ```
|
||||
|
||||
> [!failure] 坑2:事件处理器中的类型收窄失效
|
||||
>
|
||||
> 箭头函数无法正确收窄:
|
||||
> ```tsx
|
||||
> // ❌ TS 无法推断 e 具体是哪个事件
|
||||
> <button onClick={(e) => handleClick(e)}>Click</button>
|
||||
>
|
||||
> // ✅ 用命名函数保持类型信息
|
||||
> <button onClick={(e) => handleClick(e)}>Click</button>
|
||||
> // 但最好直接在属性上写,不包箭头
|
||||
> <button onClick={handleClick}>Click</button>
|
||||
> ```
|
||||
|
||||
> [!failure] 坑3:useState 初始值的类型陷阱
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 类型变成 never[] —— TS 推断了最窄的空数组类型
|
||||
> const [items, setItems] = useState([]);
|
||||
> items.push("hello"); // error: Property 'push' does not exist on type 'never[]'
|
||||
>
|
||||
> // ✅ 显式声明泛型
|
||||
> const [items, setItems] = useState<string[]>([]);
|
||||
>
|
||||
> // ✅ 或者用 non-empty array 初始化
|
||||
> const [items, setItems] = useState(["default"]);
|
||||
> ```
|
||||
|
||||
> [!failure] 坑4:React.FC 的副作用
|
||||
>
|
||||
> `React.FC` 在早期被广泛推荐,但现在社区趋于弃用原因如下:
|
||||
> - 它会隐式包含 `children` prop(即使你的组件不需要)
|
||||
> - 不支持泛型
|
||||
> - 使得 ReturnType 无法正确推导
|
||||
> - 使 HOC 的 WrappedComponent 类型丢失
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 不推荐
|
||||
> const MyComponent: React.FC<MyProps> = ({ children }) => <div>{children}</div>;
|
||||
>
|
||||
> // ✅ 推荐
|
||||
> function MyComponent({ children }: MyProps) {
|
||||
> return <div>{children}</div>;
|
||||
> }
|
||||
> ```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/REACT/README.md]]
|
||||
- [[hhs/REACT/1. 基础篇/03-组件与 Props.md]]
|
||||
- [[hhs/REACT/1. 基础篇/04-State 与不可变性.md]]
|
||||
- [[hhs/REACT/3. 生态工具篇/08-路由管理.md]]
|
||||
- [[hhs/REACT/3. 生态工具篇/09-状态管理.md]]
|
||||
|
||||
Reference in New Issue
Block a user