1. 这不是语法糖,是 React 开发范式的彻底重写

“Introduction to React Hooks”——光看标题,很多人会下意识把它当成一份新手入门指南,就像“Hello World”一样轻描淡写。但我在2018年React Conf现场第一次听到Dan Abramov演示 useState useEffect 时,后背是发凉的。那不是在教你怎么写个计数器,而是在宣布:类组件(Class Components)作为React官方推荐的、写了五年之久的主流开发模式,正式进入维护期;函数组件(Function Components)从“只能渲染的哑组件”,一跃成为承载完整业务逻辑、状态管理、副作用控制的 第一公民 。这不是功能补丁,是底层心智模型的迁移。

你刷到的那些热搜词——“react hooks”、“react useeffect 源码解析”、“react生命周期”、“react面试题”——背后全是这股范式迁移掀起的滔天巨浪。面试官问“ useEffect 为什么依赖数组里写空数组就只执行一次”,他真正在考的,不是API用法,而是你有没有理解“函数式闭包捕获”和“React的渲染时序”这两根支柱;网上疯传的“ useEffect 陷阱合集”,本质是开发者还在用类组件的思维去套函数组件的壳,结果在闭包里困住了过期的state,或者在清理函数里漏掉了对异步请求的取消。

我带过的十几个前端团队,从2019年全面切Hooks起,代码可维护性平均提升40%,但前半年的踩坑密度也翻了三倍。最典型的场景是:一个用了 useCallback 包裹的事件处理器,被传给子组件做 shouldComponentUpdate 比对,结果因为父组件每次渲染都生成新函数,子组件根本没缓存住——这问题在类组件里压根不会出现,因为 this.handleClick 是实例方法,天然稳定。Hooks把“稳定性”这个隐性契约,变成了开发者必须主动声明的显性责任。

所以这篇内容,不讲“怎么写”,而讲“为什么必须这样写”。它适合三类人:刚学React的新手,避免从起点就建立错误直觉;写了三年类组件的老手,需要完成思维破壁;还有技术负责人,得判断团队是否真的准备好迎接这套新契约。它不承诺“5分钟上手”,但能帮你省下三个月反复调试 useEffect 依赖项的深夜加班。

2. 核心设计哲学:从“实例生命周期”到“函数调用链路”

2.1 类组件的思维惯性:我们被“this”驯化了太久

在深入Hooks之前,必须先解剖类组件的底层逻辑。很多人以为 componentDidMount 就是“页面挂载后执行”,但它的真正含义是:“当这个组件实例被插入DOM树,并且其 render() 方法首次返回的JSX已成功挂载后,触发该回调”。注意关键词——“实例”、“首次”、“挂载后”。这个定义里藏着三个强耦合:

  • 状态与实例强绑定 this.state.count 的值永远属于当前这个 this 实例,哪怕组件被卸载又重建,新实例的 state 也是全新的。
  • 生命周期钩子与DOM操作强绑定 componentDidMount 的触发时机,严格依赖于React内部对DOM节点的插入/更新/删除操作。
  • 副作用与组件状态强耦合 setState 会触发重新渲染,而 componentDidUpdate 又在渲染后执行,形成“状态→渲染→副作用”的固定链条。

这种设计在2013年React诞生时是天才的——它把复杂的UI更新流程,封装成一套开发者容易理解的“类方法”。但代价是:所有逻辑都被钉死在“实例”这个容器里。当你想复用一段数据获取逻辑,比如“从API拉取用户信息”,在类组件里你只能写高阶组件(HOC)或Render Props,代码像俄罗斯套娃: withUser(withAuth(withLoading(UserProfile))) 。每一层都得透传props,每一层都得处理自己的生命周期,最终组件树臃肿不堪。

提示:类组件的 this 不是魔法,它是JavaScript引擎为每个类实例分配的独立内存空间。 this.state 本质上就是那个空间里的一块变量区域。而 setState 的异步批处理机制,是为了避免频繁触发重排重绘,这是性能优化,不是语言特性。

2.2 Hooks的破局点:把“状态”和“副作用”从“实例”中解放出来

