diff --git a/hhs/REACT/3. 生态工具篇/08-路由管理.md b/hhs/REACT/3. 生态工具篇/08-路由管理.md index b90927f..4c20e31 100644 --- a/hhs/REACT/3. 生态工具篇/08-路由管理.md +++ b/hhs/REACT/3. 生态工具篇/08-路由管理.md @@ -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["路由匹配
按 path 从上到下匹配"] + Match --> BestMatch["最佳匹配胜出"] + BestMatch --> Render["渲染组件树
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 树` → `渲染对应组件` + +两条跳转路径: +- **声明式导航**:用户点击 `` 组件,浏览器地址栏更新但不刷新 +- **程序化导航**: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: , + element: , // 所有页面共用的外层 shell children: [ - { index: true, element: }, - { path: "about", element: }, + { index: true, element: }, // "/" → 首页 + { path: "about", element: }, // "/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: , // 侧边栏 + Header 常驻 + element: , // 侧边栏 + Header 常驻不卸载 children: [ - { index: true, element: }, // /dashboard → DashboardHome - { path: "stats", element: }, // /dashboard/stats - { path: "settings", element: }, // /dashboard/settings - { path: "settings/:tab", element: }, // /dashboard/settings/profile + { index: true, element: }, // /dashboard → DashboardHome + { path: "stats", element: }, // /dashboard/stats → StatsPage + { path: "settings", element: }, // /dashboard/settings → SettingsPage + { path: "settings/:tab", element: }, // /dashboard/settings/profile ], } ``` -```jsx +```tsx // DashboardLayout.tsx +import { Outlet } from "react-router-dom"; + function DashboardLayout() { return (
- {/* v7 Outlet 替代了 v6的children渲染 */} + {/* 是嵌套内容的"占位槽" */} +
); } ``` -> [!note] Outlet vs Children -> - v6 用 `` 内部定义子路由 -> - v7 用 `` 显式标注嵌套出口,更清晰 +> [!note] 的本质 +> `` 是一个**占位组件**。当用户访问 `/dashboard/stats` 时: +> 1. React Router 匹配到 `path: "dashboard"` 这个 layout route,渲染 `` +> 2. 在 `` 内部遇到 `` → 继续向下匹配子路由 +> 3. 子路由 `` 被渲染到 `` 的位置 +> +> **关键理解**:没有 ``,子路由内容就无法显示!这是 v6/v7 中嵌套路由的唯一通信方式。 + +> [!warning] 常见坑:忘记加 Outlet +> ```tsx +> // ❌ 错误:layout route 的子路由永远不会渲染 +> function DashboardLayout() { +> return <>; // 硬编码了具体页面 +> } +> +> // ✅ 正确:用 Outlet 留出插槽 +> function DashboardLayout() { +> return <>; +> } +> ``` ## 路由参数获取 +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 { id } = useParams(); // /user/:id → { id: "123" } + const [searchParams] = useSearchParams(); // ?q=hello&page=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

User #{id}, searching for "{query}"

