中级练习:Hooks 与组件设计
完成以下练习题,可以帮助你掌握自定义 Hooks、组件设计模式、性能优化等中级技能。
练习 1:自定义 Hook - useLocalStorage
目标:创建一个自定义 Hook,将状态自动同步到 localStorage。
要求:
- 创建
useLocalStorageHook,接收 key 和初始值 - 状态变化时自动保存到 localStorage
- 组件挂载时从 localStorage 读取初始值
- 处理 JSON 序列化和反序列化
function useLocalStorage<T>(key: string, initialValue: T) {
// 从 localStorage 读取初始值
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading from localStorage:', error);
return initialValue;
}
});
// 更新 localStorage 的包装函数
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error writing to localStorage:', error);
}
};
return [storedValue, setValue] as const;
}
// 使用示例
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<div>
<p>当前主题: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</div>
);
}
练习 2:自定义 Hook - useDebounce
目标:实现防抖 Hook,优化搜索输入性能。
要求:
- 创建
useDebounceHook,延迟更新值 - 用户停止输入指定时间后才更新
- 配合搜索功能减少 API 请求
function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// 设置定时器延迟更新
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 清理函数:如果 value 在 delay 时间内再次变化,取消上一次的定时器
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// 使用示例:搜索组件
function SearchWithDebounce() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 800);
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (debouncedSearchTerm) {
// 这里只有在用户停止输入 800ms 后才会触发
console.log('执行搜索:', debouncedSearchTerm);
// 模拟 API 调用
fetch(`/api/search?q=${debouncedSearchTerm}`)
.then(res => res.json())
.then(data => setResults(data));
}
}, [debouncedSearchTerm]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="输入搜索关键词..."
/>
<p>实时输入: {searchTerm}</p>
<p>防抖后的值: {debouncedSearchTerm}</p>
<ul>
{results.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
);
}
练习 3:React.memo 性能优化
目标:理解 React.memo 的使用场景和浅比较机制。
要求:
- 创建父子组件,观察重渲染行为
- 使用 React.memo 优化子组件
- 配合 useCallback 稳定函数引用
import React, { useState, useCallback, memo } from 'react';
// 未优化的子组件
function ExpensiveChild({ name, onClick }: { name: string; onClick: () => void }) {
console.log('ExpensiveChild 渲染了:', name);
// 模拟耗时计算
const start = Date.now();
while (Date.now() - start < 100) {} // 阻塞 100ms
return (
<div>
<h3>{name}</h3>
<button onClick={onClick}>点击</button>
</div>
);
}
// 使用 React.memo 优化
const MemoizedChild = memo(ExpensiveChild);
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('张三');
// ❌ 不使用 useCallback:每次渲染都会创建新函数引用
const handleClickBad = () => {
console.log('Clicked');
};
// ✅ 使用 useCallback:函数引用稳定
const handleClickGood = useCallback(() => {
console.log('Clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
触发父组件重渲染 (count: {count})
</button>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="修改名字"
/>
<hr />
<h2>对比测试:</h2>
{/* 场景 1:未优化 - 父组件每次渲染,子组件都会重渲染 */}
<ExpensiveChild name={name} onClick={handleClickBad} />
{/* 场景 2:Memo + 不稳定函数 - 依然会重渲染(onClick 引用变化) */}
<MemoizedChild name={name} onClick={handleClickBad} />
{/* 场景 3:Memo + useCallback - 只有 name 变化时才重渲染 */}
<MemoizedChild name={name} onClick={handleClickGood} />
</div>
);
}
练习 4:复合组件模式 (Compound Components)
目标:实现一个 Tabs 组件,使用复合组件模式。
要求:
- 创建
Tabs、TabList、Tab、TabPanels、TabPanel组件 - 使用 Context 共享激活状态
- 支持灵活的布局组合
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface TabsContextValue {
activeTab: number;
setActiveTab: (index: number) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tab components must be used within a Tabs component');
}
return context;
}
// 主容器组件
export function Tabs({
children,
defaultTab = 0
}: {
children: ReactNode;
defaultTab?: number;
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 标签列表容器
Tabs.TabList = function TabList({ children }: { children: ReactNode }) {
return <div className="tab-list">{children}</div>;
};
// 单个标签
Tabs.Tab = function Tab({
index,
children
}: {
index: number;
children: ReactNode;
}) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === index;
return (
<button
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
};
// 面板容器
Tabs.TabPanels = function TabPanels({ children }: { children: ReactNode }) {
return <div className="tab-panels">{children}</div>;
};
// 单个面板
Tabs.TabPanel = function TabPanel({
index,
children
}: {
index: number;
children: ReactNode;
}) {
const { activeTab } = useTabs();
if (activeTab !== index) return null;
return <div className="tab-panel">{children}</div>;
};
// 使用示例
function App() {
return (
<Tabs defaultTab={0}>
<Tabs.TabList>
<Tabs.Tab index={0}>个人信息</Tabs.Tab>
<Tabs.Tab index={1}>账号设置</Tabs.Tab>
<Tabs.Tab index={2}>通知管理</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel index={0}>
<h2>个人信息</h2>
<p>姓名、邮箱、头像等信息</p>
</Tabs.TabPanel>
<Tabs.TabPanel index={1}>
<h2>账号设置</h2>
<p>修改密码、绑定手机号</p>
</Tabs.TabPanel>
<Tabs.TabPanel index={2}>
<h2>通知管理</h2>
<p>邮件通知、推送设置</p>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
);
}
练习 5:自定义 Hook - useFetch
目标:封装数据请求逻辑,处理加载、错误、重试状态。
要求:
- 创建
useFetchHook - 自动管理 loading、error、data 状态
- 支持重新请求功能
- 组件卸载时取消请求
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
let isMounted = true;
fetchData().then(() => {
// 确保组件仍然挂载
if (!isMounted) {
console.log('Component unmounted, ignoring fetch result');
}
});
// 清理函数
return () => {
isMounted = false;
};
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// 使用示例
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, loading, error, refetch } = useFetch<User>(
`/api/users/${userId}`
);
if (loading) return <div>加载中...</div>;
if (error) return (
<div>
<p>错误: {error.message}</p>
<button onClick={refetch}>重试</button>
</div>
);
if (!data) return <div>没有数据</div>;
return (
<div>
<h2>{data.name}</h2>
<p>邮箱: {data.email}</p>
<button onClick={refetch}>刷新</button>
</div>
);
}
练习 6:Portal 与模态框
目标:使用 React Portal 实现模态框组件。
要求:
- 使用
createPortal将模态框渲染到 body - 支持打开/关闭动画
- 点击遮罩层关闭模态框
- 按 ESC 键关闭
import { createPortal } from 'react-dom';
import { useEffect, ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
}
function Modal({ isOpen, onClose, children, title }: ModalProps) {
// 监听 ESC 键
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
// 防止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()} // 阻止点击内容时关闭
style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
maxWidth: '500px',
maxHeight: '80vh',
overflow: 'auto',
}}
>
{title && (
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose}>✕</button>
</div>
)}
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
// 使用示例
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<button onClick={() => setIsModalOpen(true)}>打开模态框</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="用户信息"
>
<p>这是模态框内容</p>
<p>点击遮罩层或按 ESC 键关闭</p>
<button onClick={() => setIsModalOpen(false)}>确定</button>
</Modal>
</div>
);
}
挑战练习:无限滚动列表
目标:实现无限滚动加载功能,使用 Intersection Observer API。
要求:
- 当滚动到底部时自动加载更多数据
- 显示加载状态
- 处理加载失败和无更多数据的情况
- 使用自定义 Hook 封装逻辑
import { useState, useEffect, useRef, useCallback } from 'react';
interface UseInfiniteScrollOptions {
initialPage?: number;
threshold?: number;
}
function useInfiniteScroll<T>(
fetchFunction: (page: number) => Promise<T[]>,
options: UseInfiniteScrollOptions = {}
) {
const { initialPage = 1, threshold = 0.8 } = options;
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<Error | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const newItems = await fetchFunction(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load'));
} finally {
setLoading(false);
}
}, [page, loading, hasMore, fetchFunction]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
if (first.isIntersecting) {
loadMore();
}
},
{ threshold }
);
observerRef.current = observer;
const currentRef = loadMoreRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [loadMore, threshold]);
return { items, loading, hasMore, error, loadMoreRef };
}
// 使用示例
interface Post {
id: number;
title: string;
content: string;
}
// 模拟 API 请求
async function fetchPosts(page: number): Promise<Post[]> {
await new Promise(resolve => setTimeout(resolve, 1000));
// 模拟 10 页数据
if (page > 10) return [];
return Array.from({ length: 20 }, (_, i) => ({
id: (page - 1) * 20 + i + 1,
title: `Post ${(page - 1) * 20 + i + 1}`,
content: `This is the content of post ${(page - 1) * 20 + i + 1}`,
}));
}
function InfiniteScrollList() {
const { items, loading, hasMore, error, loadMoreRef } = useInfiniteScroll<Post>(
fetchPosts,
{ threshold: 0.9 }
);
return (
<div>
<h1>无限滚动列表</h1>
<div className="posts">
{items.map(post => (
<div key={post.id} className="post">
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
</div>
{/* 监听元素 */}
<div ref={loadMoreRef} style={{ height: '20px' }} />
{loading && <div className="loading">加载中...</div>}
{error && <div className="error">加载失败: {error.message}</div>}
{!hasMore && <div className="no-more">没有更多数据了</div>}
</div>
);
}
学习建议
- 理解原理:每个练习都涉及重要的设计模式或性能优化技巧
- 扩展功能:尝试为这些组件添加更多功能(如 useFetch 支持 POST 请求)
- TypeScript:强化类型定义,提升代码质量
- 测试驱动:尝试为这些自定义 Hook 编写单元测试
- 性能分析:使用 React DevTools Profiler 观察优化效果
完成这些练习后,你将具备中级 React 开发能力,可以开始学习高级主题和架构设计!