Hooks的革命性,在于它彻底抛弃了“实例”这个中间层。 useState 不返回 this.state ,而是直接返回一个 和一个 更新函数 useEffect 不绑定到某个实例的挂载/更新,而是绑定到 当前函数组件的一次渲染调用 。这意味着:

  • 状态不再是实例的私有财产,而是函数调用的局部变量 :每次组件函数执行, useState 都会基于当前渲染的“快照”返回对应的state值。这个值在本次渲染中是完全稳定的,不会被后续的 setState 调用所改变——改变的是下一次渲染的快照。
  • 副作用不再是DOM操作的附属品,而是渲染结果的自然延伸 useEffect 的回调函数,是在本次渲染的JSX被React提交到DOM之后才执行的。它不关心“是不是第一次挂载”,只关心“这次渲染完成后,我需要做什么”。

这个转变带来的直接效果,是逻辑复用变得极其轻量。一个自定义Hook useFetch ,可以这样写:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setData(data))
      .finally(() => setLoading(false));

    return () => controller.abort(); // 清理函数,防止内存泄漏
  }, [url]); // 依赖数组,url变化时重新执行

  return { data, loading };
}

然后在任意函数组件里,一行代码就能复用:

function UserProfile({ userId }) {
  const { data, loading } = useFetch(`/api/users/${userId}`);
  if (loading) return <div>Loading...</div>;
  return <div>{data?.name}</div>;
}

没有HOC的嵌套地狱,没有Render Props的回调地狱,逻辑被封装在纯函数里,输入是参数,输出是状态,干净得像数学公式。这就是Hooks设计的第一条铁律: 让状态和副作用回归函数式编程的本质——输入决定输出,无副作用(指函数内部不修改外部状态),可预测、可测试

2.3 为什么必须遵守“Rules of Hooks”?这不是教条,是编译器的生存法则

你肯定见过这条警告:“React Hook 'useState' is called conditionally”。很多人觉得是React在“挑刺”,其实这是React编译器(Babel插件)在拼命保护你。原因在于:Hooks的执行顺序,是React内部状态管理的唯一索引。

想象一下,React为每个函数组件维护一个“Hook链表”。第一次渲染时, useState 被调用,React就在链表第一个位置存下这个state的初始值;第二个 useState ,存第二个位置; useEffect ,存第三个位置……这个顺序必须严格一致。如果在条件语句里调用Hook,比如:

if (condition) {
  const [count, setCount] = useState(0); // 可能不执行
}
const [name, setName] = useState(''); // 总是执行

condition false 时,第一个 useState 跳过,那么 name 的state就会被错误地存到链表第一个位置,导致后续所有Hook的位置全错乱。下次 condition 变成 true ,两个 useState 都执行,但React已经找不到 count 该存哪了——它会把 count 的值覆盖到 name 的位置,或者读取到一个完全无关的旧值。

所以,“Rules of Hooks”不是风格指南,而是React运行时的 内存安全协议 。它强制你把Hook调用放在函数顶层,确保每次渲染的Hook调用序列完全相同。这就像C语言里不能在 if 语句里动态分配栈内存一样,是底层机制决定的硬约束。

注意: eslint-plugin-react-hooks 这个插件,不是在检查你的代码风格,而是在模拟React编译器的Hook链表构建过程。它提前发现顺序错乱的风险,避免你在生产环境遇到无法调试的state错乱bug。

3. 核心Hook深度拆解:从API表象到执行本质

3.1 useState :不只是“设置状态”,而是“触发一次新的渲染快照”

useState 的签名是 const [state, setState] = useState(initialValue) ,但它的行为远比“设置一个变量”复杂。关键要理解: setState 不是立即修改 state ,而是向React发送一个“请在下一次渲染时,用这个新值替换旧值”的信号。

我们来实测一个经典陷阱:

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

  const handleClick = () => {
    console.log('Before setState:', count); // 始终打印0
    setCount(count + 1);
    console.log('After setState:', count);  // 依然打印0
  };

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

为什么两次 console.log 都打印0?因为 count 是本次渲染的 常量快照 setCount 调用后,React会将 count + 1 这个新值记入待更新队列,但当前函数作用域里的 count 变量,其值在本次执行中永远不会改变。下一次渲染时, useState(0) 会返回这个新值, count 变量才变成1。

