重新认识 React Hooks:从会用到理解设计

作为一名用了几年 React 的前端开发者,我曾经很长一段时间都处于"会用 Hooks"的状态------useState 管状态、useEffect 处理副作用、useMemo 做性能优化,一套流程下来感觉没什么问题。

但如果追问自己,"为什么 Hooks 不能写在条件语句里?",我才意识到自己对 Hooks 的理解停留在 API 层面,对它背后的设计逻辑知之甚少。

这篇文章是我重新梳理 React Hooks 的学习笔记。目标不是"教你用",而是试图回答:Hooks 为什么是这个样子的,它背后在解决什么问题,又体现了什么设计思想。


一、Hooks 诞生的背景:Class 组件的三个痛点

在 React 16.8 引入 Hooks 之前,Class 组件是编写有状态组件的唯一方式。Class 组件本身没什么大问题,但随着应用规模变大,三个痛点变得越来越明显。

痛点一:逻辑复用困难

假设你有一段"监听窗口尺寸"的逻辑,需要在多个组件里复用。Class 时代的方案通常是两种:

高阶组件(HOC) :

javascript 复制代码
// 环境:React(Class 组件时代)
// 场景:通过 HOC 复用窗口尺寸逻辑

function withWindowSize(WrappedComponent) {
  return class extends React.Component {
    state = { width: window.innerWidth, height: window.innerHeight };

    handleResize = () => {
      this.setState({ width: window.innerWidth, height: window.innerHeight });
    };

    componentDidMount() {
      window.addEventListener('resize', this.handleResize);
    }

    componentWillUnmount() {
      window.removeEventListener('resize', this.handleResize);
    }

    render() {
      return <WrappedComponent windowSize={this.state} {...this.props} />;
    }
  };
}

或者 Render Props:

javascript 复制代码
// 场景:通过 Render Props 复用逻辑
class WindowSize extends React.Component {
  state = { width: window.innerWidth, height: window.innerHeight };
  // ... 同上的监听逻辑

  render() {
    return this.props.children(this.state);
  }
}

// 使用时:
<WindowSize>
  {({ width, height }) => <div>{width} x {height}</div>}
</WindowSize>

两种方案各有问题:HOC 会产生"包装地狱",多个 HOC 嵌套后 props 来源变得不清晰;Render Props 则让 JSX 结构变得冗余。而用 Hooks,这个逻辑只需要:

javascript 复制代码
// 环境:React 16.8+
// 场景:自定义 Hook 复用窗口尺寸逻辑

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 任意组件中使用,逻辑来源清晰
function MyComponent() {
  const { width, height } = useWindowSize();
  return <div>{width} x {height}</div>;
}

代码量差不多,但清晰度和可组合性完全不同。

痛点二:生命周期割裂相关逻辑

Class 组件的生命周期是按"时机 "组织的,而不是按"逻辑 "组织的。这导致同一段业务逻辑往往被拆散到不同的 生命周期方法 里:

javascript 复制代码
// 一个 Class 组件中,订阅和清理逻辑被迫分离
componentDidMount() {
  // 初始化:订阅数据源 A
  DataSourceA.subscribe(this.handleChange);
  // 初始化:订阅数据源 B(完全不相关的逻辑混在一起)
  DataSourceB.subscribe(this.handleOtherChange);
}

componentWillUnmount() {
  // 清理逻辑分散在这里,需要和上面对应着看
  DataSourceA.unsubscribe(this.handleChange);
  DataSourceB.unsubscribe(this.handleOtherChange);
}

而 useEffect 把"建立"和"清理"放在同一个地方,相关逻辑内聚在一起:

javascript 复制代码
// 每段逻辑自成一体,不需要跨方法对应
useEffect(() => {
  DataSourceA.subscribe(handleChange);
  return () => DataSourceA.unsubscribe(handleChange); // 清理在同一处
}, []);

useEffect(() => {
  DataSourceB.subscribe(handleOtherChange);
  return () => DataSourceB.unsubscribe(handleOtherChange);
}, []);

痛点三:this 的心智负担

Class 组件中 this 的指向问题是很多 React 初学者的噩梦。事件处理函数需要手动绑定、或者使用箭头函数属性,这是语言层面的摩擦,和 UI 逻辑本身无关。

