现在位置: 首页 > React 教程 > 正文

自定义 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
  }
}

使用 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

使用 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

注意:如果用户直接访问详情页 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 }
}

在 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 ? '&#x2600; 亮色' : '☾ 暗黑'}
        </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);
}

React 自定义 Hook vs Vue3 Composable

特性React 自定义 HookVue3 Composable
命名规范use 开头use 开头
本质用了 React Hook 的函数用了 Vue3 API 的函数
响应式通过 useState 返回的 setter 触发通过 ref/reactive 自动追踪
调用限制只能在函数组件顶层只能在 <script setup> 顶层
副作用useEffectwatch / watchEffect / onMounted

本章小结

本章你掌握了 React 最重要的设计模式——自定义 Hook:以 use 开头、内部组合多个 React Hook、返回状态和操作。

usePosts 统一管理数据加载 + 筛选 + 搜索,useDarkMode 实现一行代码接入暗黑模式。组件从此只关心 UI 渲染。