React面试宝典【Part2:Hooks 深度面试】

2026-07-31 React,面试

二、Hooks 深度面试

1. Hooks使用规则

  1. 只在顶层调用,不能if/for/循环内部
  2. 只能在函数组件/自定义Hook调用
    原理:React依靠调用顺序维护Hook链表;顺序错乱会导致状态错乱。
    详解
  3. 只在函数组件/自定义 Hook 顶层调用
    不能在循环、条件、嵌套函数里用,保证调用顺序稳定。
function Demo() {
  // ✅ 顶层,顺序稳定
  const [count, setCount] = useState(0);
  useEffect(() => {}, []);

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

function Demo2() {
  const [show, setShow] = useState(true);

  if (show) {
    // ❌ 禁止:条件里调用 Hook
    const [name, setName] = useState('');
  }

  for (let i = 0; i < 3; i++) {
    // ❌ 禁止:循环里调用
    useState();
  }
}

function Demo3() {
  function handleClick() {
    // ❌ 禁止:嵌套普通函数
    const [value, setValue] = useState('');
  }

  useEffect(() => {
    setTimeout(() => {
      // ❌ 禁止:异步回调里
      useState();
    }, 1000);
  }, []);
}
  1. 只在 React 函数组件 / 自定义 Hook 中使用
    不能在普通 JS 函数、类组件、事件回调里随意调用。

  2. 自定义 Hook 必须以 use 开头
    便于 ESLint 插件识别、检查规则,也方便语义化区分。

// ✅ 以 use 开头
function useUser() {
  const [user, setUser] = useState(null);
  return user;
}

function Page() {
  const user = useUser(); // ✅ 顶层调用
  return <div>{user}</div>;
}
  1. 不要在异步回调里调用 Hook
    setTimeoutPromise.thenasync 内,会破坏顺序。

总结
只能在组件顶层用,不能嵌套/条件/异步,自定义 Hook 以 use 开头。


2. useState原理,setState同步还是异步?

useState 原理

  1. 函数组件执行时,React 按调用顺序在对应 Fiber 节点上维护一个链表式 Hook 队列
  2. useState 从队列中取出当前 state 和对应的 dispatch 方法。
  3. 调用 setState 会标记组件为待更新,触发组件重新渲染,并在下次渲染时拿到新 state。

setState 同步还是异步?
结论:批量更新时表现为异步,同步代码里也并非立即赋值。

  1. React 合成事件 / 生命周期 / useEffect 中
    • 异步批量更新
    • 多次 setState 会合并,只触发一次重渲染
    • 执行完回调后才更新 state
   const [count, setCount] = useState(0)
   const handleClick = () => {
     setCount(count + 1)
     setCount(count + 1)
     console.log(count) // 0,没变
   }
  1. 原生事件、setTimeout、Promise.then 中

    • React 18 之前:同步更新
    • React 18 之后:统一自动批处理,全部异步
  2. 如何拿到最新值

    • 用函数式更新:setCount(c => c + 1)
    • 或在 useEffect 里监听 count 变化

总结

  • 原理:按顺序存在 Fiber 的 Hook 链表,set 标记更新并重渲染
  • 同步/异步:React 控制的场景都是异步批量更新

3. useEffect 完整理解

作用:处理副作用(请求、订阅、定时器DOM操作)
执行时机:渲染完成后执行
依赖数组:空数组=仅mount;不写依赖每次渲染执行;
清理函数:相当于componentWillUnmount,同时每次依赖变更前先执行清理;
> React18严格模式:开发环境组件会挂载→卸载→重新挂载,effect执行两次,用于检测内存泄漏。

详解
useEffect作用
处理函数组件的副作用

  • 异步请求、定时器
  • 订阅、DOM 操作
  • 事件监听
  • 清理操作

相当于类组件生命周期的组合:
componentDidMount + componentDidUpdate + componentWillUnmount

语法

useEffect(() => {
  // 执行副作用

  return () => {
    // 清理函数
  }
}, [依赖项])

依赖项三种情况

  1. 不传依赖
    每次渲染都会执行
  2. 空数组 []
    只在挂载执行一次,清理在卸载时
  3. [state, props]
    依赖变化时执行,重新渲染前会先执行清理

执行顺序

  1. 组件渲染完成
  2. 执行上一轮的清理函数(如果有)
  3. 执行当前 useEffect 回调
    > 注意:useEffect 是异步延迟执行,不阻塞浏览器渲染。

清理函数的意义

  • 清除定时器
  • 取消网络请求
  • 移除事件监听
  • 取消订阅
    防止内存泄漏、重复触发、状态错乱

示例(定时器):

useEffect(() => {
  const timer = setInterval(() => {}, 1000);

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

❗闭包陷阱
useEffect 捕获的是渲染时的快照,依赖漏写会拿到旧值。

useEffect(() => {
  setInterval(() => {
    console.log(count); // 永远是旧值
  }, 1000);
}, []); // 依赖漏写

解决:

  • 补全依赖
  • 或用 useRef 存最新值
  • 或用函数式更新

与 useLayoutEffect 区别

  • useEffect:DOM 渲染后执行,不阻塞绘制
  • useLayoutEffect:DOM 更新后、绘制前同步执行,会阻塞
    适合需要获取 DOM 布局、避免闪烁的场景

总结
useEffect 管理副作用,依赖决定执行时机,清理防止泄漏,闭包陷阱是高频坑。


🔥4. Hook闭包陷阱

现象:useEffect/回调捕获到旧state/props值
原因:每次渲染创建独立函数作用域,捕获当前渲染快照;
✅解决方案:

  1. 补齐依赖数组(eslint-plugin-react-hooks)
  2. 使用useRef保存可变最新值
  3. useReducer规避依赖问题
  4. useEvent(React实验API)
    例子

定时器闭包陷阱

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

  useEffect(() => {
    // 每隔 1s 打印 count
    const timer = setInterval(() => {
      console.log(count); // 永远打印 0
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖为空

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

现象

  • 页面 count 一直在增加
  • 控制台永远打印 0

原因

  • useEffect 依赖为空,只执行一次
  • 定时器捕获了第一次渲染的 count 快照
  • 后续渲染产生新 count,但定时器里还是旧闭包值

解决方法
方法1:补上依赖

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 依赖 count

方法2:用函数式更新(不依赖外部值)

useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []);

方法3:用 useRef 存最新值

const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  const timer = setInterval(() => {
    console.log(countRef.current); // 永远最新
  }, 1000);
  return () => clearInterval(timer);
}, []);

总结
闭包陷阱 = 副作用捕获了旧的 state/props 快照,依赖没更新导致一直用旧值。


5. useRef / createRef区别

  • createRef:每次渲染创建新对象,不要在函数组件直接使用
  • useRef:组件生命周期内保持同一个引用;除DOM外,可存储可变数据,修改ref不会触发重渲染
  • 详解

本质区别

  • useRef:React Hooks API,只用于函数组件
  • createRef:普通 API,主要用于类组件

ℹ️创建时机

  • useRef组件整个生命周期只创建一次,每次重渲染返回同一个 ref 对象
  • createRef每次渲染都会创建新的 ref 对象,旧的被丢弃

例子对比

函数组件里使用

//createRef(错误示范)
function App() {
  // ❌ 每次渲染都新建一个 ref
  const ref = React.createRef();

  return <div ref={ref}>{/* 会频繁丢失、重建 */}</div>
}

//useRef(正确)
function App() {
  // ✅ 全程同一个 ref
  const ref = useRef(null);

  return <div ref={ref}></div>
}

类组件用 createRef(标准写法)

class App extends Component {
  // ✔ 构造器只执行一次
  constructor() {
    this.ref = createRef();
  }
  render() {
    return <div ref={this.ref}></div>
  }
}

总结

  • useRef:函数组件专用,持久化存储,适合存 DOM、定时器 ID、最新值
  • createRef:每次渲染都新建,只适合类组件
  • 口诀:函数用 useRef,类用 createRef

🔥6. useMemo vs useCallback vs React.memo

  1. React.memo:高阶组件,浅比较props,阻止函数组件不必要渲染(对比类组件PureComponent)
  2. useMemo(()=>计算值, deps):缓存计算结果,避免重复昂贵计算
  3. useCallback(fn, deps):缓存函数引用,配合React.memo,防止子组件因为函数引用变化重渲染

⚠️重要:不要盲目加这三个API!
缓存本身有开销;简单组件、廉价计算不需要缓存;先通过React DevTools Profiler定位性能瓶颈再优化。

详解

React.memo

  • 作用组件级缓存,对组件做浅比较,props 不变则复用组件,不重新渲染
  • 用法:包装函数组件

    export default React.memo(MyComponent);
  • 场景:防止父组件渲染导致子组件不必要更新

useCallback

  • 作用缓存函数引用,返回相同的函数实例
  • 用法

    const handleClick = useCallback(() => {
    setCount(c => c + 1);
    }, [依赖]);
  • 场景:函数传给 React.memo 包裹的子组件时,避免子组件重复渲染

useMemo

  • 作用缓存计算结果,避免每次渲染重复计算
  • 用法
  const double = useMemo(() => count * 2, [count]);
  • 场景:复杂计算、过滤、排序等开销大的逻辑

一句话区分

  • React.memo:缓存组件
  • useCallback:缓存函数
  • useMemo:缓存计算值

核心关系
React.memo 只有配合 useCallback / useMemo 才能真正防止子组件重渲染,否则引用类型 props 每次都变,memo 无效。


7. useContext 缺陷

context更新时,所有消费组件全部重渲染,无法精准订阅;
优化方案:拆分多个Context、状态原子化、搭配useReducer/zustand等外部状态库。
详解

useContext 缺陷

  1. 值一变化,所有消费组件全量重渲染
    只要 context value 变,所有用到该 Context 的组件都会重新渲染,无法精准更新,性能易崩。

  2. 无法细分状态
    一个 Context 通常存多状态,任意一个状态变,全部消费者重渲染,不能单独订阅部分状态。

  3. 层级嵌套地狱
    多业务场景需要多个 Context 时,代码会出现层层嵌套,可读性差。

    <AProvider>
     <BProvider>
       <CProvider>...</CProvider>
     </BProvider>
    </AProvider>
  4. 调试困难
    状态来源不直观,跨层级传递后难以追踪更新源头。

  5. 不支持派生状态缓存
    派生值每次都重新计算,无内置缓存,需配合 useMemo 优化。

优化方案

  • 按模块拆分为多个小 Context
  • 使用 useMemo 缓存 Context 的 value
  • 搭配状态库:Redux、Zustand、Jotai 等
  • 使用 useContextSelector(社区方案或 React 18+ 实验性)实现精准订阅

8. 自定义Hook

本质:封装可复用逻辑,抽离组件公共副作用、请求、表单逻辑;面试常手写useFetch、useDebounce。
例子

自定义 Hook 规范

  • 函数名以 use 开头
  • 内部可使用其他 Hook
  • 只封装状态逻辑,不渲染 UI

useDebounce(防抖 Hook)

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

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

  return debouncedValue;
}

使用:

const [keyword, setKeyword] = useState('');
const searchWord = useDebounce(keyword, 600);

手写 useFetch(基础请求 Hook)

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    setLoading(true);
    fetch(url, { ...options, signal })
      .then(res => res.json())
      .then(data => {
        setData(data);
        setError(null);
      })
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

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

  return { data, loading, error };
}

使用:

const { data, loading, error } = useFetch('/api/list');

9. React18新增Hooks

  • useTransition:区分紧急更新与过渡更新(搜索联想场景,输入高优,列表渲染低优)
  • useDeferredValue:延迟某个值更新,和useTransition能力相似,作用于值
  • useId:生成服务端渲染一致唯一ID,解决SSR hydration不匹配
    详解

React18 新增 Hook 依托并发渲染(Concurrent Rendering)能力:
useTransitionuseDeferredValueuseIduseSyncExternalStoreuseInsertionEffect

useTransition
作用:标记状态更新为低优先级任务,高优先级操作(输入、点击)可以打断它,保证界面流畅。
返回 [isPending, startTransition]

  • isPending:过渡是否进行中(用来展示 loading)
  • startTransition:包裹低优先级 setState
const [text, setText] = useState('');
const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();

const onChange = (e) => {
  // 高优先级:输入框实时更新
  setText(e.target.value);
  // 低优先级:大列表过滤,可被打断
  startTransition(() => {
    setList(filterBigData(e.target.value));
  });
};

> 场景:搜索大列表、Tab切换、昂贵列表渲染

useDeferredValue
作用:延迟一个值的更新,生成该值的低优先级副本。
> 和 useTransition 核心区别
> ✅ useTransition:控制状态更新函数
> ✅ useDeferredValue:控制传入的值(适合props、外部值,无法修改setState场景)

const [keyword, setKeyword] = useState('');
// 延迟副本,不会立刻更新
const deferredKey = useDeferredValue(keyword);
// 使用延迟值做昂贵计算
const result = useMemo(() => filterBigData(deferredKey), [deferredKey]);

> 不要和防抖混淆:它是优先级调度,没有固定延时;防抖是固定时间延迟。

useId
作用:生成唯一稳定ID,解决SSR水合(hydration)不匹配问题。
> 不能用作列表key!

function InputItem() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>用户名</label>
      <input id={id} />
    </>
  );
}

场景:无障碍 label、表单关联ID,组件库开发。

useSyncExternalStore
作用:订阅外部状态(非React管理的store:redux、mobx、原生全局变量),替代手写useEffect订阅方案,并发模式下保证状态一致性。
官方推荐状态库底层实现API。

// 简易结构
const state = useSyncExternalStore(
  subscribe,      // 订阅函数
  getSnapshot,    // 获取当前状态
  getServerSnapshot // SSR快照(可选)
);

useInsertionEffect
作用:仅限 CSS-in-JS库使用
执行时机:DOM变更之前,早于 useLayoutEffectuseEffect
用来动态插入style标签,避免布局抖动。
⚠️ 业务开发禁止使用,不能访问DOM。