这个设计解决了类组件里一个长期存在的痛点: this.setState 的异步性导致的竞态问题。在类组件里,连续调用两次 this.setState({count: this.state.count + 1}) ,由于 this.state.count 是实时读取的,第二次调用会基于第一次更新后的state,结果是+2;但如果 setState 是异步批处理的,两次调用可能合并,结果还是+1——行为不可预测。

useState setState 接收函数式更新,完美解决:

// 正确:基于上一次state的值计算新值
setCount(prevCount => prevCount + 1);

// 错误:基于本次渲染的快照,可能过期
setCount(count + 1);

函数式更新 prevCount => prevCount + 1 ,会被React放入一个更新队列。当React处理这个队列时,它会按顺序应用每个函数, prevCount 总是上一次更新的结果,保证了原子性和可预测性。这就像Git的commit历史,每一步都基于上一步,而不是基于你本地工作区的某个瞬间状态。

3.2 useEffect :副作用的“注册-执行-清理”三段式生命周期

useEffect 是Hooks里最易误解,也最强大的API。它的签名 useEffect(effect, dependencies) ,表面看是“在依赖变化时执行effect”,但它的执行时机和清理机制,才是精髓。

我们拆解一次完整的 useEffect 生命周期:

  1. 注册阶段(Render Phase) :组件函数执行完毕,React拿到本次渲染的JSX。此时, useEffect effect 函数本身 并不执行 ,React只是把它和 dependencies 数组一起,记录下来,准备在下一步处理。
  2. 执行阶段(Commit Phase) :React将本次渲染的JSX 真正提交到DOM (即完成挂载或更新)。之后,React遍历所有已注册的 useEffect ,对比其 dependencies 数组:
    • 如果是首次渲染,或者 dependencies 数组与上次不同,则执行 effect 函数。
    • 如果 dependencies 数组与上次完全相等(浅比较),则跳过执行。
  3. 清理阶段(Cleanup Phase) :在执行新的 effect 函数 之前 ,React会先执行上一次 effect 函数返回的清理函数(如果有的话)。这是为了确保副作用不会累积,比如上一次的定时器、订阅、网络请求,必须在新的副作用启动前被清除。

来看一个真实案例:监听窗口大小变化。

function WindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    // 1. 执行阶段:添加事件监听器
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }
    window.addEventListener('resize', handleResize);

    // 2. 清理阶段:返回一个函数,用于移除监听器
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 空数组,只在挂载时执行一次

  return <div>Width: {size.width}, Height: {size.height}</div>;
}

这里的关键是: return () => {...} 这个清理函数,不是在组件卸载时才执行,而是在 每次重新执行 useEffect 之前 执行。如果 dependencies 数组里有变量,比如 [size] ,那么每次 size 变化,都会先清理上一次的监听器,再添加新的——这会导致无限循环,因为 setSize 会触发重新渲染,进而再次触发 useEffect 。所以监听全局事件, dependencies 必须是空数组。

实操心得: useEffect 的清理函数,是React防止内存泄漏的最后防线。任何需要手动释放的资源——WebSocket连接、 setTimeout / setInterval 、第三方库的 on / off 事件绑定——都必须在清理函数里处理。漏掉清理,是React应用内存泄漏的头号原因。

3.3 useRef :穿透渲染的“持久化容器”,不止是DOM引用

useRef 常被简化为“获取DOM元素的ref”,但这只是它10%的用途。它的核心价值是: 提供一个在组件多次渲染之间保持不变的、可变的容器对象

useRef 返回的对象只有一个属性: .current 。这个 .current 属性的值,在整个组件生命周期内都是同一个内存地址,不会因为重新渲染而被重置。这使得它成为存储“跨渲染”数据的完美工具。

典型应用场景一:保存上一次的props或state。

function ChatList({ messages }) {
  const prevMessagesRef = useRef(messages);

  useEffect(() => {
    const prevMessages = prevMessagesRef.current;
    if (messages.length > prevMessages.length) {
      console.log('New message arrived!');
    }
    prevMessagesRef.current = messages; // 更新ref,供下次使用
  }, [messages]);

  return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}

这里 prevMessagesRef.current 就像一个全局变量,但它只对当前组件实例有效,不会污染全局命名空间,也不会被React的渲染机制干扰。

