ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

useState原理与React Hooks运行模型:从Fiber链表到闭包旧值

useState原理与React Hooks运行模型:从Fiber链表到闭包旧值 如果你也是 React 开发者大概率在面试或者和同事互看代码时被问过useState到底是怎么实现的为什么组件每次重新渲染上一次的 state 还能保留说实话我最早也是背答案什么 Fiber、Hook 链表、dispatch、lane背完转头就忘。真正开始被闭包旧值、批处理、setState 后读不到新值这些问题折磨时才明白背书没有用必须把 React Hooks 的运行模型串起来。这篇就围绕useState原理从“状态存在哪”讲到“setState 之后发生了什么”再到几个我真实踩过的坑。内容尽量讲得通俗且能落地适合两种人准备 React 面经的同学以及写过 Hooks 但被各种“意外表现”困扰的开发者。我不准备贴一堆大段源码而是用伪代码加实例拆解帮你建立从组件渲染到更新队列的完整认识。1. useState 的“记忆”到底存在哪里一条挂在 Fiber 上的链表1.1 组件重新执行时为什么只有 state 没被重置函数组件本质上就是一个普通函数每次 render 就是执行一次这个函数。如果没有 Hooks组件里的局部变量在函数执行完就销毁了下一次 render 从头开始。但useState的值却能在两次函数执行之间保留。这个“保留”不是魔法关键在于 React 内部有一个和组件对应的对象Fiber 节点。每个函数组件在 React 内部都会对应一个 Fiber 对象。这个 Fiber 上有一个字段memoizedState。如果你在组件里用了多个 Hooks那么这个字段保存的不是某一个状态而是一条链表的头节点。链表的每个节点对应一个 HookuseState的当前值就存在节点的memoizedState属性上。// 伪代码React 内部 Hook 节点的大致形态 type Hook { memoizedState: any, // 当前 state 值也可能是 effect 的依赖等 baseState: any, // 计算更新时使用的基础 state baseQueue: any, // 尚未执行的更新队列 queue: any, // 当前 hook 的队列对象 next: null | Hook, // 下一个 Hook 节点 };所以就算组件函数重新执行只要 Fiber 还在Hooks 链表上的历史值就不会丢。你调用const [count, setCount] useState(0)第一次执行时 React 为这个 hook 创建了一个节点把 0 放进memoizedState下一次执行时它不是重新创建节点而是找到上一次那个 hook 节点并把旧值取出来返回。一个有多个 Hooks 的组件Fiber 上的结构大概是这样的const [name, setName] useState() const [age, setAge] useState(0) useEffect(() {})这三个 Hook 会依次串成一条单链表挂到fiber.memoizedState上fiber.memoizedState - hook1(name) - hook2(age) - hook3(effect)这也是为什么useState名字里有“state”但它的底层实现更像一张按顺序排好的表每个 Hook 的位置固定React 靠顺序找到对应状态。1.2 mount 与 update两套并不对称的函数React 内部在渲染 Hooks 时会根据是首次渲染还是更新渲染走两套不同的函数首次调用useState走mountState后续更新走updateState。这就是为什么useState(initialState)里的初始值只在第一次渲染时有效之后无论传什么都会被忽略。// 伪代码mountState 简化版 function mountState(initialState) { const hook mountWorkInProgressHook(); if (typeof initialState function) { initialState initialState(); } hook.memoizedState hook.baseState initialState; const queue { pending: null, dispatch: null, lastRenderedState: initialState, }; hook.queue queue; const dispatch dispatchSetState.bind(null, currentlyRenderingFiber, queue); queue.dispatch dispatch; return [hook.memoizedState, dispatch]; }而updateState主要工作是拿到当前这个 hook 的 queue处理排队中的更新然后计算最新值。initialState在更新阶段根本不参与。因此如果你在子组件里第二次传一个全新的初始值组件也不会被重置。想要重置 state常规方案是改key让组件整体重新挂载。这里有一个值得提的性能点useState(expensive())这样的写法有问题。因为expensive()是一个表达式在组件函数执行的瞬间就会被求值哪怕这个值在更新阶段根本不会使用白白浪费一次计算。正确写法是useState(() expensive())把惰性初始化函数传给 useState这样初始化函数只会在 mount 阶段被调用一次。1.3 mountWorkInProgressHook 与链表的构建顺序首次渲染时React 会通过mountWorkInProgressHook创建 Hook 节点。第一个 Hook 挂到当前正在渲染的 Fiber 的memoizedState上后面的 Hook 用next指针接在前一个后面// 伪代码mountWorkInProgressHook function mountWorkInProgressHook() { const hook { memoizedState: null, baseState: null, baseQueue: null, queue: null, next: null, }; if (workInProgressHook null) { currentlyRenderingFiber.memoizedState workInProgressHook hook; } else { workInProgressHook workInProgressHook.next hook; } return workInProgressHook; }这段代码的价值在于它清楚地说明Hooks 的链表结构是在组件函数执行过程中“一个个接上去”的。如果组件里某个 Hook 因为条件判断没有执行链表就会少一个节点后续状态全部错位。这个问题我在第 4 章详细讲但你现在可以先记住一个结论React 没有任何机制能按名字或 key 去识别 Hook它只认调用顺序这一条铁律。2. 一次 setCount 过后Render 之前发生了什么2.1 dispatch 并不是“直接改 state”很多新手会以为setCount(1)就是把当前组件里count变量改成 1。其实setCount的底层是dispatchSetState它做的事根本不是改memoizedState而是把一次更新“登记”进队里。// 伪代码dispatchSetState 做的事 function dispatchSetState(fiber, queue, action) { const lane requestUpdateLane(fiber); const update { lane, action, next: null, }; const pending queue.pending; if (pending null) { update.next update; } else { update.next pending.next; pending.next update; } queue.pending update; scheduleUpdateOnFiber(fiber, lane); }这段代码里有两个关键动作把action包装成update对象进入queue.pending队列调用scheduleUpdateOnFiber告诉 React“这个 Fiber 有更新要处理”。action可以是普通值比如setCount(1)里的1也可以是函数比如setCount(prev prev 1)里的函数。无论哪种都是先排队不是立刻改状态。这也是为什么你在调用setCount后立刻console.log(count)拿到的永远是旧值因为组件函数还没有重新执行当前闭包里的count就是旧渲染的产物。2.2 更新队列如何计算出最新 state等到 render 阶段React 重新执行组件函数时useState会走到updateState然后进入最终的计算逻辑。React 会从queue.pending队列里取出所有积压的update逐个处理。这里最核心的 reducer 逻辑可以简化为function basicStateReducer(state, action) { return typeof action function ? action(state) : action; }如果action是函数就把当前计算出来的 state 传进去返回函数执行结果如果action是普通值就把它当作新 state 直接用。计算完成后React 把新 state 写回hook.memoizedState同时作为本次渲染返回给组件函数。要注意的是这个计算是在 render 阶段完成的不是在你点按钮瞬间完成的。所以多个setCount如果发生在同一次事件处理过程中它们会被一起放进队列最终一次性计算出结果。这也是第 5 章会展开的批处理基础。2.3 lane 与优先级setState 不是到了就执行React 17/18 的更新机制里有一套 lane 模型。每次dispatchSetState都会根据当前更新场景申请一个优先级 lane。比如用户的点击、输入这类交互更新优先级高定时器或网络回调产生的更新优先级低一些。React 调度器会根据 lane 决定什么时候 render、render 过程中能不能被更高优先级任务打断。对业务开发来说你不需要手动操作 lane但知道一点很有帮助setState后 React 并不是“立刻执行更新”而是把更新请求交给调度器。在并发特性下一次更新甚至可能被更高优先级任务中断、重新开始。这也是为什么 React 官方建议尽量把状态更新写成函数式更新因为它更符合“基于最新状态计算”的模型不容易在高优先级穿插时丢掉中间结果。3. 闭包里的旧值与函数式更新为什么 setCount(count 1) 会“少加”3.1 一个经典场景连续三次 set 只加了 1看这段代码function Counter() { const [count, setCount] useState(0); const handleClick () { setCount(count 1); setCount(count 1); setCount(count 1); }; return button onClick{handleClick}{count}/button; }直觉上你可能觉得点击一次count会变成 3。但实际结果是 1。原因就是第 2 章那个 reducer 逻辑三次setCount(count 1)传入的action都是同一个常量1。在更新队列里依次应用时第一个 1 把 0 覆盖成 1第二个 1 又把 1 覆盖成 1第三个还是 1。最终结果自然只有 1。如果改成函数式更新setCount(c c 1); setCount(c c 1); setCount(c c 1);这次点击一次count会变成 3。因为函数式 action 会把前一个动作计算出的结果传给下一个函数0 传给第一个函数得到 11 传给第二个函数得到 22 传给第三个函数得到 3。3.2 为什么函数式更新能“追新”从basicStateReducer就能看出函数式 action 特殊之处在于它接收参数state。这个state不是你在事件回调里捕获到的旧值而是 React 在 render 阶段计算更新队列时维护的中间状态。因此它天然避开了闭包旧值问题。而普通值 action 只是一个“结果值”React 没办法知道你想基于什么旧值做计算。所以当你在异步回调里写setTimeout(() { setCount(count 1); }, 1000);如果用户在这一秒内点了好几次定时器回调里的count还是当初这次渲染捕获的旧值。用户看到的结果就不是“最新基础上加 1”而是“某个旧值基础上加 1”。这个场景用setCount(prev prev 1)就能正确处理因为它读的是队列里的最新中间态。3.3 闭包旧值不是 bug而是渲染模型的结果函数组件每次渲染都是一次独立闭包。useState返回的count和setCount虽然在代码里变量名不变但它们实际上是本次渲染生成的常量。React 没有魔法去修改旧闭包里的变量它只能在下一次 render 时创建新闭包。所以处理旧值问题我的建议是如果在一次事件里需要连续做多次基于最新值的更新优先用函数式更新如果需要在“下一次渲染之后”基于最新 state 做额外逻辑不要尝试在事件回调里同步读把逻辑放进useEffect依赖这个 state如果是定时器、WebSocket、事件监听器这类长驻回调里需要读取最新 state用useRef同步const countRef useRef(count); useEffect(() { countRef.current count; }, [count]); // 在任意回调里读取 countRef.current这个模式我在生产项目里解决过很多次“消息通知条数不更新”的问题核心就是让 ref 充当一个“可变的盒子”绕过闭包旧值。4. 为什么 Hooks 不能写进if里顺序索引是硬约束4.1 没有 key 的 Hook靠位置来认React 的 Fiber 树上的节点有 key 来识别兄弟节点但 Hooks 链表没有 key。React 区分一个 hook 到底对应哪个 state靠的不是名字而是“调用顺序”。组件每执行一次 hook 调用就创建一个节点挂到链表上更新渲染时React 从链表头部开始按顺序把当前这次的 hook 和上一次的 hook 一一对齐。如果某次渲染少调用了一个 hook那么后面所有 hook 的索引都会整体前移一位。React 会把一个本来是useEffect的节点当成useState取state 串位、effect 错乱、甚至崩溃都可能发生。4.2 条件调用会出现的典型报错比如这种写法function Form({ needAge }) { const [name, setName] useState(); if (needAge) { const [age, setAge] useState(18); // 危险 } const [city, setCity] useState(上海); // 危险 return null; }当needAge从true变成false时第一次渲染创建了 3 个 hook第二次只创建了 2 个 hook。React 对比前后链表长度不一致会直接抛错常见报错信息是Rendered fewer hooks than expected. This may be caused by an accidental early return statement.如果只是改变了 hook 类型或参数数量也可能报 “Rendered more hooks than during the previous render” 之类的错。很多开发者第一次看到这个错误时一头雾水其实根源就是链表顺序对不上。4.3 铁律的原因不是限制而是模型必然“Hooks 必须在组件最顶层调用”不是 React 故意给自己加限制而是当前实现模型下的必然要求。React 团队也讨论过给 Hook 加 key 之类的方案但涉及兼容性和 memory 成本目前没有落地。所以写业务代码时唯一正确的做法是不要在条件语句里调用 Hooks不要在循环里调用 Hooks不要在嵌套函数里调用 Hooks如果确实需要“有条件下才有的状态”把那段逻辑抽成一个子组件让子组件自己内部调用 Hooks。这样父组件条件变化大不了卸载/挂载子组件Hooks 数量始终稳定。我早期写代码时也曾为了让代码少几行把useState放进一个工具函数里调用结果状态总是莫名其妙丢失。后来才明白那个工具函数每次 render 都被调用但它的 hook 节点可能没被 React 识别为组件的一部分或者顺序不稳定。Hooks 只能来自组件函数体本身这个红线不能碰。5. 批处理与异步更新三次 setState 为什么只触发一次渲染5.1 从 React 17 到 React 18 的批处理变化React 18 之前自动批处理只在 React 合成事件里生效比如onClick。如果在setTimeout、Promise 回调或者原生事件监听器里调用多个setStateReact 会默认每次都触发一次独立的渲染。React 18 配合createRoot启动应用后自动批处理扩展到了所有场景。无论更新发生在 Promise、setTimeout、原生事件还是同步逻辑里同一轮事件循环内的多个 setState 都会被合并成一次 render 提交。原理就是 dispatch 只是入队和调度在scheduleUpdateOnFiber阶段 React 会复用已经存在的更新请求不会重复开启新渲染。5.2 flushSync 可以打破批处理React 18 提供了flushSync可以在极少数需要同步刷新 DOM 的场景里强制绕过批处理import { flushSync } from react-dom; flushSync(() { setCount(1); });但这里有一个高频误解flushSync不是让count这个闭包变量变成 getter。执行完flushSync后再读console.log(count)拿到的仍然是当前闭包里的旧值因为组件函数还没有重新执行返回新闭包。flushSync只能让 React 同步完成更新并更新 DOM如果你要读取新值应该读 DOM 或者 ref。大多数场景下我不建议主动使用flushSync它会打断 concurrent 渲染的调度容易带来性能损耗。需要同步的地方往往是测 DOM 尺寸、获取焦点这类操作建议在使用前先思考有没有非同步方案。5.3 验证批处理的小实验想直观感受批处理可以用一个非常简单的实验function App() { const [a, setA] useState(0); const [b, setB] useState(0); console.log(render); const onClick () { setA(a 1); setB(b 1); }; return button onClick{onClick}click/button; }在 React 18 的createRoot模式下一次点击只打印一次 “render”。如果把同样代码跑在旧版 React ReactDOM.render的 legacy 模式下并且把 set 放进setTimeout你可能会看到两次打印。这个实验能帮你确认当前项目到底处于哪种模式。批处理带来的好处是减少无谓渲染但也带来一个经验问题如果你在同一事件里依赖两个 state 做联动计算直接基于各自旧值 set很容易出现“互相覆盖”。更好的做法是合并成一个 state 对象或者改用 reducer 统一管理让互相关联的数据在一次更新里算清楚。6. 那些年 useState 的经典“翻车现场”排错思路6.1 修改了对象/数组但视图不更新最典型的问题直接改 state 对象里的字段然后 setState 整个对象。const [user, setUser] useState({ name: 张三, age: 18 }); // 错误的写法 user.age 19; setUser(user);你打印user会发现 age 确实变成了 19但页面没有重新渲染。因为 React 判断是否更新用的是Object.is(prevState, nextState)。setUser(user)传进去的还是同一个对象引用和上一次 render 的 prevState 相等React 认为没有任何变化直接跳过渲染。正确写法是创建新引用setUser(prev ({ ...prev, age: 19 }));这个问题的根因就在memoizedState存的是对象引用。只要更新队列计算出的最终 state 和旧 state 引用相同React 更新流程在 diff 阶段就会认为没有变化。6.2 setState 之后立刻 console.log 打印的永远是旧值因为 dispatch 只是把 action 入队组件函数还没有重新执行。你在事件回调里读到的count仍然是本次渲染闭包里的旧值。const handleClick () { setCount(count 1); console.log(count); // 旧值 };这叫正常现象不叫 bug。如果你确实要在新值渲染后做事情最常见的做法是用useEffect监听它useEffect(() { // count 已经是更新后的值 }, [count]);如果事件回调里还要继续使用新值参与计算推荐把所有更新写成函数式让 React 在队列计算时帮你拿到最新中间态。6.3 定时器、事件监听器里怎么拿最新 state业务里经常遇到的一个场景setInterval 定时去刷新某个状态回调里需要读取当前值来做判断。useEffect(() { const timer setInterval(() { // 这里的 count 是创建定时器那次 render 的旧值 console.log(count); }, 1000); return () clearInterval(timer); }, []);如果把空依赖改成[count]每次 count 变化都会重建定时器确实能读最新值但频繁重建在某些场景下不划算。我常用的方案是用 ref 同步const countRef useRef(count); useEffect(() { countRef.current count; }, [count]); useEffect(() { const timer setInterval(() { console.log(countRef.current); }, 1000); return () clearInterval(timer); }, []);定时器只创建一次但每次读到的都是最新值。这个方法本质上是用 ref 绕开闭包快照的限制非常实用。6.4 初始值计算每次都被执行useState(expensive())的问题前面提过这里再展开。JavaScript 在调用 useState 之前就已经计算expensive()了哪怕 React 更新阶段根本不用初始值这个表达式也会在每次 render 执行。如果expensive()里有复杂计算或大量数据操作就会白白浪费性能。// 不推荐每次 render 都执行 expensive() const [data, setData] useState(expensive()); // 推荐仅在 mount 时执行 expensive() const [data, setData] useState(() expensive());原理很简单mountState里看到传入的是函数会调用一次updateState根本不管 initialValue。函数表达式的惰性初始化就是 React 留给我们的性能优化口子。6.5 多个 state 更新的相互依赖当两个 state 需要联动比如搜索关键词和搜索结果有人会这样写setKeyword(input); setSearchResult(search(input, keyword));但keyword在当前闭包里可能还是旧值searchResult计算结果就是错的。批处理又会把两次 set 合并你很难控制哪个先算。我的建议是联动关系强的状态直接合并成一个对象或者用useReducer把更新操作收敛到一个 reducer 函数里。这样所有状态一次性更新中间计算逻辑可以读到一个最新对象。setState(prev { const keyword input; const result search(input, prev); return { keyword, result }; });这本质上再次印证了函数式更新的价值让 React 把更新逻辑“延迟”到计算 phase 执行而不是在事件回调里基于过期快照手工推导。7. useState 和 useReducer 本是同一套机制顺带聊聊性能7.1 useState 是一个带有默认 reducer 的 useReducerReact 源码里useState的更新阶段调用的是updateReducer默认 reducer 就是basicStateReducer。也就是说useState本质上是一个“默认 reducer 为直接替换或函数执行”的useReducer。如果手动等价替换可以写成const [count, dispatch] useReducer((state, action) { return typeof action function ? action(state) : action; }, 0);这和useState的行为几乎一样。理解这一点你对“什么时候用 useReducer”会有更清晰的判断当更新逻辑复杂需要区分多种 action 类型或者想把状态更新逻辑单独拿出来测试时useReducer 更合适如果只是简单的值替换或函数式累加useState 就足够。7.2 setter 的引用稳定性useState返回的 setter即dispatch函数是在 mount 阶段创建并存在hook.queue.dispatch里的。后续每次 render 都从 queue 里取同一个函数引用所以它是稳定的。这也意味着你可以安全地把 setter 放进某个子组件的useEffect依赖里或者作为 prop 传给优化后的子组件不会因为父组件重新渲染导致子组件引用的 setter 变化。使用useReducer时同样如此dispatch引用稳定。很多人在父组件里用useCallback包装一坨逻辑其实如果这坨逻辑只需要 setter完全不用包装直接传 setter 就行引用天然稳定。7.3 “频繁 setState” 的性能提醒useState内部没有脏检查也没有 debounce。每次 set 入队后只要触发了 render整个组件函数就会重新执行一遍。批处理只能减少“提交次数”不能减少“组件函数执行成本”。所以如果你的组件很大里面又有很多未被useMemo包裹的复杂计算一次点击依然会带来较高的计算开销。性能优化的思路通常是能用useMemo缓存派生数据就缓存能用React.memo包裹子组件避免父组件更新时子组件跟着空跑就包裹能合并的 state 尽量合并减少“一个事件同时 set 多个 state”导致的重复渲染压力把互相关联的状态放进useReducer在 reducer 里集中计算便于追踪和维护。这些优化和 useState 原理互补理解它你就不会被“为什么页面没变化”“为什么重复执行”这种问题反复拷问。写这篇文章时我又把 React 源码里 Hooks 相关的部分翻了一遍最大的体会是useState看似简单却把渲染模型、闭包、更新队列、调度、链表串在了一起。以后再遇到“state 没更新”先不要怀疑框架按这个顺序排查是不是引用没变是不是读到了旧闭包是不是在批处理里被覆盖把这三个问题排除掉你会发现大部分坑其实都写在原理里。
返回列表