跳到正文
前端知识库
React

说说对 React Hooks 的理解?解决了什么问题?

理解 Hooks 的状态关联、逻辑复用与规则,并实现依赖完整、正确清理的标题、刷新和滚动订阅 Hook。

约 9 分钟React · Hooks · useEffect · useReducer · 清理 · 闭包

说说对 React Hooks 的理解?解决了什么问题?

问题解析

面试官考察点:

  • 是否了解 Hooks 的设计动机和解决的问题
  • 是否掌握常用 Hooks 的使用方法
  • 是否理解 Hooks 的工作原理
  • 对函数式编程思想的理解

核心概念

什么是 Hooks

Hooks 是 React 16.8 引入的新特性,它允许你在不编写 class 的情况下使用 state 以及其他的 React 特性。

import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Hooks 解决的问题

在 Hooks 出现之前,React 存在以下问题:

  1. 难以复用状态逻辑:高阶组件嵌套地狱,render props 代码冗余
  2. 复杂组件难以理解:生命周期函数中混杂不相关逻辑,相关逻辑分散在不同生命周期
  3. this 指向问题:需要绑定 this,增加学习成本
  4. 函数组件无状态:简单组件也需要写成 class

详细解答

1. useState - 状态管理

import React, { useState } from 'react';

function Counter() {
  // 声明一个叫 "count" 的 state 变量
  const [count, setCount] = useState(0);

  // 使用对象作为 state
  const [user, setUser] = useState({ name: '', age: 0 });

  const updateName = (newName) => {
    // 对象需要展开合并
    setUser({ ...user, name: newName });
  };

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

      <p>User: {user.name}</p>
      <input
        value={user.name}
        onChange={(e) => updateName(e.target.value)}
      />
    </div>
  );
}

2. useEffect - 副作用处理

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // 相当于 componentDidMount + componentDidUpdate
  useEffect(() => {
    document.title = `Count: ${count}`;
  });

  // 只在 count 变化时执行
  useEffect(() => {
    console.log('Count changed:', count);
  }, [count]);

  // 只在挂载时执行(空依赖数组)
  useEffect(() => {
    console.log('Component mounted');

    // 返回清理函数,相当于 componentWillUnmount
    return () => {
      console.log('Component will unmount');
    };
  }, []);

  // 多个 effect,分离关注点
  useEffect(() => {
    // 订阅操作
    const subscription = subscribeToFriendStatus(id, handleStatusChange);

    return () => {
      // 取消订阅
      subscription.unsubscribe();
    };
  }, [id]);

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

3. useContext - 上下文消费

import React, { useContext } from 'react';

const ThemeContext = React.createContext('light');

function ThemedButton() {
  // 使用 useContext 替代 Consumer
  const theme = useContext(ThemeContext);

  return (
    <button style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
      I am styled by theme context!
    </button>
  );
}

// 对比 class 组件的写法
class ThemedButtonClass extends React.Component {
  static contextType = ThemeContext;

  render() {
    return (
      <button style={{ background: this.context === 'dark' ? '#333' : '#fff' }}>
        I am styled!
      </button>
    );
  }
}

4. useRef - 引用

import React, { useRef, useEffect } from 'react';

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const timerRef = useRef(null);

  const onButtonClick = () => {
    // 访问 DOM 节点
    inputEl.current.focus();
  };

  useEffect(() => {
    // 保存任意可变值
    timerRef.current = setInterval(() => {
      console.log('Tick');
    }, 1000);

    return () => {
      clearInterval(timerRef.current);
    };
  }, []);

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

5. useReducer - 复杂状态逻辑

import React, { useReducer } from 'react';

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}

6. useMemo 和 useCallback - 性能优化

import React, { useMemo, useCallback } from 'react';

