跳到主要内容

组件生命周期

组件生命周期是 React 中非常重要的概念,它描述了组件从创建到销毁的整个过程。在现代 React 中,我们主要通过 useEffect Hook 来处理生命周期相关的逻辑,但理解生命周期的概念仍然至关重要。

理解组件的生命周期

每个 React 组件都会经历几个阶段,就像生物的生命周期一样:它诞生、成长(更新),最终消亡。理解这些阶段有助于我们在正确的时机执行正确的操作。

组件的三个核心阶段

┌─────────────────────────────────────────────────────────────┐
│ 组件生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 挂载阶段(Mounting) │
│ ├── 组件被创建 │
│ ├── 初始化 state │
│ └── 组件被插入 DOM │
│ ↓ │
│ 更新阶段(Updating) │
│ ├── props 变化 │
│ ├── state 变化 │
│ └──父组件重新渲染 │
│ ↓ │
│ 卸载阶段(Unmounting) │
│ └── 组件从 DOM 中移除 │
│ │
└─────────────────────────────────────────────────────────────┘

为什么需要理解生命周期?

在实际开发中,我们经常需要在特定时机执行特定操作:

  • 挂载时:发起网络请求、设置订阅、初始化第三方库
  • 更新时:响应数据变化、执行副作用
  • 卸载时:清理定时器、取消订阅、释放资源

理解生命周期可以帮你避免常见的陷阱,比如内存泄漏和不必要的重复渲染。


useEffect:生命周期的现代方式

在函数组件中,useEffect 是处理生命周期逻辑的核心工具。它将类组件中分散在多个生命周期方法中的逻辑统一到了一个 API 中。

useEffect 的执行时机

useEffect 在组件渲染到屏幕之后执行。这意味着它不会阻塞浏览器的屏幕更新,用户会先看到更新后的 UI,然后才执行副作用。

