在理解生命周期函数定义的基础上,真正考验的是不同场景下如何选用,以及避开那些容易踩的坑。下面从类组件和 Hooks 函数组件两条线,用代码案例详细对比,深入场景、注意事项和陷阱。


一、类组件生命周期:场景、坑点与案例

1. 挂载阶段 Mounting

constructor(props)
  • 场景:初始化内部 state、绑定事件处理函数。
  • 坑点:不可调用 setState,不要做任何副作用(如请求数据、订阅)。如果忘记调用 super(props)this.props 会是 undefined
  • 正确示例
constructor(props) {
  super(props);
  this.state = { loading: true };
  this.handleSave = this.handleSave.bind(this);
}
componentDidMount
  • 场景:发起网络请求、订阅全局事件、操作 DOM、开启定时器。
  • 坑点
    1. 直接 setState 会触发二次渲染,但只要用于初始化数据请求,这是正常的。但要注意,若在服务端渲染时调用会导致内存泄漏,应避免。
    2. 注册的监听器和定时器必须在 componentWillUnmount 中清除,否则卸载后继续执行会导致状态更新错误。
  • 代码案例
componentDidMount() {
  this.fetchUser();
  window.addEventListener('resize', this.handleResize);
  this.timer = setInterval(this.tick, 1000);
}

componentWillUnmount() {
  window.removeEventListener('resize', this.handleResize);
  clearInterval(this.timer);
}
不推荐用法:在 componentWillMount 中请求数据

废弃原因:异步渲染下该钩子可能被多次调用,请求会重复。所以请求请放在 componentDidMount


2. 更新阶段 Updating

shouldComponentUpdate(nextProps, nextState)
  • 场景:性能优化,避免不必要的渲染。默认返回 true
  • 坑点:若错误地返回 false,会导致组件无法响应 props 或 state 的变化。深层比较代价可能比渲染还高,一般推荐使用 PureComponentReact.memo
  • 案例
shouldComponentUpdate(nextProps) {
  // 只有列表数据真正变化时才更新
  return nextProps.items !== this.props.items;
}
getSnapshotBeforeUpdate(prevProps, prevState)
  • 场景:需要在 DOM 更新前捕获信息(如滚动位置),更新后配合 componentDidUpdate 恢复。
  • 坑点:忘记返回值会导致 componentDidUpdate 的第三个参数为 undefined。此方法必须和 componentDidUpdate 配合使用。
  • 代码案例(保持聊天框滚动位置)
getSnapshotBeforeUpdate(prevProps) {
  if (prevProps.messages.length < this.props.messages.length) {
    const list = this.listRef.current;
    return list.scrollHeight - list.scrollTop; // 记录距离底部的位置
  }
  return null;
}

componentDidUpdate(prevProps, prevState, snapshot) {
  if (snapshot !== null) {
    this.listRef.current.scrollTop = this.listRef.current.scrollHeight - snapshot;
  }
}
componentDidUpdate(prevProps, prevState, snapshot)
  • 场景:根据变化后的 props 重新请求数据、操作 DOM。
  • 致命陷阱:无限循环
    如果直接在里面无条件调用 setState 或触发状态更新,且未做对比判断,就会死循环。必须先比较新旧 props 或 state。
  • 错误案例 vs 正确案例
// ❌ 错误!每次更新都会请求,导致无限循环
componentDidUpdate() {
  this.fetchData(this.props.userId);
}

// ✅ 正确:仅在 userId 变化时请求
componentDidUpdate(prevProps) {
  if (this.props.userId !== prevProps.userId) {
    this.fetchData(this.props.userId);
  }
}

3. 卸载阶段 Unmounting

componentWillUnmount
  • 场景:清理所有副作用。
  • 坑点:在这里调用 setState 会触发警告且无效(组件已卸载)。此外,忘记取消异步请求的 setState 会导致内存泄漏和 React 警告。
  • 异步请求安全案例
componentDidMount() {
  this.cancelled = false;
  fetch(`/api/user/${this.props.id}`)
    .then(res => res.json())
    .then(data => {
      if (!this.cancelled) {
        this.setState({ user: data });
      }
    });
}

componentWillUnmount() {
  this.cancelled = true; // 标记已卸载,避免 setState
}

4. 错误处理 Error Boundary

  • 场景:捕获子树渲染错误,显示降级 UI。
  • 坑点:无法捕获自身抛出的错误,只能捕获子组件。也无法捕获事件处理器、异步代码中的错误,需要配合 try/catch
  • 代码
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 上报错误
    logService.report(error, errorInfo);
  }

  render() {
    if (this.state.hasError) return <h1>Something went wrong.</h1>;
    return this.props.children;
  }
}

二、函数组件 Hooks 模拟生命周期:场景与陷阱

函数组件没有生命周期方法,一切靠 Hooks 组合。最核心的是 useEffect

1. 模拟 componentDidMount

  • 场景:首次挂载后执行请求、订阅。
  • 代码
useEffect(() => {
  fetchUser();
  const subscription = eventSource.subscribe();
  return () => {
    subscription.unsubscribe(); // 模拟 componentWillUnmount
  };
}, []); // 空依赖数组 → 只在挂载时运行一次
  • 坑点
    • 依赖数组 [] 必须真实无依赖,否则 eslint-plugin-react-hooks 会报警告。如果内部引用了 props 或 state,应该列入依赖。
    • 函数中读取的 state 会形成闭包,获取到的是初次渲染的值,每次更新都会变化?实际上,如果依赖为空,内部引用的变量永远是初始值。若需要最新值,可用 useRef

2. 模拟 componentDidUpdate

  • 场景:当特定 props 或 state 变化时,执行副作用(如重新获取数据)。
  • 代码