function ParentComponent({ data, onUpdate }) {
  // 缓存计算结果
  const processedData = useMemo(() => {
    console.log('Computing...');
    return data.map(item => item * 2);
  }, [data]);

  // 缓存回调函数
  const handleClick = useCallback(() => {
    onUpdate(processedData);
  }, [onUpdate, processedData]);

  return (
    <div>
      <ChildComponent data={processedData} onClick={handleClick} />
    </div>
  );
}

7. 自定义 Hooks

// useLocalStorage.js
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.log(error);
    }
  };

  return [storedValue, setValue];
}

// 使用
function Example() {
  const [name, setName] = useLocalStorage('name', 'Bob');

  return (
    <input
      type="text"
      value={name}
      onChange={(e) => setName(e.target.value)}
    />
  );
}

深入理解

Hooks 的工作机制

React 在渲染函数组件时,按调用顺序访问当前 Fiber 上的 Hook 记录。以 React 18/19 的状态 Hook 实现理解,记录通过链表关联,useState / useReducer 还有更新队列;不是整个应用共享一个状态数组。常见“数组 + 游标”只用于解释顺序匹配,既不支持真实的多组件并发,也不能直接当成 React 源码。

函数组件 Fiber.memoizedState
  -> 第一个状态 Hook -> 第二个状态 Hook -> 后续 Hook
            ↓                  ↓
         更新队列            更新队列

每次调用自定义 Hook 都把它内部的状态记录接到调用组件上,因此两次 useCounter() 默认不会共享计数。条件分支改变状态 Hook 的调用顺序,会让上次的状态与本次调用错配。组件状态的保存位置还受组件类型、树位置与 key 影响,详见 React Keys。

Diff 复杂度与 Hook 更新不是同一层

React 常说的 O(n) Diff,针对基于元素类型、同层位置和 key 的启发式协调,不是任意两棵树的最优编辑距离;它也不包含组件自身昂贵计算、样式布局和全部提交成本。辅助索引等空间最坏可到 O(n),不能从一次 state 更新推断只处理一个节点。

Vue 3 keyed children 的常见实现先同步公共前后缀,再为中间乱序段建立 key 索引,并用最长递增子序列减少 DOM 移动。设该段长度为 m,采用二分维护 LIS 时这一部分最坏为 O(m log m),相关数组和映射为 O(m);已排序、只追加等路径可能不需要完整 LIS。因而不能将 Vue/React 的所有更新路径统一写为“时间空间均 O(n)”,DOM 创建/移动次数和组件执行成本还应分别观察。

自定义 Hook:依赖、更新和资源清理

把代码提取成 useXxx 只是第一步,面试更应说明参数变化、卸载和重挂载时的契约。

标题跟随参数变化

import { useEffect } from 'react';

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

[] 只会捕获首次的标题,参数变化时必须重新同步。这是单一页面所有者的最小实现;多个组件竞争标题、路由回退恢复、SSR 首屏标题,应交由路由/框架的元数据管理。Effect 不在服务端执行,不能用它生成服务端 HTML 的 <title>。

显式刷新不能依赖毫秒时间戳

import { useReducer } from 'react';

function useUpdate() {
  const [, update] = useReducer(version => version + 1, 0);
  return update;
}

setFlag(Date.now()) 在同一毫秒内可能写入相同值而跳过更新;Reducer 根据前值递增,没有这个碰撞问题,返回的 dispatch 引用也稳定。连续 dispatch 仍可能被 React 批处理成一次提交,它不是“强制每调用一次就立即画一帧”。该 Hook 只适合作为旧命令式接口的桥接;业务可见数据应进入 state,外部 store 订阅优先采用 useSyncExternalStore,不要靠刷新掩盖直接修改数据的问题。

滚动订阅:清理建立订阅时的那个节点

import { useCallback, useEffect, useState } from 'react';