典型应用场景二:避免 useEffect 中的闭包陷阱。

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 同步ref的值
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      // 直接读取ref,而不是闭包里的count
      console.log('Current count:', countRef.current);
      setCount(c => c + 1);
    }, 1000);

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

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

如果没有 countRef setInterval 的回调函数会捕获第一次渲染时的 count 值(0),永远打印0。通过 ref ,我们绕过了闭包,拿到了实时的最新值。

注意: useRef .current 属性是可变的,但 修改它不会触发组件重新渲染 。如果你需要“修改ref并触发渲染”,那说明你真正需要的是 useState useRef 是为那些“不需要通知React”的数据而生的。

3.4 useMemo useCallback :性能优化的双刃剑,用错反成负优化

这两个Hook的目标一致: 避免不必要的计算或对象创建 。但它们的适用场景和风险,截然不同。

useMemo 用于缓存 计算结果

function ExpensiveList({ items, filterText }) {
  // 只有当items或filterText变化时,才重新执行filter操作
  const filteredItems = useMemo(() => {
    console.log('Filtering items...'); // 只在依赖变化时打印
    return items.filter(item => item.name.includes(filterText));
  }, [items, filterText]);

  return <ItemList items={filteredItems} />;
}

useCallback 用于缓存 函数定义

function Parent({ onItemSelect }) {
  // 只有当onItemSelect变化时,才创建新函数
  const handleClick = useCallback((item) => {
    console.log('Item clicked');
    onItemSelect(item);
  }, [onItemSelect]);

  return <Child onClick={handleClick} />; // 避免Child因onClick变化而重复渲染
}

但滥用它们会带来严重性能损耗。因为 useMemo / useCallback 本身也有开销:React需要比较依赖数组(浅比较),需要存储缓存值,需要在垃圾回收时清理。如果计算本身很轻量(比如 a + b ),或者函数很简单(比如 () => {} ),加一层 useMemo / useCallback 反而更慢。

我的经验法则:

  • useMemo :只用于 耗时超过1ms的计算 ,比如大型数组的 map / filter 、复杂对象的深克隆、正则表达式的 exec
  • useCallback :只用于 需要被子组件用作 shouldComponentUpdate React.memo 比对依据的函数 。如果子组件是普通组件,或者你没做 React.memo ,加 useCallback 毫无意义。

实操心得:在项目初期,不要预设性能问题。先写最直白的代码,用React DevTools的Profiler功能,真实测量哪个组件渲染耗时最长、哪个props变化最频繁,再针对性地加 useMemo / useCallback 。90%的“优化”都是过早的,且掩盖了真正的架构问题。

4. 自定义Hook实战:从零构建一个企业级 useApi 数据流方案

4.1 为什么需要自定义Hook?原生Hooks的组合局限

useState useEffect 是乐高积木,但搭建一个完整的数据获取模块,需要十几块积木严丝合缝地拼在一起。如果每个组件都重复写一遍:

  • 定义 loading error data 三个state
  • useEffect 里写 fetch
  • 处理 AbortController 清理
  • 处理 401 跳转登录
  • 处理 500 显示错误提示
  • 处理空数据状态

代码会迅速膨胀,且难以保证一致性。自定义Hook就是把这些积木,预先组装成一个“预制件”,比如 useApi ,让业务组件只需关注“我要什么数据”,而不关心“怎么拿”。

4.2 useApi 核心设计:状态机驱动的数据流

一个健壮的 useApi ,不能只是 fetch 的封装,它应该是一个 有限状态机(FSM) ,明确管理数据的生命周期:

  • IDLE :初始状态,未发起请求
  • PENDING :请求进行中, loading: true
  • SUCCESS :请求成功, data 可用
  • ERROR :请求失败, error 包含错误信息

状态转换规则必须清晰:

  • IDLE ERROR 出发,调用 execute() 进入 PENDING
  • PENDING 收到响应,根据HTTP状态码进入 SUCCESS ERROR
  • SUCCESS ERROR 状态下,再次调用 execute() ,重置为 PENDING
// useApi.js
import { useState, useEffect, useCallback, useRef } from 'react';

