跳到主要内容
预计阅读 38 分钟

Hooks核心 —— 告别class的现代React

上一章我们用 useState 让组件拥有了”记忆”。但一个真正的应用不可能只管理数据——它还要请求接口、监听事件、操控 DOM。这些”渲染之外”的事情,就是 Hooks 的用武之地。掌握 useEffect 和 useRef,你才算真正拿到了函数组件的驾照。

📋 开篇自测:你已经知道多少?

  1. 你能说清楚 useEffect 的依赖数组为空数组 [] 和完全不传有什么区别吗?
  2. 你知道 useRef 除了操作 DOM,还能用来做什么吗?
  3. 在一个定时器回调里读取 state,为什么拿到的总是旧值?你能说出至少两种解决方案吗?

一、为什么需要 Hooks?—— 从 class 组件的痛点说起

在 Hooks 出现之前(React 16.8 之前),想在组件里做任何”有副作用”的事——请求数据、订阅事件、操作 DOM——你必须写 class 组件,并把逻辑分散在 componentDidMountcomponentDidUpdatecomponentWillUnmount 等生命周期方法里。

这带来了三个让人头疼的问题:

痛点一:相关逻辑被拆散到不同生命周期里

比如你要监听窗口 resize 事件:在 componentDidMount 里添加监听,在 componentWillUnmount 里移除监听。如果还要根据某个 prop 变化重新注册,还得在 componentDidUpdate 里写一遍。一个功能的代码被撕成三块,读起来像在拼拼图。

痛点二:不相关的逻辑挤在同一个生命周期里

componentDidMount 里既要请求用户数据,又要注册定时器,还要初始化第三方库——它变成了一个”杂物间”。

痛点三:this 的指向让人崩溃

this.handleClick = this.handleClick.bind(this) 这种代码你大概不想再写第二遍了。

Hooks 的解决思路非常优雅:把逻辑按”关注点”组织,而不是按”生命周期阶段”组织。一个功能相关的代码全部写在一个 useEffect 里,添加和清理放在一起,一目了然。

打一个比方:class 组件的生命周期就像按”早中晚”来安排一天——早上做早餐、健身、送孩子上学全混在一起;而 Hooks 则是按”事务”来安排——“健身”这件事从热身到拉伸全写在一个 block 里,清晰得多。

现在,React 官方已经把 class 组件归入了 “Legacy”(遗产)目录。这不是说 class 不能用,而是明确告诉你:未来是 Hooks 的。


二、useEffect —— 副作用管理

2.1 什么是”副作用”

函数组件本质上是一个”纯函数”:传入 props 和 state,返回 JSX。但现实中,我们总需要做一些”渲染之外”的事情:

  • 向服务器请求数据
  • 手动修改 DOM
  • 设置定时器
  • 订阅事件(WebSocket、窗口 resize 等)

这些在函数执行过程中”偷偷”干的事情,就叫做副作用(side effect)。useEffect 就是 React 提供的”副作用管理器”。

你可以把它理解成一支善后小分队——组件渲染完成后,useEffect 里的函数才会执行,专门负责那些”渲染之外”的脏活累活。

2.2 基本用法:组件挂载时请求数据

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 定义异步函数
    async function fetchUser() {
      setLoading(true);
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
      setLoading(false);
    }

    fetchUser();
  }, [userId]); // 当 userId 变化时重新请求

  if (loading) return <p>加载中...</p>;
  return <h1>你好,{user.name}</h1>;
}

注意这里有一个细节:useEffect 的回调函数不能直接用 async。因为 useEffect 期望回调要么不返回东西,要么返回一个清理函数。而 async 函数总是返回一个 Promise,React 不知道怎么处理。所以我们在内部定义一个 async 函数再调用它。

🤔 想一想 如果把 fetch 请求直接写在函数组件的顶层(不放在 useEffect 里),会发生什么?为什么这样做是有问题的?


三、useEffect 的依赖数组 —— 精确控制执行时机

useEffect 的第二个参数是一个依赖数组(dependency array),它决定了 effect 在什么时候执行。这是 useEffect 最核心、也最容易搞混的部分。

3.1 三种模式一网打尽