实时编辑器
function EffectTimingDemo() {
  const [count, setCount] = useState(0);
  const [logs, setLogs] = useState([]);

  const addLog = (message) => {
    setLogs(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
  };

  useEffect(() => {
    addLog('组件挂载完成');
    return () => addLog('组件即将卸载');
  }, []);

  useEffect(() => {
    if (count > 0) {
      addLog(`count 更新为 ${count}`);
    }
  }, [count]);

  return (
    <div>
      <p>计数: <strong style={{ fontSize: '24px' }}>{count}</strong></p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
      <div style={{ 
        marginTop: '10px', 
        padding: '10px', 
        background: '#f1f5f9', 
        borderRadius: '4px',
        maxHeight: '150px',
        overflow: 'auto'
      }}>
        <strong>生命周期日志:</strong>
        {logs.slice(-5).map((log, i) => (
          <p key={i} style={{ margin: '4px 0', fontSize: '12px', color: '#64748b' }}>{log}</p>
        ))}
      </div>
    </div>
  );
}
结果
Loading...

依赖数组的作用

依赖数组决定了 useEffect 何时执行:

// 1. 每次渲染后都执行
useEffect(() => {
console.log('每次渲染后执行');
});

// 2. 只在挂载时执行一次
useEffect(() => {
console.log('组件挂载');
}, []);

// 3. 在挂载时和 count 变化时执行
useEffect(() => {
console.log('count 变化:', count);
}, [count]);

// 4. 在挂载时和 count 或 name 任一变化时执行
useEffect(() => {
console.log('count 或 name 变化');
}, [count, name]);

清理函数

useEffect 可以返回一个清理函数,这个函数会在组件卸载时或下一次 effect 执行前被调用。

实时编辑器
function TimerDemo() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    if (!isRunning) return;

    // 设置定时器
    const intervalId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // 清理函数:在组件卸载或 isRunning 变为 false 时执行
    return () => {
      clearInterval(intervalId);
    };
  }, [isRunning]);

  return (
    <div style={{ textAlign: 'center', padding: '20px' }}>
      <div style={{ fontSize: '48px', marginBottom: '20px' }}>
        {Math.floor(seconds / 60).toString().padStart(2, '0')}:
        {(seconds % 60).toString().padStart(2, '0')}
      </div>
      <button 
        onClick={() => setIsRunning(!isRunning)}
        style={{ 
          padding: '10px 20px',
          background: isRunning ? '#ef4444' : '#22c55e',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        {isRunning ? '暂停' : '开始'}
      </button>
      <button 
        onClick={() => setSeconds(0)}
        style={{ 
          marginLeft: '10px',
          padding: '10px 20px',
          background: '#64748b',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        重置
      </button>
    </div>
  );
}
结果
Loading...

清理函数的执行时机

清理函数在以下情况执行:

  1. 组件卸载时:组件从 DOM 中移除
  2. 下一次 effect 执行前:依赖变化导致 effect 需要重新执行
// 假设 roomId 从 'general' 变为 'travel'
useEffect(() => {
const connection = connectToRoom(roomId);

return () => {
// 1. 先断开与 'general' 的连接
connection.disconnect();
};
}, [roomId]);
// 2. 然后连接到 'travel'

这个机制确保了资源始终与当前的 props 和 state 保持同步。


挂载阶段详解

挂载阶段是组件生命周期的开始,在这个阶段组件被创建并插入到 DOM 中。

挂载时机的典型操作

实时编辑器
function MountDemo() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 模拟数据获取
    let cancelled = false;
    
    async function fetchData() {
      try {
        setLoading(true);
        // 模拟 API 调用
        await new Promise(resolve => setTimeout(resolve, 1500));
        
        if (!cancelled) {
          setData({ 
            title: 'React 生命周期教程', 
            author: 'React Team',
            views: 12345 
          });
        }
      } catch (err) {
        if (!cancelled) {
          setError('加载失败');
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    // 清理:防止组件卸载后更新状态
    return () => {
      cancelled = true;
    };
  }, []);

  if (loading) {
    return (
      <div style={{ textAlign: 'center', padding: '40px' }}>
        <div style={{ 
          display: 'inline-block',
          width: '40px',
          height: '40px',
          border: '4px solid #e2e8f0',
          borderTopColor: '#3b82f6',
          borderRadius: '50%',
          animation: 'spin 1s linear infinite'
        }} />
        <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
        <p style={{ marginTop: '10px', color: '#64748b' }}>加载中...</p>
      </div>
    );
  }

  if (error) {
    return <p style={{ color: '#ef4444' }}>{error}</p>;
  }

  return (
    <div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
      <h3 style={{ margin: '0 0 10px' }}>{data.title}</h3>
      <p style={{ margin: '4px 0', color: '#64748b' }}>作者: {data.author}</p>
      <p style={{ margin: '4px 0', color: '#64748b' }}>浏览: {data.views.toLocaleString()}</p>
    </div>
  );
}
结果
Loading...

避免内存泄漏

在组件卸载时,如果异步操作仍在进行,可能会尝试更新已卸载组件的状态,导致内存泄漏。

// 错误示范:可能导致内存泄漏
useEffect(() => {
fetchData().then(data => {
setData(data); // 如果组件已卸载,这会警告
});
}, []);

// 正确做法:使用清理标志
useEffect(() => {
let cancelled = false;

fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});

return () => { cancelled = true; };
}, []);

// 或者使用 AbortController 取消请求
useEffect(() => {
const controller = new AbortController();

fetchData({ signal: controller.signal }).then(data => {
setData(data);
});

return () => controller.abort();
}, []);

更新阶段详解

更新阶段发生在组件的 props 或 state 发生变化时。React 会重新渲染组件,并可能触发副作用。

更新触发的条件

组件会在以下情况下更新:

  1. 父组件传递新的 props
  2. 组件内部调用 setState 更新 state
  3. 父组件重新渲染(即使 props 未变)
实时编辑器
function UpdateDemo() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');
  const renderCount = useRef(0);
  
  // 记录渲染次数
  renderCount.current++;

  // 响应 count 变化
  useEffect(() => {
    if (count > 0) {
      console.log(`count 更新为 ${count}`);
    }
  }, [count]);

  // 响应 text 变化
  useEffect(() => {
    if (text) {
      console.log(`text 更新为 "${text}"`);
    }
  }, [text]);

  return (
    <div>
      <p>渲染次数: <strong>{renderCount.current}</strong></p>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加 Count</button>
      
      <div style={{ marginTop: '10px' }}>
        <input 
          value={text}
          onChange={e => setText(e.target.value)}
          placeholder="输入文字..."
          style={{ padding: '8px', width: '200px' }}
        />
      </div>
      
      <p style={{ fontSize: '12px', color: '#64748b', marginTop: '10px' }}>
        打开浏览器控制台查看更新日志
      </p>
    </div>
  );
}
结果
Loading...

前一次 props/state 的值

有时候我们需要访问前一次的值来进行比较。

