从三个自定义 Hook 看 React 状态管理的设计思想

0 阅读7分钟

最近在准备技术面试,整理了几个常用的自定义 Hooks:useDebounceusePrevioususeRequest。乍一看它们都很简单,几十行代码就能实现。但仔细思考后发现,这些看似简单的封装背后,其实蕴含着 React 设计哲学、性能优化和状态管理的核心思想。

这篇文章是我的学习笔记,尝试从这三个 Hook 出发,探索它们解决的问题、设计思路,以及在什么场景下引入它们会让代码更优雅。

问题的起源:为什么需要自定义 Hooks?

在 React 16.8 引入 Hooks 之前,组件逻辑复用主要依赖高阶组件(HOC)和 Render Props。但这两种模式都有个问题:嵌套层级过深,代码难以理解。Hooks 的出现让我们可以用函数的方式提取和复用组件逻辑。

自定义 Hook 的核心价值在于:封装带副作用的逻辑,让组件代码更声明式。比如防抖、请求状态管理这些"脏活累活",可以抽离到自定义 Hook 中,让组件只关注 "我要什么数据" 而不是 "我怎么拿到数据"。

核心概念探索

useDebounce: 控制变化频率的艺术

useDebounce 的使用场景很常见:搜索框输入、窗口 resize、滚动监听等高频触发的事件。如果每次输入都发起请求,会造成不必要的性能开销和服务器压力。

// 环境:React 18+
// 场景:搜索框实时搜索

import { useState } from 'react';
import { useDebounce } from './useDebounce';

function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const debouncedKeyword = useDebounce(keyword, 500);

  // Only trigger search when user stops typing for 500ms
  useEffect(() => {
    if (debouncedKeyword) {
      fetchSearchResults(debouncedKeyword);
    }
  }, [debouncedKeyword]);

  return (
    <input 
      value={keyword}
      onChange={(e) => setKeyword(e.target.value)}
      placeholder="Search..."
    />
  );
}

实现原理:

useDebounce 的核心是利用 useEffect 的清理函数。每当 value 变化时,先清除之前的定时器,再设置新的定时器。只有当 valuedelay 时间内不再变化,定时器才会执行,更新 debouncedValue

// 环境:React 18+
// 关键点:useEffect 的清理函数

useEffect(() => {
  const timer = setTimeout(() => {
    setDebouncedValue(value); // Only update after delay
  }, delay);

  return () => clearTimeout(timer); // Cleanup on every render
}, [value, delay]);

这里有个值得思考的细节:为什么清理函数在每次渲染时都会执行?

我的理解是,React 的 Effect 清理机制保证了 "副作用的及时撤销"。每次 value 变化时,旧的定时器被清除,新的定时器才会生效。这种模式避免了多个定时器同时存在导致的竞态问题。

权衡点:

  • delay 太短: 防抖效果不明显
  • delay 太长: 用户体验差,响应迟钝
  • 常见取值: 搜索框 300-500ms,滚动事件 100-200ms

usePrevious: 时间旅行的小技巧

usePrevious 解决的是一个看似简单但实际很有用的问题: 如何在组件中获取上一次渲染的值?

// 环境:React 18+
// 场景:比较前后两次 props 的变化

function UserProfile({ userId }) {
  const prevUserId = usePrevious(userId);

  useEffect(() => {
    if (userId !== prevUserId) {
      console.log(`User changed from ${prevUserId} to ${userId}`);
      fetchUserData(userId);
    }
  }, [userId, prevUserId]);

  return <div>User ID: {userId}</div>;
}

实现原理:

usePrevious 利用了 useRef 的两个特性:

  1. 引用的持久性 :ref.current 在组件的整个生命周期中都保持同一个引用
  2. 修改不触发重渲染: 改变 ref.current 不会导致组件重新渲染
// 环境:React 18+
// 关键点:useRef 的持久性

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value; // Update ref AFTER render
  });

  return ref.current; // Return old value during render
}