// 模式一:不传依赖数组 —— 每次渲染后都执行
useEffect(() => {
  console.log('组件渲染了');
});

// 模式二:传空数组 —— 仅在挂载时执行一次
useEffect(() => {
  console.log('组件挂载了');
}, []);

// 模式三:传入依赖 —— 依赖变化时执行
useEffect(() => {
  console.log(`count 变了:${count}`);
}, [count]);

用一张表来总结:

依赖数组执行时机类比
不传每次渲染后无条件加班——只要公司没关门,你就得干活
[]仅挂载时执行一次入职培训——只在第一天做一次
[a, b]a 或 b 变化时执行按需值班——只有相关任务来了才出动

3.2 依赖数组的工作原理

React 会在每次渲染后,把本次的依赖数组和上次的逐项比较(使用 Object.is)。只要有一项不同,就重新执行 effect 函数。

function SearchResults({ query, page }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}&page=${page}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [query, page]); // query 或 page 变化时重新搜索

  return (
    <ul>
      {results.map(item => <li key={item.id}>{item.title}</li>)}
    </ul>
  );
}

这里的关键原则是:effect 里用到了哪些”会变化的值”,就把它们放进依赖数组。如果你漏了某个依赖,effect 里读到的就是旧值,这就是后面要讲的”闭包陷阱”的根源。


四、useEffect 的清理机制 —— 避免内存泄漏

4.1 为什么需要清理

想象你在一家餐厅吃饭。你点了菜(启动了副作用),吃完要走了(组件要卸载或 effect 要重新执行),你得买单、擦桌子(清理副作用)。如果不清理,桌上的碗碟会越堆越多——在程序里,这就是内存泄漏

常见需要清理的场景:

  • 定时器(setInterval / setTimeout
  • 事件监听(addEventListener
  • WebSocket 连接
  • 订阅(如 RxJS、EventEmitter)

4.2 清理函数的写法

useEffect 的回调可以返回一个函数,这个函数就是清理函数。它会在两个时机被调用:

  1. 下一次 effect 执行之前(依赖变化时)
  2. 组件卸载时
function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);

    // 添加监听
    window.addEventListener('resize', handleResize);

    // 返回清理函数:移除监听
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 空数组:只绑定一次

  return <p>窗口宽度:{width}px</p>;
}

再来看一个定时器的例子:

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds(prev => prev + 1); // 使用函数式更新
    }, 1000);

    return () => {
      clearInterval(timer); // 组件卸载时清理定时器
    };
  }, []);

  return <p>已运行 {seconds} 秒</p>;
}

这里有个细节值得注意:我们在 setInterval 回调里用了 setSeconds(prev => prev + 1) 而不是 setSeconds(seconds + 1)。为什么?因为后者会掉进”闭包陷阱”——这个坑我们在第八节会深入讲解。

4.3 清理的执行顺序

为了帮你建立更清晰的心理模型,我们来看一个依赖变化时的完整流程:

function ChatRoom({ roomId }) {
  useEffect(() => {
    console.log(`连接到房间 ${roomId}`);
    const connection = connectToRoom(roomId);

    return () => {
      console.log(`断开房间 ${roomId}`);
      connection.disconnect();
    };
  }, [roomId]);

  return <h1>房间:{roomId}</h1>;
}

roomId 从 “general” 变为 “tech” 时,控制台输出:

断开房间 general    ← 先执行旧 effect 的清理函数
连接到房间 tech      ← 再执行新 effect

就像搬家:先退掉旧房子的租约,再签新房子的合同。顺序不能乱。