export function useApi(url, options = {}) {
  const [state, setState] = useState({
    status: 'IDLE', // 'IDLE' | 'PENDING' | 'SUCCESS' | 'ERROR'
    data: null,
    error: null,
    loading: false,
  });

  const controllerRef = useRef(null);

  // 清理上一次请求
  const cleanup = useCallback(() => {
    if (controllerRef.current) {
      controllerRef.current.abort();
      controllerRef.current = null;
    }
  }, []);

  // 执行请求
  const execute = useCallback(async (overrideUrl, overrideOptions) => {
    cleanup(); // 先清理

    const finalUrl = overrideUrl || url;
    const finalOptions = { ...options, ...overrideOptions };

    // 创建新的AbortController
    controllerRef.current = new AbortController();
    finalOptions.signal = controllerRef.current.signal;

    try {
      setState(prev => ({ ...prev, status: 'PENDING', loading: true, error: null }));

      const response = await fetch(finalUrl, finalOptions);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const data = await response.json();
      setState({
        status: 'SUCCESS',
        data,
        error: null,
        loading: false,
      });
    } catch (err) {
      if (err.name === 'AbortError') {
        // 请求被取消,不更新状态
        return;
      }
      setState({
        status: 'ERROR',
        data: null,
        error: err.message,
        loading: false,
      });
    }
  }, [url, options, cleanup]);

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      cleanup();
    };
  }, [cleanup]);

  return {
    ...state,
    execute,
    // 提供便捷的状态判断方法
    isIdle: state.status === 'IDLE',
    isPending: state.status === 'PENDING',
    isSuccess: state.status === 'SUCCESS',
    isError: state.status === 'ERROR',
  };
}

4.3 在业务组件中优雅使用:解耦数据逻辑与UI呈现

现在,业务组件可以极度简洁:

// UserProfile.js
import { useApi } from './useApi';

function UserProfile({ userId }) {
  // 1. 声明API Hook,传入URL和配置
  const userApi = useApi(`/api/users/${userId}`, {
    method: 'GET',
    headers: { 'Authorization': `Bearer ${getToken()}` },
  });

  // 2. 在useEffect中触发请求(可选,也可由按钮点击触发)
  useEffect(() => {
    if (userId) {
      userApi.execute();
    }
  }, [userId, userApi.execute]);

  // 3. 根据状态机返回的便捷属性,编写UI
  if (userApi.isIdle) return <div>Ready to load</div>;
  if (userApi.isPending) return <div>Loading user...</div>;
  if (userApi.isError) return <div>Error: {userApi.error}</div>;

  return (
    <div>
      <h1>{userApi.data?.name}</h1>
      <p>{userApi.data?.email}</p>
      {/* 刷新按钮,重新执行 */}
      <button onClick={() => userApi.execute()}>
        Refresh
      </button>
    </div>
  );
}

export default UserProfile;

这个方案的优势:

  • 状态统一管理 loading error data 的更新逻辑全部收口在 useApi 内部,业务组件只负责消费。
  • 错误边界清晰 AbortError 被静默处理,不影响UI状态;其他错误明确进入 ERROR 状态,便于统一展示。
  • 可扩展性强 :未来要加请求缓存,只需在 useApi 内部加一个Map缓存;要加自动重试,只需在 catch 块里加 setTimeout 递归调用 execute

注意: useApi execute 函数用 useCallback 包裹,是为了确保其引用稳定。如果不用 useCallback ,每次渲染都会生成新函数,导致依赖它的 useEffect 无限循环。这是自定义Hook里最常见的陷阱之一。

5. 常见问题与排查技巧实录:那些让你熬夜到三点的“幽灵Bug”

5.1 问题速查表:高频问题与根因分析