实时编辑器
function PreviousValueDemo() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef();

  // 在渲染后保存当前值
  useEffect(() => {
    prevCountRef.current = count;
  }, [count]);

  const prevCount = prevCountRef.current;

  return (
    <div style={{ textAlign: 'center' }}>
      <p>当前值: <strong style={{ fontSize: '24px', color: '#3b82f6' }}>{count}</strong></p>
      <p>前一次值: <strong style={{ fontSize: '24px', color: '#94a3b8' }}>{prevCount ?? '-'}</strong></p>
      <p>变化: {prevCount !== undefined ? (count - prevCount > 0 ? '+' : '') + (count - prevCount) : '-'}</p>
      <button onClick={() => setCount(c => c + 1)} style={{ padding: '8px 16px' }}>
        增加
      </button>
      <button onClick={() => setCount(c => c - 1)} style={{ marginLeft: '8px', padding: '8px 16px' }}>
        减少
      </button>
    </div>
  );
}
结果
Loading...

封装为自定义 Hook

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

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

return ref.current;
}

// 使用
function MyComponent() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);

return <div>当前: {count}, 之前: {prevCount}</div>;
}

卸载阶段详解

卸载阶段是组件生命周期的终点,组件从 DOM 中被移除。这是执行清理工作的最佳时机。

需要清理的资源

在组件卸载时,应该清理以下类型的资源:

  • 定时器setTimeout, setInterval
  • 订阅:WebSocket, 自定义事件, Redux store
  • 网络请求:取消正在进行的 fetch/axios 请求
  • 事件监听器addEventListener 添加的监听器
  • 动画帧requestAnimationFrame
实时编辑器
function UnmountDemo() {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button 
        onClick={() => setShow(!show)}
        style={{ padding: '8px 16px', marginBottom: '10px' }}
      >
        {show ? '卸载组件' : '重新挂载'}
      </button>
      
      {show && <CounterWithCleanup />}
    </div>
  );
}

function CounterWithCleanup() {
  const [count, setCount] = useState(0);
  const [logs, setLogs] = useState([]);

  useEffect(() => {
    const log = (msg) => setLogs(prev => [...prev.slice(-4), msg]);
    
    log('组件挂载');
    
    const intervalId = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);

    const handleResize = () => {
      log('窗口大小变化');
    };
    window.addEventListener('resize', handleResize);

    // 清理函数
    return () => {
      log('组件卸载,清理资源');
      clearInterval(intervalId);
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return (
    <div style={{ padding: '15px', background: '#f0fdf4', borderRadius: '8px', border: '1px solid #86efac' }}>
      <p style={{ fontSize: '32px', margin: '0 0 10px' }}>{count}</p>
      <p style={{ fontSize: '12px', color: '#64748b' }}>计数器每秒增加 1</p>
      <div style={{ fontSize: '12px', marginTop: '10px' }}>
        {logs.map((log, i) => (
          <p key={i} style={{ margin: '2px 0', color: '#166534' }}>{log}</p>
        ))}
      </div>
    </div>
  );
}
结果
Loading...

类组件生命周期(遗留知识)

虽然现代 React 开发推荐使用函数组件,但你可能会在旧代码中遇到类组件。了解类组件的生命周期方法有助于维护遗留项目。

类组件生命周期方法对照表

类组件方法触发时机函数组件等效
constructor组件创建时useState 初始化
componentDidMount挂载后useEffect(() => {}, [])
componentDidUpdate更新后useEffect(() => {}, [deps])
componentWillUnmount卸载前useEffect 返回清理函数
shouldComponentUpdate更新前React.memo
getDerivedStateFromProps渲染前渲染时更新 state
getSnapshotBeforeUpdateDOM 更新前useLayoutEffect

类组件示例

// 类组件的生命周期方法
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
console.log('constructor: 组件创建');
}

componentDidMount() {
console.log('componentDidMount: 组件挂载完成');
// 发起网络请求、设置订阅等
}

componentDidUpdate(prevProps, prevState) {
console.log('componentDidUpdate: 组件更新');
if (prevState.count !== this.state.count) {
console.log('count 从', prevState.count, '变为', this.state.count);
}
}

componentWillUnmount() {
console.log('componentWillUnmount: 组件即将卸载');
// 清理定时器、取消订阅等
}

render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
增加
</button>
</div>
);
}
}

迁移指南

将类组件迁移到函数组件:

// 类组件
class Profile extends React.Component {
state = { user: null, loading: true };

componentDidMount() {
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}

componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.setState({ loading: true });
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
}

render() {
const { user, loading } = this.state;
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
}

// 等效的函数组件
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);

if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}

Effect 的生命周期视角

React 官方文档建议我们从 Effect 本身的视角来理解生命周期,而不是从组件的视角。

从组件视角 vs 从 Effect 视角

function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}

从组件视角看

  • 组件挂载 → 连接到房间
  • 组件更新 → 断开旧连接,连接到新房间
  • 组件卸载 → 断开连接

从 Effect 视角看

  • Effect 开始同步 → 连接到当前 roomId
  • Effect 停止同步 → 断开连接
  • Effect 重新同步 → 断开旧连接,连接到新 roomId