🤔 想一想 如果你在 useEffect 里发起了一个网络请求,但在请求还没返回时组件就卸载了,会发生什么?你该如何处理这种情况?(提示:可以在清理函数里使用 AbortController


五、useRef —— 跨渲染周期的”记事本”

5.1 理解 useRef

如果 useState 是组件的”记忆”(改了会触发重新渲染),那 useRef 就是一张便利贴——你可以在上面随便写东西、随时修改,但不管你怎么改,组件都不会重新渲染。

const myRef = useRef(initialValue);
// myRef.current === initialValue

useRef 返回一个带有 current 属性的对象。这个对象在组件的整个生命周期中保持不变——每次渲染拿到的都是同一个对象引用。

这就是 useRef 和普通变量的本质区别:

function Counter() {
  let normalVar = 0;               // 每次渲染都重新创建,永远是 0
  const refVar = useRef(0);        // 跨渲染保持,值可以累积

  const handleClick = () => {
    normalVar += 1;
    refVar.current += 1;
    console.log(`普通变量: ${normalVar}`);   // 永远打印 1
    console.log(`ref 变量: ${refVar.current}`); // 1, 2, 3, 4...
  };

  return <button onClick={handleClick}>点我</button>;
}

5.2 useRef 的两大用途

用途一:保存不需要触发渲染的数据

比如保存定时器 ID、前一次的 state 值、累计的计数等。

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

  useEffect(() => {
    ref.current = value;
  });

  return ref.current; // 返回上一次渲染时的值
}

// 使用
function PriceDisplay({ price }) {
  const prevPrice = usePrevious(price);

  return (
    <div>
      <p>当前价格:{price}</p>
      {prevPrice !== undefined && (
        <p>
          {price > prevPrice ? '涨了' : '跌了'}
        </p>
      )}
    </div>
  );
}

用途二:保存 DOM 引用(下一节详细讲)。


六、useRef 操作 DOM —— 聚焦、滚动、测量

6.1 获取 DOM 元素

这是 useRef 最常见的用法——把 ref 挂到 JSX 元素上,React 渲染完成后会自动把该 DOM 节点赋值给 ref.current

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后自动聚焦
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="我会自动获得焦点" />;
}

6.2 实战:滚动到指定位置

function ScrollToBottom() {
  const bottomRef = useRef(null);
  const [messages, setMessages] = useState([
    '你好!', '欢迎来到聊天室', '今天天气不错'
  ]);

  const addMessage = () => {
    setMessages(prev => [...prev, `新消息 ${prev.length + 1}`]);
  };

  // 每次消息更新后滚动到底部
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div>
      <div style={{ height: 200, overflow: 'auto', border: '1px solid #ccc' }}>
        {messages.map((msg, i) => (
          <p key={i} style={{ padding: 8 }}>{msg}</p>
        ))}
        <div ref={bottomRef} />
      </div>
      <button onClick={addMessage}>发送消息</button>
    </div>
  );
}

6.3 实战:测量元素尺寸

function MeasuredBox() {
  const boxRef = useRef(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (boxRef.current) {
      const { width, height } = boxRef.current.getBoundingClientRect();
      setSize({ width: Math.round(width), height: Math.round(height) });
    }
  }, []);

  return (
    <div>
      <div
        ref={boxRef}
        style={{ padding: 20, background: '#e0f0ff', display: 'inline-block' }}
      >
        测量我的尺寸
      </div>
      <p>宽:{size.width}px,高:{size.height}px</p>
    </div>
  );
}

你可以看到一个清晰的模式:useRef 负责”拿到”DOM 节点,useEffect 负责在渲染后”操作”它。这两个 Hook 经常搭配使用。


七、Hooks 的使用规则 —— 两条铁律

Hooks 不是普通函数,它们依赖 React 内部的调用顺序来工作。因此 React 定了两条不可违反的规则:

规则一:只在函数组件或自定义 Hook 的顶层调用

不要在循环、条件判断、嵌套函数里调用 Hook。

// 错误示范
function BadComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [name, setName] = useState('');  // 条件语句里调用 Hook
  }

  useEffect(() => {
    // ...
  }, []);

  return <div>...</div>;
}

// 正确做法
function GoodComponent({ isLoggedIn }) {
  const [name, setName] = useState('');

  useEffect(() => {
    if (isLoggedIn) {
      // 把条件判断放在 Hook 内部
      fetchUserName().then(n => setName(n));
    }
  }, [isLoggedIn]);

  return <div>{isLoggedIn ? `你好, ${name}` : '请登录'}</div>;
}

为什么? React 靠 Hook 的调用顺序来”记住”每个 Hook 对应的状态。如果第一次渲染调用了 3 个 Hook,第二次渲染因为条件分支只调用了 2 个,React 就会错位——第二个 Hook 拿到了第三个的值,整个状态系统就乱套了。