这里的执行顺序很关键:

  1. 组件渲染时,usePrevious 返回 ref.current(上一次的值)
  2. 渲染完成后,useEffect 执行,将 ref.current 更新为当前值
  3. 下次渲染时,ref.current 已经是"上一次"的值了

一个有趣的思考:为什么 useEffect 不需要依赖数组?

因为我们希望每次渲染后都更新 ref.current。如果加了依赖数组,反而会导致某些情况下 ref.current 不更新,失去"追踪前一个值"的意义。

useRequest:异步状态管理的简化方案

useRequest 封装了数据请求的三个核心状态: dataloadingerror。这是几乎所有数据请求场景都需要的状态管理逻辑。

// 环境:React 18+
// 场景:组件挂载时自动请求数据

function UserList() {
  const { data, loading, error, run } = useRequest(
    () => fetch('/api/users').then(res => res.json())
  );

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data?.map(user => <div key={user.id}>{user.name}</div>)}
      <button onClick={run}>Refresh</button>
    </div>
  );
}

实现原理:

useRequest 的核心是状态机思想: 请求可能处于三种互斥状态之一(加载中、成功、失败),通过 useState 管理这些状态的切换。

// 环境:React 18+
// 关键点:异步状态管理 + useCallback 优化

const run = useCallback(() => {
  setLoading(true);
  setError(null);
  
  return fetcher()
    .then((res) => {
      setData(res);
      setError(null);
    })
    .catch((err) => {
      setError(err);
      setData(undefined);
    })
    .finally(() => {
      setLoading(false);
    });
}, [fetcher]);

这里有几个值得注意的细节:

  1. useCallback 包裹 run 函数: 避免每次渲染都生成新的函数引用,减少子组件不必要的重渲染
  2. finally 中设置 loading: false: 无论成功还是失败,都要结束加载状态
  3. 成功时清除 error,失败时清除 data: 保证状态的一致性

实际场景思考

场景 1: 搜索框 + 请求防抖

这是 useDebounceuseRequest 的经典组合场景:

// 环境:React 18+ / 浏览器
// 场景:实时搜索,防抖优化
// 依赖:无(原生 Fetch API)

function SearchPage() {
  const [keyword, setKeyword] = useState('');
  const debouncedKeyword = useDebounce(keyword, 300);

  const { data: results, loading } = useRequest(
    () => fetch(`/api/search?q=${debouncedKeyword}`).then(r => r.json()),
    { manual: false }
  );

  // Re-fetch when debounced keyword changes
  useEffect(() => {
    if (debouncedKeyword) {
      // Trigger search
    }
  }, [debouncedKeyword]);

  return (
    <div>
      <input 
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />
      {loading ? <Spinner /> : <ResultList data={results} />}
    </div>
  );
}

权衡点:

  • 防抖延迟太短,请求依然频繁
  • 延迟太长,用户感觉响应迟钝
  • 建议根据实际搜索耗时调整,一般 300-500ms

场景 2: 依赖变化时的副作用清理

usePrevious 在处理 "依赖变化时的清理逻辑" 时很有用:

// 环境:React 18+ / 浏览器
// 场景:WebSocket 连接管理,userId 变化时重连
// 依赖:WebSocket API

function ChatRoom({ roomId }) {
  const prevRoomId = usePrevious(roomId);
  const [ws, setWs] = useState(null);

  useEffect(() => {
    // If roomId changed, disconnect old room
    if (prevRoomId && prevRoomId !== roomId && ws) {
      console.log(`Leaving room ${prevRoomId}`);
      ws.close();
    }

    // Connect to new room
    const socket = new WebSocket(`ws://chat.com/${roomId}`);
    setWs(socket);

    return () => socket.close();
  }, [roomId, prevRoomId]);

  return <div>Connected to room: {roomId}</div>;
}

这个模式在 "需要根据前后值的差异执行不同逻辑" 时特别有用。

场景 3: 请求竞态问题

一个容易被忽视的问题: 如果用户快速切换页面,多个请求同时进行,后发起的请求可能先返回,导致数据错乱。

// 环境:React 18+
// 场景:页面切换时的请求竞态
// 问题:后发起的请求可能先返回