; } ``` -### 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: , } ``` +```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 ( +
+

{post.title}

+

{post.body}

+ {actionResult?.success &&
保存成功!
} +
+ ); +} +``` + +> [!question] loader vs useEffect 获取数据有什么区别? +> - **loader**:先取数据 → 数据就绪后 → 才渲染页面。用户不会看到空状态或 loading 骨架屏(配合 Suspense 可以显示 fallback)。 +> - **useEffect + useState**:先渲染空白/loading → 请求发出 → 数据回来 → 更新 UI。会有闪烁过程。 +> +> loader 本质是**服务端渲染(SSR)友好的异步数据模式**。即使不使用 SSR,它的"数据先于渲染"语义也能减少水合闪烁。 + ## 懒加载 Code Splitting +代码分割的核心目标:**减少首屏加载体积**。通过 `React.lazy()` + ``,将非关键路由的代码拆成独立 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: ( + }> + {/* 2. chunk 加载完毕 + loader 数据就绪后渲染 */} + + + ), +} +``` + > [!tip] 懒加载时机判断 -> - 首屏路由(首页、登录页)**不要**懒加载 -> - 低频访问页面(设置、管理员面板)适合懒加载 -> - 每个 chunk 建议不超过 100KB gzipped +> - **不要懒加载**:首页、登录页等高频入口 +> - **适合懒加载**:设置页、管理员面板、低频功能模块 +> - **chunk 大小建议**:每个 chunk gzipped 后不超过 100KB +> - **公共代码尽量提取**:避免多个 chunk 重复包含 React / utils ## 动态路由与 Splat 路由 -```tsx -// :param —— 单个段匹配 -{ path: "users/:userId", element: } // /users/42 +URL 中的可变部分是前端路由的核心灵活性来源。React Router 提供了两种参数匹配方式: -// * splat —— 贪婪匹配剩余所有 -{ path: "docs/*", element: } // /docs/a/b/c -{ path: "*", element: } // 404 fallback +```tsx +// :param —— 匹配单个路径段 +{ path: "users/:userId", element: } // /users/42 ✅ + // /users/42/posts ❌(不匹配,需子路由) + +// * splat —— 贪婪匹配剩余所有段 +{ path: "docs/*", element: } // /docs/a/b/c ✅ +{ path: "*", element: } // /anything ❌ → 404 fallback + +// 多参数组合 +{ + path: "posts/:postId/comments/:commentId", + element: , // /posts/1/comments/5 +} ``` +### Error Route —— 错误边界处理 + +v7 中每个路由都可以声明 `errorElement`,当 loader/action 抛出异常时自动渲染: + +```tsx +{ + path: "admin", + loader: adminLoader, // 可能抛 401/403 + errorElement: , // 接管错误 UI +} + +// AdminErrorBoundary.tsx +import { useRouteError } from "react-router-dom"; + +function AdminErrorBoundary() { + const error = useRouteError() as Response; + + if (error.status === 401) return ; + if (error.status === 403) return ; + + return ; +} +``` + +> [!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 ; 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: , } ``` -## 导航 API 对比 +### 多级权限设计建议 -| 方法 | 适用场景 | 是否保留历史记录 | -|------|----------|------------------| -| `navigate(path)` | 程序化跳转 | ✅ 有 history entry | -| `Home` | 声明式导航 | ✅ 预加载 prefetch | -| `navigate(-1)` | 返回上一页 | ✅ | -| `` | 替换当前 entry | ❌ 不增加 history | +| 层级 | 方式 | 说明 | +|------|------|------| +| URL 层 | `errorElement` + loader | 拦截非法访问,返回 401/403 | +| UI 层 | `useLoaderData()` 判断 | 基于用户角色隐藏按钮/菜单项 | +| API 层 | 后端 JWT / Session | **最终防线**,前端判断必须被后端验证 | + +> [!warning] 前端权限 ≠ 安全保障 +> 所有前端守卫只影响用户体验,真正的安全必须由后端 API 保障。不要把敏感逻辑放在前端 loader 中。 + +## 导航 API 对比与选择 + +| 方式 | 代码示例 | 适用场景 | 保留历史 | Prefetch | +|------|----------|----------|---------|----------| +| `` | `` | 页面内跳转(推荐首选) | ✅ | 可配置 prefetch | +| `navigate()` | `navigate("/home")` | JS 逻辑中触发跳转 | ✅ | ❌ | +| `navigate(-1)` | — | "返回上一页"按钮 | ✅ | N/A | +| `` | `` | 登录后自动重定向,不污染 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 基础 diff --git a/hhs/REACT/3. 生态工具篇/09-状态管理.md b/hhs/REACT/3. 生态工具篇/09-状态管理.md index 70b71c7..abff9e0 100644 --- a/hhs/REACT/3. 生态工具篇/09-状态管理.md +++ b/hhs/REACT/3. 生态工具篇/09-状态管理.md @@ -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 状态
(主题、菜单展开、模态框)"] - C --> E["服务器数据 / 异步缓存"] - C --> F["应用级业务状态
(用户信息、购物车、权限)"] + C --> D["Pure UI state
(theme, menu, modal)"] + C --> E["Server data / async cache"] + C --> F["App-level business state
(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 {children}; +} + +// ✅ 正确做法 — useMemo + useCallback 双重稳定化 +function GoodProvider({ children }: { children: React.ReactNode }) { + const [user, login] = useAuth(); + const value = useMemo(() => ({ user, login }), [user, login]); + return {children}; +} +``` + ## Zustand —— 轻量级现代方案 +> [!tip] Zustand 的核心理念:像用 state 一样用 store +> 不需要 Provider、不需要 dispatch、不需要 reducer。直接 create → 直接 use,零样板代码。 + ```ts import { create } from "zustand"; @@ -84,8 +114,10 @@ const useStore = create((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((set, get) => ({ // 组件中使用 function Counter() { - // ✅ 只订阅 count —— 其他状态变化不会触发此组件重渲染 + // ✅ selector 模式:只订阅 count,users/increment 变化不会触发此组件重渲染 const count = useStore(s => s.count); const increment = useStore(s => s.increment); return ; } -// 批量更新 -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()( + // ✅ 多个中间件可以叠加使用 + 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(); + // ✅ useSelector 自带 selector(浅比较)—— 只有 value 变化时才重渲染 const count = useSelector((s: AppState) => s.counter.value); return ; @@ -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 设计中的最佳实践 diff --git a/hhs/REACT/3. 生态工具篇/10-TS + React.md b/hhs/REACT/3. 生态工具篇/10-TS + React.md index 678419a..285c6e0 100644 --- a/hhs/REACT/3. 生态工具篇/10-TS + React.md +++ b/hhs/REACT/3. 生态工具篇/10-TS + React.md @@ -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) => ; +const Button = ({ label, onClick }: ButtonProps) => ( + +); -// 方式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`) 时 + ### 合成事件类型 ```tsx // ❌ 不要用 HTML 原生的 Event const handleChange = (e: Event) => {}; -// ✅ 用 React 的合成事件类型 +// ✅ 使用 React 的合成事件类型 const handleChange = (e: React.ChangeEvent) => { - e.target.value; // string | number | string[] + e.target.value; // string | number | string[](取决于 input type) }; const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + e.preventDefault(); // 阻止表单默认提交 }; const handleClick = (e: React.MouseEvent) => { - console.log(e.button); // 鼠标按键 + console.log(e.button); // 0=左键, 1=中键, 2=右键 }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -53,18 +63,26 @@ const handleKeyDown = (e: React.KeyboardEvent) => { }; ``` +> [!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; // 只能是 Tab 组件 } -// 数组形式 +// 泛型 children —— 子节点携带的数据类型可参数化 interface ListProps { children: React.ReactElement<{ item: T }> []; } @@ -72,22 +90,42 @@ interface ListProps { ## State 类型推导 +### useState + ```tsx interface User { name: string; role: "admin" | "user"; age: number } // 完整泛型 const [user, setUser] = useState({ name: "", role: "user", age: 0 }); -// 可选初始值 +// 可选初始值 —— 必须显式声明联合类型 const [user, setUser] = useState(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(null); // mutable value ref(不触发 re-render) @@ -111,24 +151,35 @@ timerIdRef.current = setInterval(() => {}, 1000); // class component style ref object(用于挂载子组件引用) const childRef = useRef(null); // 需要 useImperativeHandle 暴露方法给父级 +``` -// forwardRef 中 ref 的正确用法 -const FancyInput = forwardRef(function FancyInput(props, ref) { - const innerRef = useRef(null); - - useImperativeHandle(ref, () => ({ - focus: () => innerRef.current?.focus(), - blur: () => innerRef.current?.blur(), - })); - - return ; -}); +> [!example] 为什么 ref.current 改变不会触发渲染? +> +> React 的更新机制只响应 setState 调用。ref 的设计初衷就是绕过 React 的响应式系统——比如在 useEffect 中保存上一次 props 的值、存储定时器 ID、或者调用子组件的原生 DOM API。如果你发现修改 ref 后需要 UI 同步更新,说明你可能应该用 state。 + +### forwardRef + useImperativeHandle + +```tsx +const FancyInput = forwardRef( + function FancyInput(props, ref) { + const innerRef = useRef(null); + + // 只暴露 focus 和 blur 给父组件,隐藏其他原生方法 + useImperativeHandle(ref, () => ({ + focus: () => innerRef.current?.focus(), + blur: () => innerRef.current?.blur(), + })); + + return ; + } +); ``` ## 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( asyncFn: (...args: Args) => Promise, deps: DependencyList @@ -152,7 +217,7 @@ function useAsync( const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + const invoke = useCallback(async (...args: Args) => { setLoading(true); setError(null); @@ -165,23 +230,27 @@ function useAsync( setLoading(false); } }, [asyncFn, ...deps]); - + useEffect(() => { invoke(); }, [invoke]); - + return { data, loading, error, invoke }; } -// 使用:T 自动推导! -const { data: users } = useAsync(fetchUsers, []); // data: User[] | null -const { data: user } = useAsync(fetchUserById, [id]); // data: User | null +// 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 ErrorAction { type: "error"; error: string } interface LoadingAction { type: "loading" } type Action = SuccessAction | ErrorAction | LoadingAction; @@ -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(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 ( + setTheme(t => t === "light" ? "dark" : "light") }}> + {children} + + ); +} +``` + +> [!tip] 多 Context 时的组合策略 +> +> Context 数量增多后,推荐使用 **多个小型 Context** 而非一个巨型 Context:每个 Context 负责一小块职责(认证、主题、语言),这样消费者只会在自己关注的 context 变化时重渲染。也可以用一个 Context 包裹另一个,形成嵌套结构。 + +## 泛型组件与 Polymorphic Components + +### 泛型列表组件 + +```tsx +interface SelectableTableProps { + data: T[]; + renderRow: (item: T, selected: boolean) => React.ReactNode; + selectedIds: Set; + onSelect: (id: string) => void; + getId: (item: T) => string; +} + +function SelectableTable({ + data, renderRow, selectedIds, onSelect, getId, +}: SelectableTableProps) { + return ( + + + {data.map(item => { + const id = getId(item); + const selected = selectedIds.has(id); + return onSelect(id)}> + + ; + })} + +
{renderRow(item, selected)}
+ ); +} + +// 使用:T 自动推导为 User + u.id} + renderRow={(u, sel) => {u.name}} + selectedIds={selected} + onSelect={setId} +/> +``` + +### HTML 标签聚合组件(Polymorphic) + +```tsx +// 基于 JSX.IntrinsicElements 实现 polymorphic component +type PolymorphicComponent = { + ( + 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 }) => ( + +); +``` + +> [!warning] Polymorphic Component 的类型难点 +> +> 上述写法是简化版。生产环境通常借助第三方库如 `@radix-ui/react-slot` 来处理复杂的泛型推导,因为要让 `as="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; + +// 2. React Hook Form + ZodResolver 无缝对接 +const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(LoginSchema), +}); + +// 3. 模板中使用 —— errors 和 register 都有完整类型提示 +
+ + {/* errors.email?.message 有类型提示 */} + {errors.email && {errors.email.message}} + + + +
+``` + +> [!tip] 为什么选择 Zod 而非 Yup? +> +> - Zod 用 TypeScript 原生语法定义,无需额外 import 类型(Yup 需要 `yupToZooooootTypes` 等桥接方案) +> - Zod 零依赖、性能更优,且支持 `.parse()` 运行时校验与 `.infer()` 类型推导一体化 +> - Zod 的错误消息更可定制化 + +## 常见坑点与避坑指南 + +> [!failure] 坑1:`any` 的隐式传播 +> +> `Array`、`Record` 会让整个链条失去类型保护。替代方案: +> - 用 `unknown` 替代顶层 `any`(必须先类型守卫才能使用) +> - 用泛型 `` 传递具体的数据结构 +> +> ```tsx +> // ❌ 危险 +> const data: Record = {}; +> data.foo.bar.baz; // 编译通过,运行崩溃 +> +> // ✅ 安全 +> const data: Record = {}; +> if (typeof data.foo === "object" && data.foo !== null) { +> data.foo.bar; // 需要额外的守卫 +> } +> ``` + +> [!failure] 坑2:事件处理器中的类型收窄失效 +> +> 箭头函数无法正确收窄: +> ```tsx +> // ❌ TS 无法推断 e 具体是哪个事件 +> +> +> // ✅ 用命名函数保持类型信息 +> +> // 但最好直接在属性上写,不包箭头 +> +> ``` + +> [!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([]); +> +> // ✅ 或者用 non-empty array 初始化 +> const [items, setItems] = useState(["default"]); +> ``` + +> [!failure] 坑4:React.FC 的副作用 +> +> `React.FC` 在早期被广泛推荐,但现在社区趋于弃用原因如下: +> - 它会隐式包含 `children` prop(即使你的组件不需要) +> - 不支持泛型 +> - 使得 ReturnType 无法正确推导 +> - 使 HOC 的 WrappedComponent 类型丢失 +> +> ```tsx +> // ❌ 不推荐 +> const MyComponent: React.FC = ({ children }) =>
{children}
; +> +> // ✅ 推荐 +> function MyComponent({ children }: MyProps) { +> return
{children}
; +> } +> ``` + ## 关联笔记 + +- [[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]]