规则二:只在 React 函数中调用

Hook 只能在两个地方调用:

  1. 函数组件内部
  2. 自定义 Hook内部(即以 use 开头的函数)

不要在普通 JavaScript 函数、class 组件、事件处理函数外部等地方调用。

// 错误:在普通函数里调用 Hook
function getWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth); // 报错!
  return width;
}

// 正确:封装成自定义 Hook
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

🤔 想一想 ESLint 插件 eslint-plugin-react-hooks 提供了 rules-of-hooksexhaustive-deps 两条规则。前者帮你检查是否违反了”顶层调用”规则,后者帮你检查依赖数组是否写全。你的项目配置了这个插件吗?如果没有,强烈建议加上。


八、闭包陷阱 —— Hooks 最常踩的坑及解决方案

如果说 Hooks 有一个”终极 Boss”,那一定是闭包陷阱。它让无数开发者抓狂过,也是面试中的常考题。

8.1 问题复现

来看一个经典案例——用定时器每秒给 count 加 1:

function BrokenCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count:', count); // 永远打印 0
      setCount(count + 1);               // 永远是 0 + 1 = 1
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 空依赖数组:effect 只执行一次

  return <h1>{count}</h1>; // 界面永远显示 1
}

预期行为:count 每秒加 1,显示 0, 1, 2, 3, …

实际行为:count 从 0 变为 1 后就不动了。

为什么?

关键在于 useEffect 的依赖数组是 [],这意味着 effect 函数只在组件挂载时执行一次。而那一次执行时,count 的值是 0

setInterval 的回调函数”记住了”创建时的 count 值(这就是 JavaScript 的闭包机制),之后不管组件渲染多少次,这个回调里的 count 永远是 0。所以每次执行 setCount(count + 1) 实际上都是 setCount(0 + 1)

用一张图来理解:

第1次渲染:count = 0
  └─ useEffect 创建了 setInterval
       └─ 回调函数闭包捕获了 count = 0
            └─ 每秒执行 setCount(0 + 1) = 1

第2次渲染:count = 1(但 setInterval 的回调没有更新)
  └─ useEffect 没有重新执行(因为 deps 是 [])
       └─ 回调函数里的 count 还是 0
            └─ 每秒执行 setCount(0 + 1) = 1  ← 不再变化

⚠️ 常见误区 很多人以为 count 是一个”实时变量”,在哪里读都是最新值。但在函数组件里,每次渲染都有自己的 count。useEffect 闭包捕获的是那次渲染的 count,而不是某个会自动更新的”引用”。这是理解闭包陷阱的核心:函数组件的每次渲染都是一次独立的快照。

8.2 解决方案一:使用函数式更新(最简单)

如果你只是要基于前一个值来计算新值,根本不需要读取外部的 count

function FixedCounter1() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // 不再引用外部的 count,而是通过参数拿到最新值
      setCount(prevCount => prevCount + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <h1>{count}</h1>; // 正常递增:0, 1, 2, 3, ...
}

setCount(prevCount => prevCount + 1) 传入的是一个函数,React 会把最新的 state 作为参数传给它。这样就完全绕开了闭包——回调函数里没有引用任何外部的 state 变量。

适用场景: 新值可以从旧值推导出来,不需要读取其他状态。

8.3 解决方案二:把依赖加入数组(正统方案)

如果你确实需要在 effect 里读取 state 的最新值(比如要打印日志),那就把它加到依赖数组里:

function FixedCounter2() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('当前 count:', count); // 每次都能打印最新值

    const timer = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    // 必须清理,否则每次 effect 重新执行都会多一个定时器
    return () => clearInterval(timer);
  }, [count]); // count 变了就重新执行 effect

  return <h1>{count}</h1>;
}

每次 count 变化时:旧 effect 清理 -> 新 effect 执行 -> 新 setInterval 启动。这样回调函数里的 count 永远是最新的。

注意事项: 这种方式会导致定时器不断被销毁和重建。对于简单场景没问题,但如果你依赖”定时器不被中断”(比如精确计时),这种方案就不太合适了。

适用场景: 需要读取最新 state,且频繁重建 effect 可以接受。

8.4 解决方案三:useRef 保存最新值(最灵活)