function useScroll() {
  const [element, setElement] = useState(null);
  const [position, setPosition] = useState([0, 0]);
  // 回调 ref 会把节点替换变成响应式输入;ref.current 本身不是依赖通知。
  const scrollRef = useCallback(node => { setElement(node); }, []);

  useEffect(() => {
    if (element === null) return;
    const readPosition = () => {
      const next = [element.scrollLeft, element.scrollTop];
      setPosition(previous => previous[0] === next[0] && previous[1] === next[1]
        ? previous
        : next);
    };
    readPosition();
    element.addEventListener('scroll', readPosition, { passive: true });
    return () => {
      element.removeEventListener('scroll', readPosition);
    };
  }, [element]);

  return { scrollRef, position: element === null ? [0, 0] : position };
}
function ScrollPanel() {
  const { scrollRef, position: [x, y] } = useScroll();
  return (
    <section>
      <div ref={scrollRef} style={{ maxHeight: 200, overflow: 'auto' }}>
        <div style={{ height: 1000 }}>滚动内容</div>
      </div>
      <output>x: {x}, y: {y}</output>
    </section>
  );
}

清理函数捕获 element 和同一个监听函数,避免卸载时读取已经变成 null 或新节点的 ref.current。节点替换会先清理旧订阅,再读取新节点初始位置;普通 Effect 的同步发生在提交之后,并不保证节点更换当帧就完成测量。开发 Strict Mode 的额外 setup/cleanup 用来检查释放是否对称,应保证重复订阅后只保留一份监听。

这个例子按 scroll 事件更新位置,passive 不会自动节流。高频渲染有成本时按需求做节流/帧合并,并同时取消尾部定时器或 rAF;完整滚动性能策略复用 渲染与运行时优化。

防抖与节流:最新回调、尾调用和卸载

防抖等待最后一次调用后的静默窗口;节流限制调用频率。下面约定 debounce 只有尾调用,throttle 首次立即执行、窗口内最多补一次最新参数的尾调用。计时器晚于截止时间执行很常见,不能把延时当作精确时钟。

先把计时逻辑独立出来,方便验证快速连续调用、取消和异常后再次使用:

function createRateLimiter(callback, delay, mode) {
  if (!Number.isFinite(delay) || delay < 0) throw new RangeError('invalid delay')
  if (mode !== 'debounce' && mode !== 'throttle') throw new TypeError('invalid mode')
  let timer = null
  let lastInvoke = null
  let pending = null

  function invoke() {
    timer = null
    const call = pending
    pending = null
    lastInvoke = performance.now()
    return callback.apply(call.context, call.args)
  }

  function limited(...args) {
    pending = { context: this, args }
    if (mode === 'debounce') {
      clearTimeout(timer)
      timer = setTimeout(invoke, delay)
      return
    }
    const remaining = lastInvoke === null ? 0 : delay - (performance.now() - lastInvoke)
    if (remaining <= 0) {
      clearTimeout(timer)
      return invoke()
    }
    if (timer === null) timer = setTimeout(invoke, remaining)
  }

  limited.cancel = () => {
    clearTimeout(timer)
    timer = null
    pending = null
    lastInvoke = null
  }
  return limited
}

再在客户端 Hook 中管理引用和生命周期:

import { useLayoutEffect, useMemo, useRef } from 'react';

function useRateLimitedCallback(callback, delay, mode = 'debounce') {
  const callbackRef = useRef(callback);
  useLayoutEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const limited = useMemo(() => createRateLimiter(function (...args) {
    return callbackRef.current.apply(this, args);
  }, delay, mode), [delay, mode]);

  useLayoutEffect(() => () => limited.cancel(), [limited]);
  return limited;
}

// 在组件顶层调用:只传递需要保留的数据,不延后读取事件对象。
// const search = useRateLimitedCallback(query => submit(query), 300);
// <input onChange={event => search(event.currentTarget.value)} />
// const report = useRateLimitedCallback(position => track(position), 100, 'throttle');

