3、useEffect 生命周期:告别 Class 的生命周期函数

说实话,我刚从 Class 组件切换到 Hooks 时,最不适应的就是生命周期。

以前写 componentDidMountcomponentDidUpdatecomponentWillUnmount,三个函数各司其职,逻辑清晰。但 useEffect 一个函数就把它们全包了,刚开始真有点懵。

不过用久了你会发现,这种设计其实更合理。为什么?因为它强迫你「关注副作用本身」,而不是「关注组件在哪个阶段」。

3.1 从 Class 到 Effect:思维转换

先看一个典型的 Class 组件写法:

class UserProfile extends React.Component {
  componentDidMount() {
    // 初始化:获取用户数据
    fetchUser(this.props.userId);
  }

  componentDidUpdate(prevProps) {
    // 更新:userId 变了就重新获取
    if (prevProps.userId !== this.props.userId) {
      fetchUser(this.props.userId);
    }
  }

  componentWillUnmount() {
    // 清理:取消请求或订阅
    cancelRequest();
  }

  render() {
    return <div>...</div>;
  }
}

换成 useEffect 怎么写?

function UserProfile({ userId }) {
  useEffect(() => {
    // 挂载 & 更新时执行
    fetchUser(userId);

    // 卸载时执行清理
    return () => {
      cancelRequest();
    };
  }, [userId]); // 只有 userId 变化时才重新执行

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

看到了吗?三个生命周期函数,被合并成一个 useEffect。我个人觉得,这种写法更符合直觉——「当 userId 变化时,我要做这件事」。而不是「组件挂载了我要做这个,更新了我要做那个」。

3.2 模拟 componentDidMount:空依赖数组

想只在组件挂载时执行一次?传一个空数组 [] 就行。

useEffect(() => {
  console.log('组件挂载了');
  // 初始化数据、注册事件监听等
}, []);

注意:空数组意味着这个 Effect 不依赖任何值,所以只在首次渲染后执行一次。

我在项目中遇到过一个问题:有个同事在空数组里用了某个 prop,结果 prop 更新了但 Effect 没重新执行,数据一直不对。嗯,这里要记住——如果你在 Effect 里用了某个变量,它就应该出现在依赖数组里。

3.3 模拟 componentDidUpdate:有依赖的 Effect

想监听某个值的变化?把它放进依赖数组:

useEffect(() => {
  console.log('count 变了:', count);
  // 每次 count 变化时执行
}, [count]);

如果想监听所有状态变化?不传依赖数组:

useEffect(() => {
  console.log('任意状态更新了');
}); // 没有第二个参数

但我不建议你这么做。每次渲染都执行 Effect,性能开销大,而且容易引发无限循环。你想想看,如果 Effect 里又更新了状态,那就会再次触发渲染,再次执行 Effect……死循环了。

3.4 模拟 componentWillUnmount:清理函数

Effect 返回的函数就是清理函数,在组件卸载时执行:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);

  // 清理函数
  return () => {
    clearInterval(timer);
    console.log('定时器已清理');
  };
}, []);

我曾经犯过一个错:在 Effect 里订阅了 WebSocket,但忘了写清理函数。结果组件卸载后,订阅还在,回调里还尝试更新已卸载组件的状态,控制台报了一堆警告。从那以后,我养成了一个习惯——只要 Effect 里有订阅、定时器、事件监听,就一定要写清理函数。

3.5 三种模式的对比表格

Class 生命周期 useEffect 写法 依赖数组 执行时机
componentDidMount useEffect(() => {...}, []) [] 首次渲染后
componentDidUpdate useEffect(() => {...}, [dep]) [dep] 或不传 依赖变化时或每次渲染后
componentWillUnmount useEffect(() => { return () => {...} }, []) [] 组件卸载时

3.6 避坑指南:常见的 useEffect 陷阱

陷阱一:依赖数组漏了变量

说白了,这就是 React 官方说的「闭包陷阱」。Effect 里用到的变量,如果没写在依赖数组里,它拿到的永远是旧值。

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

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 永远打印 0!
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 漏了 count

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

解决方案:要么把 count 加进依赖数组,要么用 ref 保存最新值。

陷阱二:无限循环

Effect 里更新状态,状态变化又触发 Effect 重新执行,形成死循环。

useEffect(() => {
  setCount(count + 1); // 每次执行都更新状态
}); // 没有依赖数组,每次渲染都执行

我的经验是:如果发现页面卡死或疯狂 re-render,先检查 Effect 里是不是直接更新了状态,而且没有加依赖控制。

陷阱三:清理函数执行时机

很多人以为清理函数只在卸载时执行。其实不是——每次 Effect 重新执行前,都会先执行上一次的清理函数。

useEffect(() => {
  console.log('订阅', id);
  return () => {
    console.log('取消订阅', id);
  };
}, [id]);

当 id 从 1 变成 2 时,执行顺序是:

  1. 取消订阅 id=1
  2. 订阅 id=2

这种设计很巧妙,它保证了不会同时存在两个订阅。

3.7 实战建议:什么时候用哪个?

我个人习惯这样划分:

  • 数据初始化(比如请求列表数据)→ 用 [],模拟 didMount
  • 监听 prop/state 变化(比如根据 userId 切换用户)→ 用 [userId],模拟 didUpdate
  • 清理资源(定时器、订阅、事件监听)→ 用清理函数,模拟 willUnmount
  • 多个独立副作用 → 拆成多个 useEffect,别写在一个里面

举个例子,一个聊天组件可能需要三个 Effect:

function ChatRoom({ roomId }) {
  // 1. 连接房间(挂载时 + roomId 变化时)
  useEffect(() => {
    connectToRoom(roomId);
    return () => disconnectFromRoom(roomId);
  }, [roomId]);

  // 2. 发送已读回执(每次消息列表变化时)
  useEffect(() => {
    sendReadReceipt(messages);
  }, [messages]);

  // 3. 页面标题(挂载时设置一次)
  useEffect(() => {
    document.title = `聊天室 - ${roomId}`;
  }, [roomId]);

  return // ...
}

你看,三个 Effect 各管各的事,互不干扰。比在 Class 里把逻辑分散到不同生命周期函数里清晰多了。

最后说一句:别想着「用 useEffect 模拟生命周期」,而是想「这个副作用什么时候该执行」。思维转过来后,你会发现 Hooks 的设计其实更优雅。