这种视角的转变让我们更关注"同步"这个核心概念,而不是"生命周期的时机点"。

Effect 应该代表独立的同步过程

每个 Effect 应该做一件事,代表一个独立的同步过程。

// 不推荐:一个 Effect 做多件事
useEffect(() => {
logVisit(roomId); // 分析日志
const connection = createConnection(roomId);
connection.connect(); // 连接聊天室
document.title = roomId; // 更新标题
return () => connection.disconnect();
}, [roomId]);

// 推荐:拆分为多个独立的 Effect
useEffect(() => {
logVisit(roomId);
}, [roomId]);

useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

useEffect(() => {
document.title = roomId;
}, [roomId]);

常见陷阱与解决方案

1. 无限循环

当 useEffect 的依赖包含了在 effect 内部更新的状态时,会导致无限循环。

// 错误:无限循环
function BadExample() {
const [count, setCount] = useState(0);

useEffect(() => {
setCount(count + 1); // 更新 count
}, [count]); // 依赖 count,导致无限循环
}

// 正确方案 1:移除不必要的依赖
function GoodExample1() {
const [count, setCount] = useState(0);

useEffect(() => {
setCount(c => c + 1); // 使用函数式更新
}, []); // 空依赖,只执行一次
}

// 正确方案 2:检查是否真的需要这个 effect
function GoodExample2() {
const [count, setCount] = useState(0);
// 也许这个更新根本不需要在 effect 中进行
}

2. 依赖数组遗漏

遗漏依赖可能导致 effect 使用过时的 props 或 state。

// 错误:遗漏依赖
function BadExample({ userId }) {
const [data, setData] = useState(null);

useEffect(() => {
fetchData(userId).then(setData);
}, []); // 缺少 userId 依赖
// 当 userId 变化时,不会重新获取数据!
}

// 正确:包含所有依赖
function GoodExample({ userId }) {
const [data, setData] = useState(null);

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]); // userId 变化时重新获取
}

3. 在渲染中执行副作用

副作用不应该在渲染过程中执行,这会导致问题。

// 错误:在渲染中执行副作用
function BadExample() {
const [count, setCount] = useState(0);

if (count > 10) {
setCount(0); // 在渲染中更新状态,导致无限循环
}

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

// 正确:在 effect 中处理
function GoodExample() {
const [count, setCount] = useState(0);

useEffect(() => {
if (count > 10) {
setCount(0);
}
}, [count]);

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

// 或者更好的方式:在事件处理器中处理
function BetterExample() {
const [count, setCount] = useState(0);

const handleClick = () => {
const newCount = count + 1;
setCount(newCount > 10 ? 0 : newCount);
};

return <button onClick={handleClick}>{count}</button>;
}

useLayoutEffect 与 useEffect 的区别

useLayoutEffectuseEffect 语法相同,但执行时机不同。

执行时机对比

状态更新

React 计算新的虚拟 DOM

React 更新真实 DOM

useLayoutEffect 执行 ← 同步执行,阻塞浏览器绘制

浏览器绘制屏幕

useEffect 执行 ← 异步执行,不阻塞绘制

使用场景

  • useEffect:大多数副作用,不阻塞视觉更新
  • useLayoutEffect:需要读取 DOM 布局并同步重新渲染时
function Tooltip({ children, content }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const [show, setShow] = useState(false);

useLayoutEffect(() => {
if (show && triggerRef.current && tooltipRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();

// 在浏览器绘制前计算位置,避免闪烁
setPosition({
top: triggerRect.top - tooltipRect.height - 8,
left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
});
}
}, [show]);

return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
</span>
{show && (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
background: '#1e293b',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '14px'
}}
>
{content}
</div>
)}
</>
);
}

小结

  1. 三个核心阶段:挂载、更新、卸载
  2. useEffect 是函数组件处理生命周期逻辑的核心工具
  3. 依赖数组 决定了 effect 何时执行
  4. 清理函数 在组件卸载或 effect 重新执行前调用
  5. 从 Effect 视角理解:关注同步的开始和停止,而非组件生命周期
  6. 避免常见陷阱:无限循环、依赖遗漏、渲染中执行副作用
  7. useLayoutEffect 用于需要同步读取 DOM 布局的场景

练习

  1. 实现一个自定义 Hook useInterval,能够安全地设置和清理 interval
  2. 实现一个计数器组件,显示前一次的值和当前值的差异
  3. 创建一个组件,在挂载时订阅窗口大小变化,卸载时取消订阅
  4. 使用 useLayoutEffect 实现一个不会闪烁的下拉菜单
  5. 将一个使用生命周期方法的类组件迁移为函数组件

参考资源