Hooks 把这些摩擦从根源上消除了------函数组件里没有 this。


二、Hooks 能工作的秘密:链表与调用顺序

理解了"为什么需要 Hooks"之后,一个自然的问题是:函数组件每次渲染都会重新执行,React 怎么知道哪个 useState 对应哪个状态?

React 用链表记住 Hooks 的顺序

每个 React 组件对应一个 Fiber 节点。Fiber 节点上有一个 memoizedState 字段,它指向一条链表,每个节点存储着一个 Hook 的状态。

css 复制代码
Fiber 节点
└── memoizedState
    └── Hook[0]: { state: count, next → }
        └── Hook[1]: { state: name, next → }
            └── Hook[2]: { effect: ..., next → null }

关键点在于:React 靠调用顺序来对应每个 Hook。 第一次调用 useState 对应链表第一个节点,第二次对应第二个......以此类推。

这就是为什么 Hooks 必须在顶层调用,不能写在条件语句、循环或嵌套函数里:

javascript 复制代码
// ❌ 错误:条件语句打乱了 Hook 的调用顺序
function BadComponent({ showName }) {
  const [count, setCount] = useState(0); // Hook[0]

  if (showName) {
    const [name, setName] = useState(''); // 有时是 Hook[1],有时不存在
  }

  const [age, setAge] = useState(0); // 有时是 Hook[1],有时是 Hook[2]
  // React 拿错链表节点,状态全乱了
}

结论很简单:

Hooks 的调用顺序就是它的"地址",顺序变了,React 就找错门了。


三、五个核心 Hook 的深度拆解

3.1 useState:状态更新的真相

useState 看起来最简单,但它有几个细节值得深究。

传值 vs 传函数

setState 接受两种形式:直接传新值,或传一个接收旧值的函数。

javascript 复制代码
// 环境:React
// 场景:理解 setState 传函数的必要性

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

  // ❌ 在某些场景下有问题
  const handleClickBad = () => {
    setCount(count + 1); // 闭包捕获的 count 可能是旧值
    setCount(count + 1); // 两次调用,实际只加了 1
  };

  // ✅ 传函数,基于最新状态计算
  const handleClickGood = () => {
    setCount(prev => prev + 1); // 基于最新值 +1
    setCount(prev => prev + 1); // 再基于最新值 +1,共加了 2
  };

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

原因在于:React 在处理事件时会批量更新(batching),多次 setState 不会立即触发重渲染。如果传的是值,多次调用都在引用同一个闭包里的旧 count;如果传的是函数,React 会把函数排队,依次传入最新状态执行。

React 18 的批量更新

在 React 18 之前,批量更新只在 React 合成事件中生效;在 setTimeout、Promise 回调里的 setState 是同步触发渲染的。React 18 引入了"自动批量更新"(Automatic Batching),这些场景也会被批量处理。

javascript 复制代码
// 环境:React 18
// 场景:展示 Automatic Batching 的效果

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

  const handleClick = () => {
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React 18:只触发一次重渲染
      // React 17 及之前:触发两次重渲染
    }, 100);
  };

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

初始化惰性求值

如果初始状态需要复杂计算,可以传入函数,React 只在首次渲染时调用它:

javascript 复制代码
// ✅ 惰性初始化:computeExpensiveValue 只执行一次
const [state, setState] = useState(() => computeExpensiveValue(props));

// ❌ 每次渲染都会执行
const [state, setState] = useState(computeExpensiveValue(props));

3.2 useEffect:副作用不等于生命周期

useEffect 是 Hooks 里最容易被误用的一个。很多人把它当作 componentDidMount + componentDidUpdate + componentWillUnmount 的合体,这个理解不完全准确。

依赖数组比较的是什么?

React 用 Object.is 对依赖数组里的每一项做浅比较。这意味着:

javascript 复制代码
// 环境:React
// 场景:理解依赖比较的陷阱

function Component({ options }) {
  useEffect(() => {
    // 问题:每次渲染 options 都是新对象引用,即使内容没变
    fetchData(options);
  }, [options]); // 父组件每次渲染都传入 { page: 1 },但引用不同,Effect 会不停触发
}