当你既需要读取最新 state,又不想重建 effect 时,useRef 就是终极武器:

function FixedCounter3() {
  const [count, setCount] = useState(0);

  // 用 ref 保存最新的更新函数
  const countRef = useRef(count);
  countRef.current = count; // 每次渲染都同步最新值到 ref

  useEffect(() => {
    const timer = setInterval(() => {
      // 从 ref 读取,永远是最新值
      console.log('当前 count:', countRef.current);
      setCount(countRef.current + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // effect 只执行一次,定时器不会被重建

  return <h1>{count}</h1>;
}

原理很简单:countRef 是一个在组件整个生命周期中保持不变的对象引用。每次渲染时我们把最新的 count 写入 countRef.current,而 setInterval 的回调里通过 countRef.current 去读取——因为读的是对象属性而不是闭包里的变量,所以永远能拿到最新值。

适用场景: 定时器、动画等不希望被重建的长期副作用,同时需要访问最新状态。

8.5 三种方案对比

方案原理优点局限
函数式更新不引用外部变量,从参数获取旧值最简单只能基于旧 state 推导新值
依赖数组依赖变化时重建 effect最符合 React 设计理念effect 会被频繁重建
useRef用 ref 桥接最新值最灵活,effect 不重建多一层间接,代码稍复杂

实际开发中,优先用方案一,解决不了再用方案二,方案二有性能问题再用方案三。

8.6 封装一个不受闭包陷阱影响的 useInterval

定时器 + 闭包陷阱是如此常见,我们可以把方案三封装成一个自定义 Hook:

import { useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  // 每次渲染后更新 ref 中保存的回调
  useEffect(() => {
    savedCallback.current = callback;
  });

  // 设置定时器(只执行一次)
  useEffect(() => {
    if (delay === null) return; // 传 null 可以暂停定时器

    const timer = setInterval(() => {
      savedCallback.current();
    }, delay);

    return () => clearInterval(timer);
  }, [delay]);
}

使用时,再也不用担心闭包问题了:

function App() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    // 这里可以放心使用最新的 count
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}

📝 掌握度自测

  1. 以下 useEffect 的依赖数组写法,哪个表示”仅在组件挂载时执行一次”?

    • A) useEffect(() => { ... })
    • B) useEffect(() => { ... }, [])
    • C) useEffect(() => { ... }, [undefined])
    • D) useEffect(() => { ... }, null)
  2. useEffect 清理函数的执行时机,以下哪个说法是正确的?

    • A) 仅在组件卸载时执行
    • B) 在下一次 effect 执行前 + 组件卸载时执行
    • C) 在每次渲染前执行
    • D) 仅在依赖数组变化时执行
  3. 以下代码中,inputRef.current 在什么时候被赋值为 DOM 元素?

    const inputRef = useRef(null);
    return <input ref={inputRef} />;
    • A) useRef 调用时
    • B) return 语句执行时
    • C) React 完成 DOM 渲染后
    • D) 组件函数执行之前
  4. 以下哪个做法违反了 Hooks 的使用规则?

    • A) 在函数组件顶层调用 useState
    • B) 在自定义 Hook 中调用 useEffect
    • C) 在 if 语句内部调用 useRef
    • D) 在 useEffect 回调中调用 setState
  5. 解决闭包陷阱的”函数式更新”方案,其核心原理是什么?

    • A) 使用 useRef 保存最新值
    • B) 把 state 加入依赖数组
    • C) 传函数给 setState,通过参数获取最新 state,避免闭包引用
    • D) 使用 useLayoutEffect 替代 useEffect

💡 自我评估

  • 答对5题:你已经扎实掌握了 useEffect 和 useRef 的核心用法,闭包陷阱对你来说不再是问题。
  • 答对3-4题:基础不错,建议回顾答错的部分,特别是清理函数的执行时机和闭包陷阱的原理。
  • 答对0-2题:别灰心,这一章内容密度较高。建议动手把文中的代码示例全部跑一遍,边跑边理解。

参考答案: 1-B, 2-B, 3-C, 4-C, 5-C

购买课程解锁全部内容

从组件到架构:12 章系统掌握现代 React

¥29.90