useEffect(() => {
  if (userId) {
    fetchUserData(userId);
  }
}, [userId]); // userId 变化时执行
  • 陷阱1:忽略依赖导致闭包陈旧值

    // ❌ 错误:count 陈旧,每次打印的 count 都是初始的 0
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 永远打印 0
      }, 1000);
      return () => clearInterval(timer);
    }, []);
    

    解决:将 count 列入依赖,或使用函数式更新 setCount(c => c + 1),或用 useRef 存储最新 count。

  • 陷阱2:异步请求在组件卸载后更新状态
    即使依赖变化触发新请求,前一次请求可能还未完成。可使用标志位或 AbortController

    useEffect(() => {
      let cancelled = false;
      fetch(`/api/user/${userId}`)
        .then(res => res.json())
        .then(data => {
          if (!cancelled) setUser(data);
        });
      return () => { cancelled = true; };
    }, [userId]);
    

3. 模拟 componentWillUnmount

  • 代码:在 useEffect 返回清理函数。
useEffect(() => {
  const onResize = () => { /* ... */ };
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

4. useLayoutEffect — 同步读取布局

  • 场景:需要同步获取 DOM 尺寸,并在修改后立即渲染,避免闪烁。如滚动到指定位置、测量元素。
  • 对比 useEffectuseEffect 是在屏幕绘制后执行,如果修改 DOM 可能导致闪屏。
useLayoutEffect(() => {
  // 获取元素尺寸并马上调整
  const rect = ref.current.getBoundingClientRect();
  setHeight(rect.height);
}, []);
  • 坑点:会阻塞渲染,需谨慎使用。大多数情况 useEffect 足够。

5. useRef 突破闭包陷阱

  • 场景:需要在异步回调中获取最新 state 或 props,但又不想添加依赖。
const latestCount = useRef(count);
useEffect(() => {
  latestCount.current = count; // 每次渲染更新 ref
});

useEffect(() => {
  const timer = setInterval(() => {
    console.log(latestCount.current); // 总是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []);

三、常见坑点深度对比案例

陷阱对比:getDerivedStateFromProps 滥用 vs 正确替代

错误用法:用 props 复制到 state,导致后续 props 更新无法同步。

// ❌ 仅在挂载时复制,后续 props 变化不会更新 state
state = { email: this.props.email };

如果确实需要根据 props 重置 state,应使用 getDerivedStateFromProps,但更好的方式是用完全受控组件使用 key 重置

// ✅ 使用 key 强制重新挂载
<EmailInput key={userId} email={user.email} />

getDerivedStateFromProps 会让代码冗长,通常只在罕见场景(如 state 必须严格映射 props 变化,同时还要维护内部修改)使用。

陷阱对比:shouldComponentUpdate vs PureComponent vs React.memo

  • 类组件:React.PureComponent 自动浅比较 props 和 state。
  • 函数组件:React.memo 包裹。
  • 搭配 useMemouseCallback 避免引用变化导致 memo 失效。
const MyComponent = React.memo(({ items, onItemClick }) => {
  // 渲染
});
// 父组件中
const clickHandler = useCallback((id) => { /* ... */ }, []);
const sortedItems = useMemo(() => items.sort(), [items]);

陷阱:在事件处理中直接使用 state 导致闭包陈旧值

函数组件每次渲染都会创建新的函数,若事件处理函数(如定时器)捕获了旧的 state,解决方案是使用函数式更新

// ❌ 如果依赖变化频繁可能仍捕获旧值
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // count 陈旧
  }, 1000);
  return () => clearInterval(id);
}, [count]); // 每次 count 变化重建定时器,不好

// ✅ 函数式更新
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

四、场景对比:类组件 vs 函数组件实现订阅

类组件实现实时消息订阅

class ChatRoom extends React.Component {
  componentDidMount() {
    this.sub = MessageAPI.subscribe(this.props.roomId, this.handleNewMessage);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.roomId !== this.props.roomId) {
      this.sub.unsubscribe();
      this.sub = MessageAPI.subscribe(this.props.roomId, this.handleNewMessage);
    }
  }
  componentWillUnmount() {
    this.sub.unsubscribe();
  }
  handleNewMessage = (msg) => {
    this.setState(prev => ({ messages: [...prev.messages, msg] }));
  }
  // ...
}

函数组件 Hooks 实现

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const handleNewMessage = (msg) => {
      setMessages(prev => [...prev, msg]);
    };
    const sub = MessageAPI.subscribe(roomId, handleNewMessage);
    return () => sub.unsubscribe();
  }, [roomId]); // roomId 变化时重新订阅,清理旧订阅

  // ...
}

Hooks 版本自动将挂载、更新、卸载的清理逻辑整合在一起,避免了重复代码和漏清理的风险。


五、最佳实践总结

  1. 数据请求:统一在 componentDidMountuseEffect([], []) 中发起,注意清理。
  2. 副作用清理:务必返回清除函数,避免内存泄漏。
  3. 避免不必要的重新渲染:类组件用 PureComponentshouldComponentUpdate;函数组件用 React.memo + useCallback/useMemo
  4. 小心闭包陈旧值:使用 useRef 或函数式更新 setState(prev => ...)
  5. 谨慎使用 getDerivedStateFromProps,优先考虑受控组件或 key 重置。
  6. 错误边界用类组件实现,包裹可能出错的子树,同时注意无法捕获异步错误。
  7. useEffectuseLayoutEffect:绝大多数场景用前者;只有当 DOM 操作需要同步读取/修改,避免视觉抖动时才用后者。
  8. 严格遵循 eslint-plugin-react-hooks 规则,它可以帮助你捕获缺失的依赖。

理解生命周期的本质,就是管理组件的资源申请与释放。用对场景、避开陷阱,才能写出健壮的 React 应用。

更多推荐