// 父组件
function Parent() {
  return <Component options={{ page: 1 }} />; // 每次渲染都是新对象
}

对于对象和数组类型的依赖,需要特别注意:要么用 useMemo 稳定引用,要么把对象解构成基本类型后放入依赖。

和 componentDidMount 的微妙差异

useEffect 的执行时机是渲染提交到 DOM 之后、浏览器完成绘制之后 ,是异步执行的。而 componentDidMount 在 DOM 更新后同步执行。如果需要同步读取 DOM 布局(比如测量元素尺寸后立即更新 UI),应该用 useLayoutEffect:

javascript 复制代码
// useLayoutEffect:DOM 更新后同步执行,防止闪烁
useLayoutEffect(() => {
  const height = ref.current.getBoundingClientRect().height;
  setHeight(height); // 这次状态更新会和 DOM 操作合并,用户不会看到中间状态
}, []);

异步请求的内存泄漏问题

这是实际开发中很常见的问题:

javascript 复制代码
// 环境:React
// 场景:组件卸载后异步请求仍在执行,尝试更新已卸载的组件

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false; // 用一个标志位标记是否已取消

    fetchUser(userId).then(data => {
      if (!cancelled) {
        setUser(data); // 只有未取消时才更新状态
      }
    });

    return () => {
      cancelled = true; // 清理时标记为已取消
    };
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

更现代的方案是使用 AbortController:

javascript 复制代码
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error(err);
      }
    });

  return () => controller.abort(); // 组件卸载时中止请求
}, [userId]);

3.3 useMemo & useCallback:缓存的代价

这两个 Hook 经常被一起提,但它们的本质略有不同。

本质区别

javascript 复制代码
// useMemo:缓存计算结果(一个值)
const sortedList = useMemo(() => {
  return items.sort((a, b) => a.value - b.value);
}, [items]);

// useCallback:缓存函数引用(函数也是一种值,但强调的是引用稳定性)
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

// useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

useCallback 的核心价值在于稳定函数引用,主要用于两个场景:

  1. 函数作为 props 传给用 React.memo 包裹的子组件
  2. 函数被放入 useEffect 的依赖数组

缓存本身有成本

这是很多人忽视的一点。useMemo 和 useCallback 本身并不是免费的------React 需要:

  • 存储依赖数组的前一个值
  • 每次渲染时对比新旧依赖
  • 在内存中保留缓存的值

对于轻量的计算,这个开销可能比重新计算更大。一个粗略的判断原则:

javascript 复制代码
// ❌ 没必要:计算本身很轻量
const doubled = useMemo(() => count * 2, [count]);

// ✅ 有价值:耗时的计算,或需要稳定引用传给子组件
const processedData = useMemo(() => {
  return largeDataSet.filter(...).map(...).reduce(...);
}, [largeDataSet]);

React 官方的建议是:先写正确的代码,再根据实际性能问题决定是否优化 ,而不是预防性地给所有函数和值套上 useMemo / useCallback。

缓存失效的时机

依赖数组中任何一项(通过 Object.is 比较)发生变化时,缓存就会失效。另外,React 在某些情况下(如开发模式的 Strict Mode、内存压力)可能主动丢弃缓存,因此 useMemo 的缓存只能用于性能优化,不能作为语义保证。


3.4 useRef:被低估的 Hook

useRef 在很多教程里被简单介绍为"获取 DOM 节点的方式",但它的能力远不止于此。

Ref 的本质:一个稳定的可变容器

javascript 复制代码
// useRef 返回一个在组件整个生命周期内保持同一引用的对象
const ref = useRef(initialValue);
// ref 始终是 { current: ... } 这个对象
// 修改 ref.current 不会触发重渲染

这个特性让 useRef 成了一个"逃生舱"------当你需要在不触发渲染的情况下存储某个值时,就用它。

保存计时器 ID

javascript 复制代码
// 环境:React
// 场景:在多次渲染间保持 timer 引用,用于清除

function Stopwatch() {
  const [time, setTime] = useState(0);
  const timerRef = useRef(null); // 不需要触发渲染,不用 useState

  const start = () => {
    timerRef.current = setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(timerRef.current);
  };

  return (
    <div>
      <span>{time}s</span>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </div>
  );
}

