自定义 Hook — 封装可复用逻辑
本章你将学会 React 最佳实践——用自定义 Hook 封装可复用的逻辑,让组件更简洁、更易维护。
什么是自定义 Hook?
随着功能增多,useState 和 useEffect 散落在组件中,不同组件间有重复逻辑。
自定义 Hook 就是把多个 Hook 的组合逻辑提取到一个以 use 开头的函数中。
它的本质:一个内部使用了 React Hook 的普通 JS 函数。
为什么需要自定义 Hook?
| 没有自定义 Hook | 有自定义 Hook |
|---|---|
| 每个组件自己写 fetch + 过滤逻辑 | 一个 usePosts() 到处复用 |
| 数据逻辑和 UI 逻辑混在一起 | 数据逻辑独立,组件只关心 UI |
| 改一个逻辑要改多个文件 | 改一处,所有组件同步更新 |
| 组件动辄 200+ 行 | 组件通常 < 80 行 |
命名规范与 Hook 规则
| 规范 | 说明 | 示例 |
|---|---|---|
以 use 开头 | React 借此识别 Hook,应用 lint 规则 | usePosts、useDarkMode |
| 放在 hooks/ 目录 | 方便查找和维护 | src/hooks/usePosts.js |
| 只能在函数组件顶层调用 | 不能在条件/循环/嵌套函数中调用 | — |
| 返回数据或方法 | 让调用方获取 Hook 内部的状态和逻辑 | return { data, isLoading } |
Hook 的使用规则:1. 只在函数组件顶层调用 Hook;2. 只在 React 函数中调用 Hook(函数组件或自定义 Hook)。这确保了每次渲染时 Hook 的调用顺序一致。
usePosts — 封装文章数据逻辑
实例
// 文件路径:src/hooks/usePosts.js
import { useState, useEffect, useMemo } from 'react'
export function usePosts() {
const [articles, setArticles] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
const [activeCategory, setActiveCategory] = useState('全部')
const [keyword, setKeyword] = useState('')
// 加载数据
useEffect(() => {
let cancelled = false
async function fetchPosts() {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/posts.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!cancelled) setArticles(data)
} catch (err) {
if (!cancelled) setError(err.message)
} finally {
if (!cancelled) setIsLoading(false)
}
}
fetchPosts()
return () => { cancelled = true }
}, [])
// 提取所有分类
const categories = useMemo(() => {
return ['全部', ...new Set(articles.map(a => a.category))]
}, [articles])
// 按分类 + 关键词过滤
const filteredArticles = useMemo(() => {
let result = articles
if (activeCategory !== '全部') {
result = result.filter(a => a.category === activeCategory)
}
if (keyword.trim()) {
const kw = keyword.trim().toLowerCase()
result = result.filter(a =>
a.title.toLowerCase().includes(kw) ||
a.summary.toLowerCase().includes(kw)
)
}
return result
}, [articles, activeCategory, keyword])
// 根据 ID 查找文章
function getArticleById(id) {
return articles.find(a => a.id === Number(id))
}
// 重新加载
function refetch() {
// 触发 useEffect 的另一种方式:用 key 或增强 state
window.location.reload()
}
return {
articles, isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
getArticleById,
refetch
}
}
import { useState, useEffect, useMemo } from 'react'
export function usePosts() {
const [articles, setArticles] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
const [activeCategory, setActiveCategory] = useState('全部')
const [keyword, setKeyword] = useState('')
// 加载数据
useEffect(() => {
let cancelled = false
async function fetchPosts() {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/posts.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!cancelled) setArticles(data)
} catch (err) {
if (!cancelled) setError(err.message)
} finally {
if (!cancelled) setIsLoading(false)
}
}
fetchPosts()
return () => { cancelled = true }
}, [])
// 提取所有分类
const categories = useMemo(() => {
return ['全部', ...new Set(articles.map(a => a.category))]
}, [articles])
// 按分类 + 关键词过滤
const filteredArticles = useMemo(() => {
let result = articles
if (activeCategory !== '全部') {
result = result.filter(a => a.category === activeCategory)
}
if (keyword.trim()) {
const kw = keyword.trim().toLowerCase()
result = result.filter(a =>
a.title.toLowerCase().includes(kw) ||
a.summary.toLowerCase().includes(kw)
)
}
return result
}, [articles, activeCategory, keyword])
// 根据 ID 查找文章
function getArticleById(id) {
return articles.find(a => a.id === Number(id))
}
// 重新加载
function refetch() {
// 触发 useEffect 的另一种方式:用 key 或增强 state
window.location.reload()
}
return {
articles, isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
getArticleById,
refetch
}
}
使用 usePosts 的首页
实例
// 文件路径:src/pages/HomePage.jsx
import { usePosts } from '../hooks/usePosts'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'
function HomePage() {
// 一行代码获取所有文章相关数据和操作
const {
isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
refetch
} = usePosts()
if (isLoading) return <p className="status-msg">加载中...</p>
if (error) return (
<div className="status-msg error">
<p>加载失败:{error}</p>
<button onClick={refetch}>重试</button>
</div>
)
return (
<div>
<h2 className="section-title">最新文章</h2>
<div className="search-bar">
<input
type="text"
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索文章标题或摘要..."
className="search-input"
/>
{keyword && <span className="clear-btn" onClick={() => setKeyword('')}>✕</span>}
</div>
<CategoryFilter
categories={categories}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
<p className="result-info">共 {filteredArticles.length} 篇</p>
{filteredArticles.length === 0 ? (
<p className="empty-tip">没有匹配的文章</p>
) : (
<div className="article-grid">
{filteredArticles.map(article => (
<BlogCard key={article.id} {...article} />
))}
</div>
)}
</div>
)
}
export default HomePage
import { usePosts } from '../hooks/usePosts'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'
function HomePage() {
// 一行代码获取所有文章相关数据和操作
const {
isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
refetch
} = usePosts()
if (isLoading) return <p className="status-msg">加载中...</p>
if (error) return (
<div className="status-msg error">
<p>加载失败:{error}</p>
<button onClick={refetch}>重试</button>
</div>
)
return (
<div>
<h2 className="section-title">最新文章</h2>
<div className="search-bar">
<input
type="text"
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索文章标题或摘要..."
className="search-input"
/>
{keyword && <span className="clear-btn" onClick={() => setKeyword('')}>✕</span>}
</div>
<CategoryFilter
categories={categories}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
<p className="result-info">共 {filteredArticles.length} 篇</p>
{filteredArticles.length === 0 ? (
<p className="empty-tip">没有匹配的文章</p>
) : (
<div className="article-grid">
{filteredArticles.map(article => (
<BlogCard key={article.id} {...article} />
))}
</div>
)}
</div>
)
}
export default HomePage
使用 usePosts 的详情页
实例
// 文件路径:src/pages/PostPage.jsx
import { useParams, Link } from 'react-router-dom'
import { usePosts } from '../hooks/usePosts'
function PostPage() {
const { id } = useParams()
const { isLoading, error, getArticleById } = usePosts()
const article = getArticleById(id)
if (isLoading) return <p className="status-msg">加载中...</p>
if (error) return <p className="status-msg error">加载失败:{error}</p>
if (!article) {
return (
<div className="not-found">
<h2>文章不存在</h2>
<Link to="/">返回首页</Link>
</div>
)
}
return (
<article className="post-view">
<span className="category-tag">{article.category}</span>
<h1>{article.title}</h1>
<time>{article.date}</time>
<div className="content" dangerouslySetInnerHTML={{ __html: article.content }} />
<Link to="/" className="back-link">← 返回首页</Link>
</article>
)
}
export default PostPage
import { useParams, Link } from 'react-router-dom'
import { usePosts } from '../hooks/usePosts'
function PostPage() {
const { id } = useParams()
const { isLoading, error, getArticleById } = usePosts()
const article = getArticleById(id)
if (isLoading) return <p className="status-msg">加载中...</p>
if (error) return <p className="status-msg error">加载失败:{error}</p>
if (!article) {
return (
<div className="not-found">
<h2>文章不存在</h2>
<Link to="/">返回首页</Link>
</div>
)
}
return (
<article className="post-view">
<span className="category-tag">{article.category}</span>
<h1>{article.title}</h1>
<time>{article.date}</time>
<div className="content" dangerouslySetInnerHTML={{ __html: article.content }} />
<Link to="/" className="back-link">← 返回首页</Link>
</article>
)
}
export default PostPage
注意:如果用户直接访问详情页 URL(如刷新页面),usePosts 会重新 fetch 数据。这是正确的行为——因为首页的 usePosts 实例和详情页的 usePosts 实例是独立的,各自调用自己的 useEffect。
useDarkMode — 一行代码切换暗黑模式
实例
// 文件路径:src/hooks/useDarkMode.js
import { useState, useEffect } from 'react'
export function useDarkMode() {
// 从 localStorage 读取之前的设置
const [isDark, setIsDark] = useState(() => {
return localStorage.getItem('blog-theme') === 'dark'
})
// 当 isDark 变化时,同步到 DOM 和 localStorage
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark')
localStorage.setItem('blog-theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('blog-theme', 'light')
}
}, [isDark])
function toggleDark() {
setIsDark(!isDark)
}
return { isDark, toggleDark }
}
import { useState, useEffect } from 'react'
export function useDarkMode() {
// 从 localStorage 读取之前的设置
const [isDark, setIsDark] = useState(() => {
return localStorage.getItem('blog-theme') === 'dark'
})
// 当 isDark 变化时,同步到 DOM 和 localStorage
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark')
localStorage.setItem('blog-theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('blog-theme', 'light')
}
}, [isDark])
function toggleDark() {
setIsDark(!isDark)
}
return { isDark, toggleDark }
}
在 NavBar 中使用:
实例
// 文件路径:src/components/NavBar.jsx
import { useDarkMode } from '../hooks/useDarkMode'
function NavBar() {
const { isDark, toggleDark } = useDarkMode()
return (
<header className="navbar">
<a href="/" className="logo">RUNOOB Blog</a>
<nav>
<a href="/">首页</a>
<button className="theme-btn" onClick={toggleDark}>
{isDark ? '☀ 亮色' : '☾ 暗黑'}
</button>
</nav>
</header>
)
}
export default NavBar
import { useDarkMode } from '../hooks/useDarkMode'
function NavBar() {
const { isDark, toggleDark } = useDarkMode()
return (
<header className="navbar">
<a href="/" className="logo">RUNOOB Blog</a>
<nav>
<a href="/">首页</a>
<button className="theme-btn" onClick={toggleDark}>
{isDark ? '☀ 亮色' : '☾ 暗黑'}
</button>
</nav>
</header>
)
}
export default NavBar
CSS 变量实现主题切换
实例
/* 文件路径:src/index.css(全局样式) */
/* 浅色模式(默认) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #eeeeee;
}
/* 暗黑模式 */
html.dark {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #2a2a4a;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card, .article-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
}
/* 浅色模式(默认) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #eeeeee;
}
/* 暗黑模式 */
html.dark {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #2a2a4a;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card, .article-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
}
React 自定义 Hook vs Vue3 Composable
| 特性 | React 自定义 Hook | Vue3 Composable |
|---|---|---|
| 命名规范 | use 开头 | use 开头 |
| 本质 | 用了 React Hook 的函数 | 用了 Vue3 API 的函数 |
| 响应式 | 通过 useState 返回的 setter 触发 | 通过 ref/reactive 自动追踪 |
| 调用限制 | 只能在函数组件顶层 | 只能在 <script setup> 顶层 |
| 副作用 | useEffect | watch / watchEffect / onMounted |
本章小结
本章你掌握了 React 最重要的设计模式——自定义 Hook:以 use 开头、内部组合多个 React Hook、返回状态和操作。
usePosts 统一管理数据加载 + 筛选 + 搜索,useDarkMode 实现一行代码接入暗黑模式。组件从此只关心 UI 渲染。