这里在提交阶段更新 callbackRef,避免并发渲染尚未提交的回调被旧计时器观察到。回调变化保留待执行任务,但任务使用最新已提交的回调;delay/mode 变化和卸载取消旧任务,Strict Mode 的额外清理也不留下计时器。配置不变时返回函数引用稳定;取消已发出的网络请求仍需单独使用 AbortController,取消计时器不会撤销已经执行的副作用。

这是客户端交互 Hook,SSR 首屏应输出不依赖测量的内容。同步首调用的返回值可以直接取得,延后的尾调用没有可同步返回的结果;异步回调的拒绝需要在回调内显式处理,不能把包装函数当作“每次调用都返回 Promise”的 API。函数节流也不能替代请求层的并发限制,后者复用 请求队列。

React 19 的 useDeferredValue 初始值

useDeferredValue(value, initialValue) 在首次渲染时可以先返回 initialValue,再安排使用当前 value 的后台渲染;省略它则首次直接返回 value。在后续紧急更新中先保留旧的 deferred value,后台再尝试新值,更新被再次打断时可以丢弃中间渲染。若更新本身已在 Transition 中,它不再安排一次重复的延后渲染。

import { useDeferredValue } from 'react';

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query, '');
  return <ResultList query={deferredQuery} />;
}

这里的初始空字符串必须是 ResultList 支持的有效输入;对象初始值应保持稳定引用。它没有固定等待时间,不会自动减少网络请求次数,也不会取消过期请求,因此不能代替防抖或 AbortController。是否能改善交互还取决于昂贵子树是否能跳过紧急渲染以及 Suspense/数据缓存的设计。

Hooks 规则

  1. 只在最顶层使用 Hooks:不要在循环、条件或嵌套函数中调用
  2. 只在 React 函数中调用 Hooks:函数组件或自定义 Hooks

上面针对 useState、useEffect 等普通 Hooks。React 19 的 use(resource) 允许在循环和条件中调用,但仍必须位于组件/Hook 中,不能包在 try/catch 里;它不是状态 Hook 顺序规则的任意例外。

// 错误示例
function BadExample() {
  if (condition) {
    // 在条件中调用 Hook 是错误的
    const [state, setState] = useState(0);
  }

  for (let i = 0; i < 10; i++) {
    // 在循环中调用 Hook 是错误的
    const [state, setState] = useState(0);
  }
}

// 正确示例
function GoodExample() {
  const [state, setState] = useState(0);

  useEffect(() => {
    if (condition) {
      setState(1);
    }
  }, [condition]);
}

最佳实践

  1. 使用 ESLint 插件:eslint-plugin-react-hooks 自动检查规则
  2. 合理拆分 Hooks:将相关逻辑封装到自定义 Hooks
  3. 注意依赖数组:确保 effect 的依赖项完整
  4. 避免过度优化:不是所有情况都需要 useMemo/useCallback
// 依赖项完整示例
useEffect(() => {
  fetchData(id);
}, [id]); // 确保包含所有依赖

// 使用函数式更新避免依赖
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // 不依赖外部 count
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖数组是安全的

面试要点

  1. Hooks 解决的问题:复用逻辑、分离关注点、消除 this(知识点:Hooks 解决的问题)
  2. 常用 Hooks:useState、useEffect、useContext、useRef、useReducer、useMemo、useCallback(知识点:详细解答)
  3. Hooks 规则:只在顶层调用、只在 React 函数中调用(知识点:Hooks 规则)
  4. 自定义 Hooks:以 use 开头,封装可复用逻辑(知识点:自定义 Hooks)
  5. 与 Class 组件对比:更简洁、更符合函数式编程思想(知识点:Hooks 解决的问题)

常见追问:

资料来源与补充边界

来源:FE-006《React进阶&工程化面试全解.pdf》第 5–7 页。补充 useTitle、useUpdate、useScroll 的依赖与清理,修正空依赖、时间戳碰撞和清理读取可变 ref 的问题;常用 Hook API 与逻辑复用动机复用本篇前半部分。HOC 对比统一见 HOC 选型。

参考:复用自定义 Hook、useEffect、use。