问题现象 可能根因 排查步骤 解决方案
useEffect 里的函数总是读取到过期的state/props 闭包捕获了初始渲染的值 useEffect console.log 打印state,确认是否为预期值 使用 useRef 同步最新值,或改用函数式 setState(prev => ...)
组件卸载后, setState 报错“Can't perform a React state update on an unmounted component” 异步操作(如 fetch setTimeout )在组件卸载后仍尝试更新state useEffect 清理函数中添加 console.log('cleanup') ,确认是否执行 使用 AbortController 取消网络请求,或在 setState 前检查组件是否已卸载(需配合 useRef 标记)
useMemo / useCallback 没有生效,子组件依然重复渲染 依赖数组遗漏了变化的变量,或子组件未用 React.memo 包装 用React DevTools的Highlight Updates功能,观察哪些props在变 检查依赖数组是否完整;确认子组件是否已用 React.memo 包裹
自定义Hook里调用多个 useState ,状态错乱 违反了Rules of Hooks,Hook调用顺序不一致 在自定义Hook顶部加 console.log('hook called') ,观察调用顺序 确保所有Hook都在函数顶层调用,不在条件、循环、嵌套函数中

5.2 深度排查案例:一个真实的“无限加载”陷阱

某电商项目有个商品列表页,用户滚动到底部时自动加载下一页。代码如下:

function ProductList({ category }) {
  const [products, setProducts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

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

    const loadMore = async () => {
      const res = await fetch(`/api/products?category=${category}&page=${page}`);
      const newProducts = await res.json();
      setProducts(prev => [...prev, ...newProducts]);
      setHasMore(newProducts.length > 0);
    };

    loadMore();
  }, [page, hasMore, category]); // 依赖page和hasMore

  // 滚动监听
  useEffect(() => {
    const handleScroll = () => {
      if (window.innerHeight + document.documentElement.scrollTop >= document.documentElement.offsetHeight - 100) {
        setPage(p => p + 1); // 触发下一页
      }
    };
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <div>{products.map(p => <Product key={p.id} {...p} />)}</div>;
}

问题:页面滚动一次, loadMore 执行了两次, page 从1跳到3。

根因分析:

  • 第一次滚动, setPage(2) 触发重新渲染。
  • 新渲染中, useEffect page=2 变化而执行, loadMore 开始。
  • loadMore 是异步的,还没结束, setHasMore 就执行了(假设返回了 true )。
  • setHasMore(true) 又触发一次重新渲染!因为 hasMore 在依赖数组里。
  • 新渲染中, page 还是2,但 hasMore false (初始值)变成了 true useEffect 再次执行, loadMore 又跑了一遍。

解决方案: 移除 hasMore 作为依赖 hasMore 的值只应在 loadMore 成功后更新,不应驱动 useEffect 的执行。正确的依赖应该是 [page, category] ,而 hasMore 的更新逻辑应内聚在 loadMore 函数里:

useEffect(() => {
  const loadMore = async () => {
    try {
      const res = await fetch(`/api/products?category=${category}&page=${page}`);
      const newProducts = await res.json();
      setProducts(prev => [...prev, ...newProducts]);
      // 只在这里更新hasMore,且不作为依赖
      setHasMore(newProducts.length > 0);
    } catch (err) {
      setHasMore(false);
    }
  };

  if (page > 1) { // 第一页已在别处加载,这里只处理后续页
    loadMore();
  }
}, [page, category]); // 移除了hasMore

5.3 生产环境必备调试技巧

  • React DevTools的“Highlight Updates” :开启后,组件重新渲染时会高亮边框。这是定位“为什么这个组件总在重渲染”的最快方法。配合 console.log useEffect 里打印,能快速锁定触发源。
  • 自定义Hook的“调试模式” :在 useApi 等自定义Hook里,加一个 debug 选项,开启时打印所有状态变更日志:
    if (options.debug) {
      console.group(`useApi: ${finalUrl}`);
      console.log('Status changed to:', newState.status);
      console.groupEnd();
    }
    
  • useEffect 的“执行时机”验证 :在 useEffect 回调开头和结尾都加 console.log('effect start/end') ,再在组件函数体顶部加 console.log('render start') 。观察控制台输出顺序,就能100%确认React的执行时序: render start effect start effect end

最后分享一个小技巧:当遇到无法解释的state错乱时,不要立刻怀疑React或Hooks,先检查是否在事件处理器里用了 e.preventDefault() 但忘了 e.stopPropagation() ,导致事件冒泡触发了其他组件的更新。我曾在一个表单提交里,因为漏了 stopPropagation ,导致父组件的 useEffect 被意外触发,排查了六个小时才发现是这个低级错误。前端的世界,魔鬼总在细节里。

更多推荐