重新认识 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 的依赖数组

缓存本身有成本

这是很多人忽视的一点。useMemouseCallback 本身并不是免费的------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-hooksexhaustive-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 是一个纯粹的函数,可以独立测试、自由组合。这是函数式编程中"组合优于继承"原则的直接体现。

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

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

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

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"的规则
  • 渲染即快照的模型换来了可预测性,代价是需要小心处理闭包陷阱
  • 声明式副作用换来了逻辑内聚,代价是依赖数组需要仔细维护

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

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

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

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


参考资料

相关推荐
林太白2 小时前
ref和reactive对比终于学会了
前端
Apifox2 小时前
测试数据终于不用到处复制了,Apifox 自动化测试新增「共用测试数据」
前端·后端·测试
小小小小宇2 小时前
Mac龙虾保姆级完整部署指南
前端
睡不着的可乐2 小时前
vue2 和 vue3自定义指令有什么区别,都是怎么实现和使用一个指令
前端·vue.js
闲来没事抠鼻屎2 小时前
Web打印插件实战:轻量化JS打印方案vue-print-designer落地指南
前端
孙凯亮2 小时前
从 SSR 踩坑到 CSR 封神:Nuxt4 全流程终极实战
前端
想努力找到前端实习的呆呆鸟2 小时前
网易云桌面端--精选歌单布局思路记录
前端·javascript·vue.js
Flywith242 小时前
【每日一技】Raycast 实现 scrcpy 的快捷显示隐藏
android·前端
薛端阳3 小时前
OpenClaw的架构优化思路杂想
前端