解决闭包陷阱:保存最新值

这是 useRef 最重要的一个高级用法,和下一节的"闭包陷阱"紧密相关:

javascript 复制代码
// 环境:React
// 场景:在定时器回调中始终读到最新的 state 值

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

  // 每次 count 更新时,同步更新 ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      // countRef.current 始终是最新值,不受闭包影响
      console.log('current count:', countRef.current);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖数组为空,effect 只执行一次

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

四、最容易掉进去的坑:闭包陷阱(Stale Closure)

闭包陷阱可能是使用 Hooks 过程中最隐蔽的 Bug 来源。

什么是闭包陷阱?

先看一个最小复现:

javascript 复制代码
// 环境:React
// 场景:经典的闭包陷阱示例

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

  useEffect(() => {
    const timer = setInterval(() => {
      // 这里的 count 永远是 0
      // 因为 effect 只在挂载时执行一次,count 被"封印"在了那一刻的闭包里
      console.log('count is:', count); // 始终打印 0
      setCount(count + 1); // 所以始终是 0 + 1 = 1
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 空依赖数组:effect 不会随 count 更新而重新执行

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

问题的根源 :useEffect 的回调函数在执行时,捕获的是创建那一刻 的变量快照。空依赖数组意味着 effect 只在挂载时创建一次,之后 count 每次更新,effect 里的闭包引用的还是最初那个 0。

为什么 Hooks 特别容易产生这个问题?

Class 组件不容易出现这个问题,因为 this.state.count 是通过 this 动态查找的,总是指向最新值。而函数组件里,每次渲染产生一个新的函数作用域,变量的值是那次渲染的快照------这是"渲染即快照"的设计哲学,通常是优点,但在异步场景下会成为陷阱。

三种解法

方案一:把依赖项补全(最常见)

javascript 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 告诉 React:count 变了就重新创建 effect
// 副作用:每次 count 变化都会重新创建 setInterval,可接受

方案二:用函数式更新避免读取旧值

javascript 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1); // 不需要读 count,只需要在旧值上 +1
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组可以为空,因为我们不依赖外部的 count 值

这是这个场景下最优雅的解法:利用函数式更新的特性,完全绕开了闭包捕获的问题。

方案三:用 useRef 保存最新值

javascript 复制代码
// 适合需要在回调里读取多个最新状态,或状态逻辑更复杂的场景
function useLatest(value) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count); // 始终指向最新值的 ref

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(countRef.current + 1); // 安全地读取最新值
    }, 1000);
    return () => clearInterval(timer);
  }, []);
}

如何从根源上避免?

一个有效的心智模型是:写 useEffect 时,先不填依赖数组,把所有回调里用到的外部变量都写进去。如果依赖数组里的东西太多或变化太频繁,再考虑用函数式更新或 useRef 来精简。

React 官方的 ESLint 插件 eslint-plugin-react-hooks 的 exhaustive-deps 规则可以自动检测遗漏的依赖,强烈建议开启。


五、设计思想的延伸:Hooks 与函数式编程

当我试着从更高的视角看 Hooks,发现它和函数式编程领域的一些概念有着深刻的共鸣。

"渲染即快照":拥抱不可变性

函数式编程的核心思想之一是不可变数据 :不修改现有的值,而是创建新的值。React 的每次渲染,本质上是用当前的 props 和 state 生成一个 UI 的快照。useState 的更新不会"修改"旧状态,而是产生一个新状态,触发新一轮渲染。

这种设计让每一次渲染都是一个纯函数式的映射:UI = f(state)。

副作用管理:Effect 作为"声明"

函数式编程里,纯函数不产生副作用。但真实应用里不可能没有副作用(网络请求、DOM 操作、定时器......)。

useEffect 的设计哲学是:把副作用从渲染逻辑里分离出来,以声明的方式描述"这个 effect 依赖哪些状态,应该在什么时候运行" ,而不是命令式地说"在第 3 步运行这段代码"。

这和函数式编程里用 Monad 把副作用"隔离"到类型系统边界的思想,有异曲同工之妙------当然,React 的实现要工程化得多。

代数效应:一个更底层的视角

这是一个稍微抽象的概念,但理解它能让你对 Hooks 的设计有更深的感受。