function UserDetail({ userId }) {
  const { data, loading } = useRequest(
    () => fetch(`/api/users/${userId}`).then(r => r.json())
  );

  // Problem: If userId changes quickly:
  // Request A (userId=1) -> Request B (userId=2)
  // But B returns first, then A returns
  // Final data might be userId=1 (wrong!)
}

一种可能的解决方案:

// 环境:React 18+
// 场景:使用 AbortController 取消过期请求

function useRequest(fetcher, options = {}) {
  // ... existing code

  const run = useCallback(() => {
    const abortController = new AbortController();
    
    setLoading(true);
    setError(null);
    
    return fetcher({ signal: abortController.signal })
      .then(setData)
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      })
      .finally(() => setLoading(false));
      
    // Cleanup: abort on next call or unmount
    return () => abortController.abort();
  }, [fetcher]);

  // ...
}

但这个方案需要 fetcher 支持 signal 参数,对使用者有一定要求。另一种思路是用"请求 ID"标记最新请求,忽略过期响应。

延伸与发散

在研究这些 Hook 时,我产生了一些新的疑问:

1. useDebounce vs useThrottle

防抖(debounce)是"停止触发后才执行",节流(throttle)是"固定时间间隔执行一次"。什么场景下应该用节流而不是防抖?

我的理解是:

  • 防抖适合"最终状态"场景: 搜索框、表单验证、窗口 resize
  • 节流适合"过程状态"场景: 滚动加载、鼠标移动跟踪、拖拽

2. useRequest 的更多能力

现有的 useRequest 还比较简陋,实际项目中可能需要:

  • 缓存: 相同参数的请求直接返回缓存
  • 重试: 请求失败后自动重试
  • 轮询: 定时刷新数据
  • 依赖刷新: 某个依赖变化时自动重新请求

这些能力可以通过 ahooksuseRequestreact-query 等库实现,它们的源码值得深入学习。

3. usePrevious 的局限性

usePrevious 只能追踪一次前一个值。如果需要追踪"前两次"、"前三次"的值呢?

一种可能的思路是:

// 环境:React 18+
// 场景:追踪历史值

function useHistory(value, maxLength = 10) {
  const historyRef = useRef([]);

  useEffect(() => {
    historyRef.current = [value, ...historyRef.current].slice(0, maxLength);
  });

  return historyRef.current;
}

但这会带来额外的内存开销,需要权衡。

4. Hooks 的依赖管理

这三个 Hook 都涉及 useEffect 的依赖数组。我在实践中经常遇到的困惑:

  • 什么时候应该加依赖?
  • 什么时候可以省略依赖?
  • 如何避免无限循环?

React 官方的 ESLint 插件(eslint-plugin-react-hooks)会检查依赖,但有时它的建议并不完全符合实际需求。这可能需要更深入理解 React 的渲染机制。

设计 Hook 的思维模式

通过这三个 Hook,我总结了一些设计自定义 Hook 的思路:

1. 单一职责

每个 Hook 只做一件事:

  • useDebounce: 控制变化频率
  • usePrevious: 获取前一次的值
  • useRequest: 管理请求状态

避免把多个功能塞进一个 Hook,保持可组合性。

2. 声明式 API

Hook 的使用者应该关注"要什么",而不是"怎么做":

// Good: Declarative
const debouncedValue = useDebounce(value, 500);

// Bad: Imperative
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
  const timer = setTimeout(() => setDebouncedValue(value), 500);
  return () => clearTimeout(timer);
}, [value]);

小结

这篇文章从三个简单的自定义 Hook 出发,探索了 React 中的副作用管理、状态同步和性能优化。useDebounce 教会我们如何优雅地清理副作用,usePrevious 展示了 useRef 的妙用,useRequest 则是异步状态管理的入门范例。

但这些都只是起点。实际项目中,我们可能需要更强大的状态管理方案(如 react-queryswr),或者需要考虑更复杂的场景(并发请求、错误重试、缓存策略等)。

这篇文章更多是我个人的学习笔记,而非标准答案。如果你有不同的理解或补充,欢迎交流。

参考资料