Files
cs-note/hzh/TEST/React.md
T
2026-05-24 11:42:38 +08:00

971 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- 前端
- React
- Hooks
- 路由
- 测试
- 自我考察
create time: 2026-04-30 15:00
update time: 2026-04-30 15:45
status: reviewed
---
# React Hook 与路由状态测试
## 使用说明
本试卷覆盖 **Hooks 核心机制 → 自定义 Hooks → 路由状态管理 → 工程实践** 全链路知识点。共四部分:选择题、编程题、场景分析、综合应用。
**建议用时:** 45 分钟 | **及格线:** 70 / 100
做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。
---
## 一、选择题(每题 3 分,共 45 分)
> 每题只有一个正确答案。
### Q1.【useState 的状态更新机制】
以下关于 `useState` 的描述,**错误**的是:
A. 同一批次内连续调用多次 `setState` 会被合并优化
B. 状态更新是异步的,因此更新后立即访问 state 可能是旧值
C. 当新状态与旧状态使用 `Object.is` 比较为相同值时,组件不会重新渲染
D. 可以直接修改 state 对象来触发重新渲染
> [!tip]- Q1 答案
> **D — React 要求状态不可变,直接修改 state 对象不会触发重新渲染**
>
> **解析:**
> React 的状态更新遵循不可变性原则。直接修改 state 对象(如 `state.count = count + 1`)不会触发组件重新渲染,因为 React 无法检测到这种变化。必须通过 `setState` 返回新对象。
>
> **为什么其他选项正确:**
> - ✅ A:React 会将同一批次内的多次 setState 合并为一次更新,这是批处理优化
> - ✅ B:setState 是异步的(在 React 18 中使用自动批处理),更新后立即访问 state 可能仍是旧值
> - ✅ C:React 使用 `Object.is` 比较新旧状态,如果相同则跳过渲染
>
> ```tsx
> // ❌ 错误:直接修改
> state.count = state.count + 1;
>
> // ✅ 正确:返回新对象
> setCount(prev => prev + 1);
> ```
>
> 💡 **核心原则:** 状态不可变性是 React 的基石,它让 React 能够通过浅比较快速判断是否需要重新渲染。
### Q2.【useEffect 的依赖数组】
```tsx
useEffect(() => {
fetchData();
}, [userId]);
```
当 `userId` 变化时,以下哪种说法正确?
A. 先执行清理函数(如果有的话),再执行 effect
B. 直接执行 effect,忽略之前的 effect
C. 必须在 effect 内部手动调用 cleanup
D. 以上都不对
> [!tip]- Q2 答案
> **A — React 在每次执行新的 effect 之前,会先执行上一次 effect 的清理函数**
>
> **解析:**
> 这是 React 的清理机制核心设计。当依赖项变化时:
> 1. 先执行上一次 effect 的清理函数(cleanup)
> 2. 再执行新的 effect
>
> 这种设计确保了资源的正确释放,避免内存泄漏。
>
> ```tsx
> useEffect(() => {
> const controller = new AbortController();
> // effect 逻辑
> return () => controller.abort(); // 清理函数
> }, [userId]);
> ```
>
> 💡 **应用场景:** 取消未完成的请求、清除定时器、取消事件监听器等。
### Q3.【路由参数获取】
在 React Router v6+ 中,以下哪种方式**不能**获取动态路由参数 `/user/:id` 中的 `id`?
A. `useParams()` hook
B. `useMatch('/user/:id')` 返回的 match 对象
C. `useLocation()` 解析 pathname
D. 直接从 props 读取 `props.params.id`
> [!tip]- Q3 答案
> **D — 函数组件不再通过 props 接收路由参数**
>
> **解析:**
> 在 React Router v6+ 中,函数组件使用 hooks 获取路由参数,而不是通过 props。`props.params` 是 Class 组件时代的 React Router v5 写法。
>
> | 方式 | 是否可行 | 说明 |
> |------|---------|------|
> | `useParams()` | ✅ | 最常用,返回参数对象 |
> | `useMatch()` | ✅ | 返回匹配信息,包含 params |
> | `useLocation()` | ⚠️ | 可以解析 pathname,但需要手动提取 |
> | `props.params` | ❌ | v5 写法,v6 不支持 |
>
> ```tsx
> // React Router v6+ 正确做法
> const { id } = useParams();
> ```
>
> 💡 **迁移注意:** 从 v5 迁移到 v6 时,需要将 `props.params.xxx` 改为 `useParams().xxx`。
### Q4.【自定义 Hook 的调用规则】
以下关于自定义 Hook 的代码,哪个会导致运行时错误?
A. 在组件顶层调用 `useCustomHook()`
B. 在 useEffect 回调中调用 `useCustomHook()`
C. 在条件语句中调用 `useCustomHook()`
D. 在事件处理函数中调用 `useCustomHook()`
> [!tip]- Q4 答案
> **C — Hooks 必须在组件顶层或自定义 Hook 中调用,不能在条件语句、循环或嵌套函数中调用**
>
> **解析:**
> 这违反了 Hooks 的调用顺序规则。React 依赖 Hook 的调用顺序来正确关联状态和 Effect。如果在条件语句中调用 Hook,当条件变化时,Hook 的调用顺序也会变化,导致状态错乱。
>
> ```tsx
> // ❌ 错误:条件调用
> if (isLoggedIn) {
> const data = useUserData(); // 违反规则
> }
>
> // ✅ 正确:顶层调用
> const data = useUserData();
> const result = isLoggedIn ? data : null;
> ```
>
> 💡 **记忆口诀:** "Hook 只能在顶层调用,不在条件、循环、嵌套函数中使用"。
### Q5.【嵌套路由的 Outlet 使用】
关于 React Router 的 `<Outlet>` 组件,以下哪项描述正确?
A. Outlet 必须在父路由组件中渲染
B. Outlet 只能渲染一层嵌套
C. Outlet 的位置可以是任意的,只要在父组件中
D. Outlet 渲染的内容由父路由的 `element` 属性决定
> [!tip]- Q5 答案
> **A — Outlet 必须在父路由组件中渲染**
>
> **解析:**
> `<Outlet>` 是嵌套路由的核心,它作为占位符,渲染匹配的子路由组件。每个嵌套路由的父组件都必须包含一个 `<Outlet>`,否则子路由无法渲染。
>
> ```tsx
> // 父路由组件
> function Layout() {
> return (
> <div>
> <Sidebar />
> <Outlet /> {/* 子路由在此渲染 */}
> </div>
> );
> }
>
> // 路由配置
> <Route path="/dashboard" element={<Layout />}>
> <Route path="profile" element={<Profile />} />
> <Route path="settings" element={<Settings />} />
> </Route>
> ```
>
> **为什么其他选项错误:**
> - ❌ B:Outlet 可以渲染任意深度的嵌套
> - ❌ C:位置可以任意,但通常放在需要显示子路由内容的地方
> - ❌ D:Outlet 渲染的是匹配的子路由组件,不是父路由的 element
>
> 💡 **最佳实践:** Outlet 通常放在布局组件中,用于渲染子页面内容。
---
### Q6.【Context 的重新渲染行为】
```tsx
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Header />
<Sidebar />
</ThemeContext.Provider>
);
}
```
关于以上代码,以下说法**正确**的是:
A. `Header` 和 `Sidebar` 只有在 `theme` 变化时才会重新渲染
B. `Header` 和 `Sidebar` 在任何时候都不会重新渲染,因为它们没有直接订阅 context
C. 只要 `value` 引用变化,所有消费该 context 的组件都会重新渲染,即使用到的值没变
D. 应该在每次渲染时创建新的 context 值,以确保数据最新
> [!tip]- Q6 答案
> **C — Context Provider 使用引用比较来判断是否需要通知消费者**
>
> **解析:**
> `value={{ theme, setTheme }}` 在每次渲染时都是新对象,导致所有消费该 context 的组件都重新渲染。即使用了的值没变也会渲染。
>
> **解决方案:**
> ```tsx
> // ✅ 使用 useMemo 稳定 value 引用(仅当相关值变化时更新)
> const value = useMemo(() => ({ theme, setTheme }), [theme]);
>
> // ✅ 或拆分 context,只让需要的部分变化
> const ThemeValueContext = createContext('light');
> const SetThemeContext = createContext(() => {});
> ```
>
> 💡 **核心原则:** context 是「全有或全无」的通知机制,无法按字段粒度优化。需要细粒度控制时应拆分 context 或使用状态管理库。
>
> **为什么其他选项错误:**
> - ❌ A:忽略了 value 引用变化的影响
> - ❌ B:消费 context 的组件会在 provider value 变化时重新渲染
> - ❌ D:恰恰相反,应稳定 value 引用以避免不必要的渲染
### Q7.【React 虚拟 DOM Diff 算法】
关于 React 的 Diff 算法,以下描述**正确**的是:
A. React 会对树进行深度优先对比,逐个节点比较
B. React 只对同层组件进行比较,不同层的组件不会跨层对比
C. React 通过 key 属性来精确匹配组件身份,避免不必要的重建
D. 当列表元素的顺序发生变化时,React 会重新排列 DOM 节点而不是重建
> [!tip]- Q7 答案
> **B、C、D 均为正确描述(本题为多选题)**
>
> **解析:**
> React 的 Diff 算法采用三种启发式策略简化复杂度:
> 1. **只对同层比较**:不同树的节点直接销毁重建,不会跨层移动
> 2. **key 匹配身份**:列表中使用稳定的 key 帮助 React 识别哪些元素变了
> 3. **类型相同则复用**:同层同类型的组件会复用实例,仅更新变化的 props
>
> ```tsx
> // ❌ 不推荐:用 index 作为 key,会导致不必要的状态重置和渲染错误
> {items.map((item, index) => <Item key={index} item={item} />)}
>
> // ✅ 推荐:使用稳定且唯一的 id 作为 key
> {items.map(item => <Item key={item.id} item={item} />)}
> ```
>
> 💡 **性能关键:** key 的作用是帮助 React 识别「哪个元素变了」而非「是否应该渲染」。没有 key 时 React 依赖索引,列表增删时会出错。
>
> **为什么 A 错误:** React 的 diff 是同级比较,不是纯粹的深度优先,且采用了类型优先的比较策略。
### Q8.【useCallback 与 useMemo 的区别】
以下关于 `useCallback` 和 `useMemo` 的说法,**错误**的是:
A. `useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)`
B. 两者返回的值在依赖不变时都是稳定的引用
C. `useMemo` 用于缓存计算结果,`useCallback` 用于缓存函数本身
D. 如果没有传入依赖数组,两者的行为与直接使用原始值没有区别
> [!tip]- Q8 答案
> **D — 没有依赖数组时,每次渲染都会重新计算,失去缓存意义**
>
> **解析:**
> 省略依赖数组意味着 Hooks 不会做任何缓存——每次渲染都会执行回调并返回新值,这与普通变量的行为无异。
>
> **对比:**
>
> | Hook | 返回值 | 典型用途 |
> |------|--------|---------|
> | `useCallback(fn, deps)` | 缓存的函数引用 | 传给子组件的回调 |
> | `useMemo(() => val, deps)` | 缓存的计算结果 | 昂贵计算、衍生状态 |
>
> ```tsx
> // ✅ 正确:有依赖数组
> const memoizedFn = useCallback(() => doSomething(a, b), [a, b]);
> const memoizedValue = useMemo(() => expensiveCalc(data), [data]);
>
> // ⚠️ 常见误区:不加 deps 等于白写
> const badFn = useCallback(() => doSomething(a), []); // a 变化时不会更新
> ```
>
> 💡 **最佳实践:** 只在传参给 memo 子组件或有第三方库依赖时使用 useCallback,日常函数不需要过度优化。
### Q9.【Error Boundary 的能力边界】
以下哪种错误**不能**被 Error Boundary 捕获?
A. 事件处理函数中的错误
B. 生命周期钩子中的错误
C. 组件树渲染过程中的错误
D. `useEffect` 中的异步错误
> [!tip]- Q9 答案
> **A、D — Error Boundary 只能捕获渲染期、生命周期期和构造期的同步错误**
>
> **解析:**
> Error Boundary 是一个特殊的 React 组件,通过声明周期方法捕获子组件树的错误。但它有明确的限制:
>
> | 场景 | 能否捕获 | 原因 |
> |------|---------|------|
> | 渲染阶段 | ✅ | 标准捕获范围 |
> | 生命周期 | ✅ | 标准捕获范围 |
> | 构造函数 | ✅ | 标准捕获范围 |
> | 事件处理 | ❌ | 属于异步回调,不在捕获范围 |
> | useEffect 异步 | ❌ | 异步操作不在捕获范围 |
> | 自身错误 | ❌ | 不会捕获自己抛出的错误 |
> | 路由 | ❌ | React Router 的错误不属于 React 抛出 |
> | 异常处理 | ❌ | `try/catch` 无法捕获 Promise 未处理错误 |
>
> **正确的错误处理方式:**
> ```tsx
> // 事件处理中自行处理
> function handleClick() {
> try {
> riskyOperation();
> } catch (err) {
> handleError(err);
> }
> }
>
> // useEffect 中的异步错误
> useEffect(() => {
> fetchData().catch(err => console.error(err));
> }, []);
>
> // Error Boundary 组件
> class ErrorBoundary extends React.Component {
> static getDerivedStateFromError(error) {
> return { hasError: true };
> }
> componentDidCatch(error, errorInfo) {
> reportToService(error, errorInfo);
> }
> render() {
> if (this.state.hasError) return <FallbackUI />;
> return this.props.children;
> }
> }
> ```
>
> 💡 **记忆要点:** Error Boundary 只管「渲染时」的错误,异步路径一律不管。
### Q10.【Portal 的使用场景】
`ReactDOM.createPortal(child, container)` 的主要作用是什么?
A. 将子节点渲染到父组件 DOM 层级之外的指定容器中
B. 提升子组件的状态到全局 store
C. 绕过 React 的生命周期直接进入原生 DOM 操作
D. 将子组件的内容克隆到多个位置同时渲染
> [!tip]- Q10 答案
> **A — Portal 允许将子树渲染到 DOM 树的任何位置**
>
> **解析:**
> Portal 改变了渲染的 DOM 位置,但**保持 React 树结构不变**。这意味着事件冒泡仍然按照 React 组件树的层次关系向上冒泡,不受 DOM 结构影响。
>
> ```tsx
> // Modal 渲染到 document.body,但在 React 树中仍属于 Dialog 组件
> function Modal({ isOpen, children }) {
> if (!isOpen) return null;
> return createPortal(
> <div className="modal-overlay">{children}</div>,
> document.body
> );
> }
>
> // 事件冒泡仍然是:Button → Dialog → Modal,不会因为 DOM 分离而中断
> ```
>
> **常见使用场景:**
> - 模态框 / 弹窗(需要突破 z-index 和 overflow:hidden 的限制)
> - Tooltip / 下拉菜单(需要覆盖多个层级的定位)
> - 固定定位的全局组件(Toast、Loading 遮罩等)
>
> 💡 **注意:** Portal 改变的是 DOM 挂载位置,不改变 React 组件层级。父子关系、Context、事件冒泡照常工作。
---
## 二、编程题(每题 10 分,共 40 分)
> 实现题目要求的功能,写出核心代码。
### Q11.【自定义 Hook:useFetch】
**要求**:实现一个通用的 `useFetch` hook,支持加载状态、错误处理和数据缓存。
```tsx
// 请补全以下 Hook 的实现
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
// 你的实现
}
// 使用示例
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{data?.name}</div>;
}
```
> [!tip]- Q11 参考实现
>
> ```tsx
> function useFetch<T>(url: string) {
> const [data, setData] = useState<T | null>(null);
> const [loading, setLoading] = useState(true);
> const [error, setError] = useState<Error | null>(null);
>
> useEffect(() => {
> const controller = new AbortController();
> setLoading(true);
>
> fetch(url, { signal: controller.signal })
> .then(res => {
> if (!res.ok) throw new Error(`HTTP ${res.status}`);
> return res.json();
> })
> .then(setData)
> .catch(err => {
> if (err.name !== 'AbortError') setError(err);
> })
> .finally(() => setLoading(false));
>
> return () => controller.abort();
> }, [url]);
>
> return { data, loading, error };
> }
> ```
>
> **考察要点:**
> - useState 多状态管理
> - useEffect 副作用处理
> - AbortController 取消请求
> - 错误边界处理
> - loading 状态管理
>
> 💡 **进阶优化:** 可以添加缓存机制、防抖、重试功能等。
### Q12.【路由守卫实现】
**要求**:实现一个路由守卫组件,根据用户认证状态决定是否允许访问。
```tsx
// 要求:
// 1. 已登录用户:直接渲染 children
// 2. 未登录用户:重定向到 /login,并保存当前路径以便登录后返回
// 3. 登录后自动跳转回原页面
// 请实现以下组件
function ProtectedRoute({ children }: { children: React.ReactNode }) {
// 你的实现
}
// 路由配置示例
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
```
> [!tip]- Q12 参考实现
>
> ```tsx
> function ProtectedRoute({ children }: { children: React.ReactNode }) {
> const { isAuthenticated } = useAuth();
> const location = useLocation();
>
> if (!isAuthenticated) {
> // 保存当前路径,登录后可返回
> return <Navigate to="/login" state={{ from: location }} replace />;
> }
>
> return <>{children}</>;
> }
>
> // 登录页处理
> function LoginPage() {
> const navigate = useNavigate();
> const location = useLocation();
> const { login } = useAuth();
>
> const handleLogin = async (credentials: LoginCredentials) => {
> await login(credentials);
> // 从 state 获取原始路径
> const from = (location.state as any)?.from?.pathname || '/';
> navigate(from, { replace: true });
> };
>
> // ... 登录表单
> }
> ```
>
> **考察要点:**
> - `useNavigate` 编程式导航
> - `useLocation` 获取当前路径
> - `Navigate` 组件的 `state` 和 `replace` 属性
> - 登录后返回原页面的实现思路
>
> 💡 **安全考虑:** 生产环境应验证 state.from 的合法性,防止开放重定向漏洞。
### Q13.【购物车状态同步】
假设你正在开发一个电商应用,购物车状态需要在以下地方同步:
1. 商品列表页的"加入购物车"按钮
2. 购物车页面的商品列表和总价
3. 导航栏的购物车数量徽章
**要求**:使用 Context API 实现这个功能,写出核心代码结构。
> [!tip]- Q13 参考实现
>
> ```tsx
> // CartContext.tsx
> interface CartItem {
> id: string;
> name: string;
> price: number;
> quantity: number;
> }
>
> interface CartState {
> items: CartItem[];
> total: number;
> addItem: (item: Omit<CartItem, 'quantity'>) => void;
> removeItem: (id: string) => void;
> updateQuantity: (id: string, quantity: number) => void;
> }
>
> const CartContext = createContext<CartState | null>(null);
>
> export function CartProvider({ children }: { children: React.ReactNode }) {
> const [items, setItems] = useState<CartItem[]>([]);
>
> const addItem = useCallback((product: Omit<CartItem, 'quantity'>) => {
> setItems(prev => {
> const existing = prev.find(item => item.id === product.id);
> if (existing) {
> return prev.map(item =>
> item.id === product.id
> ? { ...item, quantity: item.quantity + 1 }
> : item
> );
> }
> return [...prev, { ...product, quantity: 1 }];
> });
> }, []);
>
> const total = useMemo(
> () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
> [items]
> );
>
> return (
> <CartContext.Provider value={{ items, total, addItem, ... }}>
> {children}
> </CartContext.Provider>
> );
> }
>
> // 自定义 Hook
> export function useCart() {
> const context = useContext(CartContext);
> if (!context) throw new Error('useCart must be used within CartProvider');
> return context;
> }
> ```
>
> **考察要点:**
> - Context 创建和 Provider
> - 自定义 Hook 封装
> - useCallback 和 useMemo 优化
> - 状态更新逻辑
>
> 💡 **性能优化:** 可以拆分 Context,将频繁变化的数据和不常变化的函数分离。
### Q14.【useLocalStorage Hook】
**要求**:实现一个与 `useState` API 完全兼容的 `useLocalStorage` hook,支持自动同步到 localStorage。
```tsx
// 使用示例
const [theme, setTheme] = useLocalStorage('theme', 'light');
// 等同于 useState,但值会持久化到 localStorage
```
**要求**:
1. 支持懒初始化(与 useState 相同)
2. 支持函数式更新
3. 当 key 变化时,从对应 key 读取值
4. 处理 localStorage 不可用的情况
> [!tip]- Q14 参考实现
>
> ```tsx
> function useLocalStorage<T>(
> key: string,
> initialValue: T | (() => T)
> ): [T, React.Dispatch<React.SetStateAction<T>>] {
> // 懒初始化:从 localStorage 读取
> const [storedValue, setStoredValue] = useState<T>(() => {
> try {
> const item = window.localStorage.getItem(key);
> return item ? JSON.parse(item) :
> initialValue instanceof Function ? initialValue() : initialValue;
> } catch {
> return initialValue instanceof Function ? initialValue() : initialValue;
> }
> });
>
> // 当 key 变化时,重新从 localStorage 读取
> useEffect(() => {
> try {
> const item = window.localStorage.getItem(key);
> if (item) setStoredValue(JSON.parse(item));
> } catch (error) {
> console.error(`Error reading localStorage key "${key}":`, error);
> }
> }, [key]);
>
> // 包装 setter,同时更新 localStorage
> const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback((value) => {
> setStoredValue(prev => {
> const nextValue = value instanceof Function ? value(prev) : value;
> try {
> window.localStorage.setItem(key, JSON.stringify(nextValue));
> } catch (error) {
> console.error(`Error setting localStorage key "${key}":`, error);
> }
> return nextValue;
> });
> }, [key]);
>
> return [storedValue, setValue];
> }
> ```
>
> **考察要点:**
> - useState 懒初始化模式
> - useCallback 优化函数引用稳定性
> - useEffect 依赖 key 变化重新同步
> - localStorage 错误处理(容量超限、隐私模式等)
>
> 💡 **扩展:** 可以添加跨标签页同步功能,监听 `storage` 事件。
---
## 三、场景分析(每题 10 分,共 20 分)
> 分析问题原因并给出解决方案。
### Q15.【避免无关组件重新渲染】
当购物车组件频繁更新时,如何避免无关组件重新渲染?列出至少两种优化方案。
> [!tip]- Q15 参考答案
>
> **方案 1:拆分 Context**
>
> ```tsx
> // 分离频繁变化的数据和不常变化的函数
> const CartItemsContext = createContext<CartItem[]>([]);
> const CartActionsContext = createContext<{ addItem: Function }>({ addItem: () => {} });
>
> // 使用时只订阅需要的部分
> function CartBadge() {
> const items = useContext(CartItemsContext); // 只在 items 变化时渲染
> return <Badge count={items.length} />;
> }
> ```
>
> **方案 2:使用 useReducer + useMemo**
>
> ```tsx
> function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
> switch (action.type) {
> case 'ADD': /* ... */
> }
> }
>
> function CartProvider({ children }) {
> const [items, dispatch] = useReducer(cartReducer, []);
>
> const actions = useMemo(() => ({
> addItem: (product) => dispatch({ type: 'ADD', payload: product }),
> // ...
> }), []); // dispatch 稳定,actions 不会变
>
> return <CartContext.Provider value={{ items, actions }}>{children}</CartContext.Provider>;
> }
> ```
>
> **方案 3:使用 memo 和 useMemo**
>
> ```tsx
> const CartItem = React.memo(({ item }: { item: CartItem }) => {
> // 只在 item 变化时重新渲染
> return <div>{item.name}</div>;
> });
> ```
>
> 💡 **核心原则:** 最小化渲染范围,只让真正需要更新的组件重新渲染。
### Q16.【路由状态持久化】
**问题**:用户在 A 页面填写了表单但未提交,跳转到 B 页面后再返回,表单数据丢失。请分析原因并给出解决方案。
```tsx
// A 页面
function PageA() {
const [formData, setFormData] = useState({ name: '', email: '' });
return <form>...</form>;
}
// 路由配置
<Route path="/a" element={<PageA />} />
<Route path="/b" element={<PageB />} />
```
> [!tip]- Q16 参考答案
>
> **原因分析:**
> - 当路由从 `/a` 切换到 `/b` 时,`PageA` 组件卸载
> - `useState` 的状态是组件实例的一部分,卸载后状态丢失
> - 返回 `/a` 时重新挂载 `PageA`,状态重置为初始值
>
> **解决方案 1:状态提升到父组件**
>
> ```tsx
> function FormContainer() {
> const [formData, setFormData] = useState({ name: '', email: '' });
> return (
> <Routes>
> <Route path="/a" element={<PageA data={formData} onChange={setFormData} />} />
> <Route path="/b" element={<PageB />} />
> </Routes>
> );
> }
> ```
>
> **解决方案 2:URL 搜索参数持久化**
>
> ```tsx
> function PageA() {
> const [searchParams, setSearchParams] = useSearchParams();
> const name = searchParams.get('name') || '';
>
> const updateName = (value: string) => {
> setSearchParams(prev => {
> prev.set('name', value);
> return prev;
> });
> };
> }
> ```
>
> **解决方案 3:使用 sessionStorage**
>
> ```tsx
> function usePersistedState<T>(key: string, initialValue: T) {
> const [state, setState] = useState<T>(() => {
> const saved = sessionStorage.getItem(key);
> return saved ? JSON.parse(saved) : initialValue;
> });
>
> useEffect(() => {
> sessionStorage.setItem(key, JSON.stringify(state));
> }, [key, state]);
>
> return [state, setState];
> }
> ```
>
> **方案对比:**
>
> | 方案 | 优点 | 缺点 | 适用场景 |
> |------|------|------|---------|
> | 状态提升 | 简单直接 | 父组件会重新渲染 | 少量表单 |
> | URL 参数 | 可分享、可书签 | 有长度限制 | 简单表单 |
> | sessionStorage | 自动同步 | 需要手动清理 | 复杂表单 |
>
> 💡 **推荐:** 简单表单用 URL 参数,复杂表单用 sessionStorage 或全局状态管理。
---
## 四、综合应用(每题 10 分,共 10 分)
> 综合运用所学知识解决复杂问题。
### Q17.【完整的路由认证系统】
设计一个完整的路由认证系统,包括:
1. 登录页面
2. 受保护的路由
3. 角色权限控制(admin/user)
4. 自动登过期处理
**要求**:画出架构图并写出核心代码。
> [!tip]- Q17 参考答案
>
> **架构图:**
>
> ```mermaid
> graph TD
> A["用户访问"] --> B{"是否已登录?"}
> B -->|否| C["重定向到 /login"]
> B -->|是| D{"检查 Token 过期"}
> D -->|过期| E["自动刷新 Token"]
> D -->|有效| F{"检查角色权限"}
> F -->|权限不足| G["重定向到 /403"]
> F -->|权限足够| H["渲染页面"]
> E -->|刷新成功| D
> E -->|刷新失败| C
> C --> I["登录页面"]
> I -->|登录成功| J["保存 Token"]
> J --> H
> ```
>
> **核心代码:**
>
> ```tsx
> // AuthContext.tsx
> interface AuthState {
> user: User | null;
> token: string | null;
> isAuthenticated: boolean;
> login: (credentials: LoginCredentials) => Promise<void>;
> logout: () => void;
> }
>
> const AuthContext = createContext<AuthState | null>(null);
>
> export function AuthProvider({ children }: { children: React.ReactNode }) {
> const [user, setUser] = useState<User | null>(null);
> const [token, setToken] = useState<string | null>(null);
>
> // Token 过期检查
> useEffect(() => {
> if (!token) return;
> const expiration = decodeToken(token).exp * 1000;
> const timeout = expiration - Date.now() - 60000; // 提前 1 分钟刷新
>
> const timer = setTimeout(async () => {
> try {
> const newToken = await refreshToken(token);
> setToken(newToken);
> } catch {
> logout();
> }
> }, timeout);
>
> return () => clearTimeout(timer);
> }, [token]);
>
> const value = {
> user,
> token,
> isAuthenticated: !!token,
> login: async (credentials) => {
> const { user, token } = await authService.login(credentials);
> setUser(user);
> setToken(token);
> },
> logout: () => {
> setUser(null);
> setToken(null);
> },
> };
>
> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
> }
>
> // ProtectedRoute.tsx
> function ProtectedRoute({
> children,
> requiredRole,
> }: {
> children: React.ReactNode;
> requiredRole?: 'admin' | 'user';
> }) {
> const { isAuthenticated, user } = useAuth();
> const location = useLocation();
>
> if (!isAuthenticated) {
> return <Navigate to="/login" state={{ from: location }} replace />;
> }
>
> if (requiredRole && user?.role !== requiredRole) {
> return <Navigate to="/403" replace />;
> }
>
> return <>{children}</>;
> }
>
> // 路由配置
> <Routes>
> <Route path="/login" element={<LoginPage />} />
> <Route
> path="/admin"
> element={
> <ProtectedRoute requiredRole="admin">
> <AdminDashboard />
> </ProtectedRoute>
> }
> />
> <Route
> path="/user"
> element={
> <ProtectedRoute>
> <UserDashboard />
> </ProtectedRoute>
> }
> />
> </Routes>
> ```
>
> **考察要点:**
> - Context 状态管理
> - Token 过期处理
> - 角色权限控制
> - 路由守卫实现
> - 登录后跳转
>
> 💡 **安全建议:**
> - Token 存储在 httpOnly cookie 中比 localStorage 更安全
> - 定期刷新 Token,避免长时间使用同一个 Token
> - 服务端也要验证权限,不能只依赖前端
---
## 评分参考
| 题目类型 | 题目 | 满分 | 权重 |
|---------|------|------|------|
| 选择题 | Q1-Q10 | 45 分 | 45% |
| 编程题 | Q11-Q14 | 40 分 | 40% |
| 场景分析 | Q15-Q16 | 20 分 | 20% |
| 综合应用 | Q17 | 10 分 | 10% |
| **总计** | | **100 分** | **100%** |
**及格线:** ≥70 分
**优秀线:** ≥85 分