代数效应(Algebraic Effects) 是函数式编程里的一种错误处理和副作用管理机制(目前在一些学术语言如 Koka、Eff 中实现,主流语言尚未支持)。它的核心想法是:

函数可以"发出"一个效应(effect),调用方决定如何处理这个效应,函数本身不需要知道处理细节。

用一个伪代码来理解:

javascript 复制代码
// 伪代码,非真实语法
// 场景:理解代数效应的思想

function getName() {
  // 发出一个"读取用户名"的效应,不关心怎么读
  const name = perform ReadUserName;
  return `Hello, ${name}`;
}

// 调用方决定如何处理这个效应
handle getName() {
  on ReadUserName -> resume('Alice'); // 用 'Alice' 处理,继续执行
}

Hooks 的 useState 可以被理解为一种类似的机制:函数组件"调用" useState 相当于发出一个"我需要管理这个状态"的效应,React 运行时(调用方)处理这个效应,并提供读写状态的能力------函数组件本身不需要知道状态存在哪里、怎么触发重渲染。

Dan Abramov(React 核心成员)在博客中明确提到,Hooks 的设计受到了代数效应思想的启发。当然,这是一种"精神上的借鉴",而非严格的学术实现。

自定义 Hook:组合优于继承

面向对象编程通过继承来复用逻辑,函数式编程通过函数组合。自定义 Hook 就是 React 版本的函数组合:

javascript 复制代码
// 环境:React
// 场景:通过组合自定义 Hook 构建更复杂的能力

// 原子级 Hook
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved !== null ? JSON.parse(saved) : initialValue;
  });

  const setStoredValue = useCallback((newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  }, [key]);

  return [value, setStoredValue];
}

// 组合出更具体的能力
function useTheme() {
  return useLocalStorage('theme', 'light'); // 组合,而非继承
}

function useLanguage() {
  return useLocalStorage('language', 'zh-CN');
}

每个 Hook 是一个纯粹的函数,可以独立测试、自由组合。这是函数式编程中"组合优于继承"原则的直接体现。

一个判断原则:先问结构,再问优化

最后分享一个我觉得很有价值的思考角度。当你发现需要用 useMemo 或 useCallback 来解决性能问题时,先停下来问一个问题:

"这个问题能不能通过调整组件结构来解决?"

javascript 复制代码
// ❌ 用 useMemo 解决昂贵计算的"常见做法"
function Parent({ data }) {
  const processed = useMemo(() => expensiveProcess(data), [data]);
  return (
    <div>
      <ExpensiveChild data={processed} />
      <SimpleChild />   {/* 这个组件因为 Parent 重渲染而跟着渲染 */}
    </div>
  );
}

// ✅ 先考虑:能不能把昂贵的部分单独提取成子组件?
function ProcessedChild({ data }) {
  const processed = useMemo(() => expensiveProcess(data), [data]);
  return <ExpensiveChild data={processed} />;
}

function Parent({ data }) {
  return (
    <div>
      <ProcessedChild data={data} />
      <SimpleChild />   {/* 现在 SimpleChild 不会受影响 */}
    </div>
  );
}

组件结构的调整往往比性能 API 的使用更根本,也更容易维护。


小结

回头看这些问题,React Hooks 表面上是一套 API,但深入进去,会发现它在工程层面做了很多取舍:

  • 用调用顺序换来了简洁的 API,代价是"不能在条件语句里调用 Hooks"的规则
  • 用渲染即快照的模型换来了可预测性,代价是需要小心处理闭包陷阱
  • 用声明式副作用换来了逻辑内聚,代价是依赖数组需要仔细维护

这种取舍本身就是软件设计的本质。

还有一些问题我还在探索中:

  • useTransition 和 useDeferredValue 是如何在 Hooks 模型下实现并发渲染的?
  • React Server Components 的出现对 Hooks 的使用边界有什么影响?
  • 代数效应如果真的进入主流 JavaScript,会怎样改变前端状态管理的方式?

如果你对某个部分有不同的理解,或者有什么补充,欢迎交流。


参考资料

相关推荐
jay神1 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神1 小时前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat1 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss2 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan2 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真2 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment2 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan3 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么3 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技3 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue