useState 完全解析:初始化、异步更新、以及为什么传函数能解决闭包问题

19 阅读7分钟

一个 useState,三个核心问题:

  • 初始值可以传什么?惰性初始化又是什么?
  • setState 为什么是"异步"的?React 为什么要这样做?
  • 为什么三次 setState(count + 1) 结果是 +1,而三次 setState(prev => prev + 1) 结果却是 +3?

这篇文章逐一拆解。


一、useState 的基本形式

const [state, setState] = useState(初始值);
//        ↑         ↑              ↑
//     状态变量   修改状态的方法    初始值

useState 返回一个数组:第一个元素是当前状态值,第二个是修改这个状态的方法。这两个东西是绑定在一起的——调用 setCount,React 就知道你要改 count;调用 setName,就知道要改 name


二、初始值可以是什么

2.1 任何 JS 类型都可以

useState(0);           // number
useState('');          // string
useState(null);        // null — 常用于"暂无数据"
useState([]);          // 空数组
useState({});          // 空对象
useState(true);        // boolean

基础类型、对象、数组、null、undefined——没有任何限制。

2.2 惰性初始化:传函数

有时初始值不是随手能写出来的——比如从 localStorage 读数据、计算一个 1000 条的用户列表、做一次复杂的数学运算。

function heavyComputation() {
    const result = [];
    for (let i = 0; i < 1000; i++) {
        result.push({ id: i, name: `用户-${i}` });
    }
    return result;
}

// ❌ 错误方式 — 每次渲染都执行 heavyComputation
const [users] = useState(heavyComputation());
// 组件每重新渲染一次,heavyComputation() 就跑一次,结果只在第一次被 React 使用,
// 后续渲染中计算结果直接丢弃。100 次渲染 = 100 次无用计算。

// ✅ 惰性初始化 — 只在挂载时执行一次
const [users] = useState(() => heavyComputation());
// React 在首次挂载时执行这个箭头函数,拿到返回值,存入 state。
// 后续重新渲染时,React 已经有初始值了,箭头函数不会被执行。

React 内部的简化逻辑:

function useState(initialValue) {
    if (!hasMounted) {
        // 如果是函数就执行拿结果,不是函数就直接用
        state = typeof initialValue === 'function'
            ? initialValue()
            : initialValue;
        hasMounted = true;
    }
    // 后续渲染:initialValue 完全被忽略
    return [state, setState];
}

惰性初始化 = "这个初始值算起来很贵,你只算一次就行。"


三、setState 的"异步"是什么意思

3.1 它不返回 Promise

const result = setCount(count + 1);
console.log(result);  // undefined

setState 不是 JavaScript 的 async 函数。说它"异步"是指:调用后状态值不会立即改变。

const handleClick = () => {
    console.log(count);       // 0
    setCount(count + 1);
    console.log(count);       // 还是 0,没变
};

3.2 React 为什么要这样做:批处理(Batching)

React 的目的是减少真实 DOM 操作的次数。如果每次 setState 都立即更新,就会:

setState → 更新 DOM → Layout → Paint → Composite
setState → 更新 DOM → Layout → Paint → Composite
setState → 更新 DOM → Layout → Paint → Composite
每调用一次,浏览器就要走一遍完整的渲染管线。

React 的做法是:把同一事件处理函数中的多次 setState 攒起来,等函数执行完毕,一次性计算新状态、一次性更新 DOM。

setState(1) ──┐
setState(2) ──┼── React:"不急,先攒着"
setState(3) ──┘
                 │
   函数执行完毕   ↓
                 │
   React:一次性计算 → 一次性更新 DOM → 浏览器只重排一次

这和我们用原生 document.createDocumentFragment() 批量插入 DOM 节点的思路完全一致——攒着,一起做,减少 Layout 和 Paint 的次数。


四、为什么三次 setState(count + 1) 结果是 +1

4.1 传值:值来自渲染快照

React 每次渲染都是一个独立的快照。在同一个快照中,state 是常量——它不会中途变化。

当前渲染快照中:count = 0(这个值在快照中是不会变的)

setState(count + 1);   // setState(0 + 1) = setState(1)
setState(count + 1);   // setState(0 + 1) = setState(1)
setState(count + 1);   // setState(0 + 1) = setState(1)
                        ↑ 三次的 count 都是 0

每次 count + 1 的结果都是 0 + 1 = 1。React 批处理合并这三次,最终 count 变成 1。

这不是 bug,是 React 有意为之——同一次渲染中,state 是常量。这保证了代码的可预测性:同一个快照里所有地方看到的 count 都一样。

4.2 这跟闭包有什么关系

先看一个最基础的闭包例子:

function createCounter() {
    let count = 0;                    // 外部函数的变量

    return function() {              // 内部函数
        count++;
        console.log(count);
    };
}

const counter = createCounter();    // createCounter 执行完了
counter();  // 1                    // 内部函数还是能访问 count
counter();  // 2                    // 它"记住"了 count
counter();  // 3

闭包 = 内部函数"抓"住了外部函数的变量。即使外部函数已经执行完毕,内部函数仍然可以访问这些变量。 这是 JavaScript 最基础也最优雅的特性之一。

现在看 React 组件:

function Counter() {                         // 外部函数 — 组件本身
    const [count, setCount] = useState(0);    // count 是 Counter 里的变量

    const handleClick = () => {              // 内部函数 — 闭包
        setCount(count + 1);                 // ← 这个 count 是从外面抓进来的
    };

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

handleClick 是一个闭包,它抓取了它出生那次渲染中的 count

关键来了:每次重新渲染,Counter() 都会完整地重新执行一次,产生一个全新的闭包

第 1 次渲染: Counter() → count = 0, handleClick 闭包记住 count = 0
第 2 次渲染: Counter() → count = 1, handleClick 闭包记住 count = 1
第 3 次渲染: Counter() → count = 2, handleClick 闭包记住 count = 2

现在回头看三次 setCount(count + 1)

const handleClick = () => {
    setCount(count + 1);  // count 来自闭包,在第 1 次渲染中是 0
    setCount(count + 1);  // 同一个闭包,count 还是 0
    setCount(count + 1);  // 还是 0
};

这三个 count 都是同一个闭包里的同一个变量,它在这个闭包生命周期里永远不会变。 这就是常说的"闭包陷阱"——但严格来说这不是陷阱,闭包本来就应该这样工作。闭包忠实地记住了它出生时看到的值,这正是闭包的设计意图。

所以问题不在于闭包,而在于 React 的渲染模型:每次渲染是一个独立快照,闭包抓住的是那个快照中的值。 你需要的不是"修复闭包",你需要的是"从闭包外面拿最新值"——这就是传函数的作用。

4.3 传函数:绕过闭包,让 React 给值

setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);

prev 不是从闭包抓来的,而是 React 在执行时通过函数参数实时传进来的。你传了一个函数(prev => prev + 1)给 React,React 在合适的时机调用它,并把最新状态值作为 prev 传进去。

// React 内部简化逻辑
function dispatch(action) {
    if (typeof action === 'function') {
        newState = action(currentState);  // 把最新 state 作为参数传给你的函数
    } else {
        newState = action;                // 直接用值
    }
}

React 维护一个更新队列,依次执行这些函数:

let state = 0;                           // 当前状态
const updaters = [
    prev => prev + 1,                    // 第一个更新函数
    prev => prev + 1,                    // 第二个
    prev => prev + 1,                    // 第三个
];

state = updaters[0](state);              // prev = 0 → 返回 1
state = updaters[1](state);              // prev = 1 → 返回 2
state = updaters[2](state);              // prev = 2 → 返回 3
// 最终 state = 3

传值 = 你从闭包快照里拿 count,算好结果告诉 React。传函数 = 你告诉 React"怎么算",React 把最新值递到你手上。 后者主动放弃了闭包里的旧值,把控制权交还给了 React。

4.4 直观对比

setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);

这里 prev 不是从渲染快照中抓取的,而是 React 在执行时通过参数传进来的——每次传的都是最新值。

React 内部维护一个更新队列,依次执行这些函数:

let state = 0;                           // 初始值
const updaters = [
    prev => prev + 1,                    // 你的第一个更新函数
    prev => prev + 1,                    // 第二个
    prev => prev + 1,                    // 第三个
];

state = updaters[0](state);              // prev = 0 → 返回 1
state = updaters[1](state);              // prev = 1 → 返回 2
state = updaters[2](state);              // prev = 2 → 返回 3
// 最终 state = 3

4.4 直观对比

传值 setState(count + 1):
  你从快照里拿 count(旧值),算好了结果告诉 React
  → 三次算出来都是同一个结果 → 被批处理合并

传函数 setState(prev => prev + 1):
  你告诉 React"怎么算",由 React 把最新值传给你
  → 三次执行,每次拿到上一步的结果 → 链式累加
传值传函数
值的来源当前渲染快照中的 state(旧值)React 在执行时传入的最新值
谁负责计算调用之前就计算好了React 调用时实时计算
多次调用的结果都拿到同一个值每次拿到上一步的结果

五、总结

知识点一句话
初始化useState(初始值),初始值可以是任何 JS 类型
惰性初始化useState(() => 昂贵计算) — 函数只在挂载时执行一次
setState 的"异步"调用后状态不立即改变,React 批处理合并更新
批处理的目的减少真实 DOM 更新次数,进而减少浏览器 Layout/Paint
传值 vs 传函数传值用的是快照中的旧值,传函数拿到的是 React 给的最新值
什么时候必须传函数当新状态依赖旧状态时(prev => prev + 1),避免批处理覆盖