资讯动态

React useState惰性初始化原理与Fiber机制下的性能优化

发布时间:2026/10/18 19:03:55 来源:尧图企业网站定制
前阵子在代码评审里看到一位同事写了const [list, setList] useState(largeArray.filter(...))。我建议改成useState(() largeArray.filter(...))顺便解释这行改动背后是React的Lazy Initialization惰性初始化也牵扯到Fiber机制怎么管理hook状态。他听完一脸“就这”——很多人都会觉得无非少算一次不值得展开。但当我追问他惰性初始化到底是省在哪一次计算React又是怎么存这个初始state的他也答不上来。所以这篇文章我想把这几件事一次讲透。文章中我会先比较两种写法的执行时机差异再拆解Lazy Initialization真正能省掉的开销随后从Fiber视角看React内部如何处理useState的初始状态最后用一个小实验验证结论并给出项目里可落地的判断标准。为了让不同基础的读者都能跟上我会尽量把原理和实际代码对应起来。1. useState的两种初始化写法差异比你想的大1.1 两行代码表面一样内在完全不同最简单的useState用法是这样的const [count, setCount] useState(0);它也可以写成箭头函数形式const [count, setCount] useState(() 0);对普通常量0来说这两种写法没有任何差别后者反而多此一举。问题在于很多人把这两种写法混用在复杂表达式的初始化上结果就出现了肉眼看不到的性能损耗。举个例子假如某个组件的初始状态需要从一个超大的数组里筛出有效项并且还要做排序、去重习惯性会写成const [list, setList] useState(buildInitialList());而更合理的写法是const [list, setList] useState(() buildInitialList());差别就在括号里是“调用结果”还是“函数本身”。React官方文档把第二种写法称为“Lazy initial state”也就是「惰性初始化」。当初始状态的计算成本很高时官方明确建议使用函数传参的方式。1.2 执行时机传值 vs 传函数要看懂这个优化点必须先理解JavaScript的函数调用规则。useState(arg)本身只是一个普通函数调用。在JavaScript里函数参数会在调用前先完成求值。也就是说当你写useState(buildInitialList())时不管React内部是否真的要用这个返回值buildInitialList()都必须先执行一遍。接下来再看React组件的渲染流程。一个函数组件本质上就是一个普通函数它在挂载和更新时都会被React调用。这意味着每次组件重新渲染buildInitialList()都会被重新执行。就算React在第一次渲染后已经把state存起来了后面的渲染根本不会再读取这个初始值但表达式已经在参数位置算完了。而useState(() buildInitialList())传的是一个函数引用。函数体内代码不会立刻执行React拿到这个函数后只有在首次挂载初始化state时才会调用它然后把执行结果作为初始值保存。后续更新渲染时React直接把保存的state返回不再调用这个初始化函数。这就是两种写法最核心的差异一个是“每次render都白算一遍”一个是“只在首次挂载时算一次”。1.3 从“省一次计算”到“省到点子上”很多人觉得每次渲染多算一遍又怎样如果buildInitialList()只是做个简单的数组map几毫秒甚至几微秒的事确实无所谓。但真实项目里初始state可能来自这些地方读取localStorage并解析JSON甚至还要做兼容处理对一个上万项的大数组执行filter、sort、reduce、去重根据URL参数、环境变量做条件判断再层层计算出一个初始对象生成随机数或ID需要调用多次随机函数从嵌套很深的配置对象中提取默认值。这些操作如果叠加到每次点击按钮、每次输入表单都触发的重渲染上页面就会明显卡顿。尤其在高频交互场景里比如拖拽、滚动、快速切换Tab这种“白算”会被无限放大。惰性初始化的意义就是把一次性初始化开销压缩到组件挂载时后续渲染完全不受影响。提示惰性初始化并不能让首次挂载变得更快它省的从来不是“第一次计算”而是后续N次重渲染里不必要的重复计算。2. Lazy Initialization到底优化了什么2.1 什么时候惰性初始化是“必须品”你可以把useState的初始表达式看作两种开销常量开销和重复开销。如果是下面的场景用箭头函数几乎是必须的。第一初始值依赖不可靠的外部环境。最典型的是读localStorageconst [user, setUser] useState(() { const raw localStorage.getItem(user); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { return null; } });这里不光有JSON解析开销还涉及异常处理。如果每次重渲染都重新执行一遍轻则浪费重则因为解析失败出现不可预期行为。惰性初始化保证了这段逻辑只在组件实例挂载时执行一次与后续交互完全隔离。第二初始值需要复杂计算。比如一个列表组件进来时要把几千条日志按时间、级别、关键字做多重排序和筛选再提取统计信息。这类计算的耗时可能达到几十毫秒甚至更多放在重渲染路径上是不可接受的。第三初始值需要“只执行一次”的副作用。例如初始化时生成一个随机种子、读取一次剪贴板、测量一次DOM尺寸。注意这类想在一次渲染期间只执行一次的逻辑并不适合放进惰性初始化里做有副作用的操作因为React严格模式可能会额外调用它。但如果你明确知道组件不会被StrictMode包裹或者这个副作用是幂等的那勉强可以用。更稳妥的做法是放在useEffect里不过这是另一个话题了。2.2 什么时候它只是“装饰品”惰性初始化不是万能的滥用反而会让代码变丑。常见的“装饰品”写法有const [name, setName] useState(() 张三); const [visible, setVisible] useState(() true); const [id, setId] useState(() props.id);这些写法没有任何性能收益因为普通值字面量或者简单变量在参数位置的求值开销可以忽略不计。箭头函数反而让代码多了一层嵌套可读性下降每次阅读还要多思考一秒钟。还有一种情况是初始值本身就是外部传入的props或store里的数据。如果你只是想把一个现成的值作为state的起步值直接useState(props.value)就行。注意这样写之后state和props并不会自动同步因为useState只在mount时使用初始值。这跟惰性初始化没关系是React状态设计中一个常见的认知误区。2.3 别忘了useReducer也有initReact另一个与惰性初始化直接相关的API是useReducer。它支持第三个参数function init(initialArg) { return { count: initialArg.count * 2 }; } function reducer(state, action) { switch (action.type) { case increment: return { count: state.count 1 }; default: return state; } } function App({ initialData }) { const [state, dispatch] useReducer(reducer, initialData, init); // ... }第三个参数init就是官方钦定的惰性初始化入口。当初始state需要通过initialArg做一些复杂计算时把它单独抽成一个函数比在useReducer里嵌套箭头函数更清晰。这个函数的执行时机与useState(() ...)一致只在首次挂载执行一次后续重渲染直接复用已有state。如果你已经在项目里用了useReducer处理复杂初始state时就应该优先考虑这第三个参数而不是绕道去写useState加useEffect。它不仅是性能优化也是一种代码组织手段把“如何根据外部参数生成初始state”这件事内聚在init函数里。3. 从Fiber的视角看useState初始值的存储与更新3.1 Fiber节点上的Hook链表结构要理解惰性初始化为什么只执行一次必须进入React内部看看Fiber架构下hook状态是怎么存放的。React 16开始全面使用Fiber架构。每个组件都对应一个Fiber节点这个节点是React在渲染过程中的“工作单元”负责保存组件在当前界面上的状态、副作用和相关信息。函数组件里的hook信息全部挂在Fiber节点的memoizedState属性上。你可能听过一个说法hook是链表。这句话说的就是memoizedState。组件里每调用一个hookReact就会在内部创建一个hook对象然后通过next指针把它们串成一个单向链表。链表上的每个hook对象都有自己的属性Fiber节点.memoizedState - hook1: { memoizedState: ..., next: hook2 } - hook2: { memoizedState: ..., next: hook3 } - hook3: { memoizedState: ..., next: null }对于useState来说memoizedState对应的就是当前state的值。对于useEffect它保存的是effect对象对于useRef它保存的是ref对象。这个设计保证了每次重渲染时React都能沿着链表精确找到“上一次的位置”。3.2 首次渲染与更新渲染的心智模型理解了hook链表再回头看初始化流程就清楚多了。首次渲染时组件对应的Fiber节点还不存在或者是一个新的“当前Fiber”。React会执行组件函数然后把遇到的useState映射到新的hook对象上。如果useState收到的是一个函数React就调用它把返回值写入hook.memoizedState。如果收到的是普通值直接写入。这之后初始state被写死在这个hook对象里。后续更新时组件重新渲染React沿着workInProgress Fiber的hook链表找到同一个hook对象。它发现这个hook已经初始化过了于是不再关心useState的第一个参数是什么。它要做的是读取hook.queue里的更新队列把之前调用setState传入的动作逐个计算出来最后得到一个新的memoizedState。所以惰性初始化的函数之所以只在首次挂载执行是因为React内部根本没有在后续更新时“再次初始化”这个动作。初始值在hook对象里保存着后面就用队列计算新值初始值彻底退役。这里还涉及Fiber的双缓存机制。React维护两棵Fiber树一棵是当前屏幕上展示的current树另一棵是正在构建的workInProgress树。当状态更新发生时React基于current树复制出workInProgress树在workInProgress树上完成diff和更新最后切换指针。函数组件的hook链表也在这个复制过程中被复用。也就是说更新时组件的hook对象不会新建初始值自然也不需要重新求值。3.3 React为什么不做“自动惰性”到这里自然会冒出一个问题既然React在后续渲染中根本不需要初始值为什么不自动帮开发者“惰性求值”所有useState参数这句话需要拆开想。如果React真的对所有useState参数都做延迟求值那它必须能区分参数是一个“昂贵的计算表达式”还是一个“已经算好的普通值”。但JavaScript的函数参数在传入时就已经完成求值React拿到手的永远是值不是表达式。真正判断“贵不贵”的权力只可能在写代码的人这边。官方选择“传函数才代表惰性”这个约定其实是一种显式信号。你写成箭头函数就是告诉React请在执行到初始化阶段时才调用我。你直接传值React就按值处理。这个设计把控制权完全交给开发者避免了React内部无谓的猜测。代价就是如果你不懂这个约定很容易写出useState(expensive())这种“表面无害”的代码让昂贵计算混进每一次渲染。4. 用Profiler说话一个实验和它的结论4.1 实验场景与代码口说无凭我搭一个非常简单的React项目用两个组件做对比。第一个组件用普通传值function ExpensiveValue() { const [count, setCount] useState(0); const [total, setTotal] useState(expensiveCalcuation()); function expensiveCalcuation() { console.log(expensiveCalcuation called); let sum 0; for (let i 0; i 10000000; i) { sum i; } return sum; } return ( div button onClick{() setCount(count 1)}重新渲染/button span{count}/span span{total}/span /div ); }第二个组件改用惰性初始化function ExpensiveLazy() { const [count, setCount] useState(0); const [total, setTotal] useState(() expensiveCalcuation()); function expensiveCalcuation() { console.log(expensiveCalcuation called (lazy)); let sum 0; for (let i 0; i 10000000; i) { sum i; } return sum; } return ( div button onClick{() setCount(count 1)}重新渲染/button span{count}/span span{total}/span /div ); }当然正常代码里不应该在组件函数体内定义耗时函数这样每次渲染都会重新创建函数本身可能也有损耗。我这里故意简化重点看console.log的输出次数。跑起来之后每个组件初次挂载时都会打印一次。然后我连续点击5次按钮让count变化5次触发5次重渲染。4.2 实验结果对比两个组件的console输出如下操作普通传值版打印次数惰性初始化版打印次数首次挂载1次1次点击5次按钮后增加5次共6次仍然是1次再点击10次后增加10次共16次仍然是1次这就是性能差异的最直接证据。普通传值版每次渲染都要执行一次1000万次循环的计算哪怕state已经被完整保存React也根本用不上这个值。而惰性初始化版在整个生命周期里只计算一次后续每次点击都轻如羽毛。如果你打开React DevTools的Profiler还能看到另一种现象普通传值版的 “Render duration” 在每次重渲染时都会比惰性初始化版高出几十毫秒。在计算逻辑更复杂的场景里这个差异会扩大到一个用户能感知到的程度表现为点击按钮有粘滞感、页面滚动掉帧。注意我这里用了console.log辅助观察是因为它简单直接。但真实项目里不要把console.log或者任何有副作用的操作放进惰性初始化函数下面会专门讲。4.3 容易翻车的细节StrictMode、组件重挂载惰性初始化虽然好但有几个细节必须知道否则容易掉坑。第一React严格模式StrictMode在开发环境下会刻意双调用初始化函数。React 18的StrictMode会模拟“卸载再重挂载”的行为用来提前暴露副作用相关的代码问题。因此你在开发环境下可能会看到初始化函数被调用两次。这是设计如此不是bug。生产环境中不会发生。但如果你在useState(() ...)里放了计数器、发送埋点请求、修改外部变量你会被这种“多的一次”坑得很惨。正确的姿势是惰性初始化函数应该是纯函数——同样输入同样输出不带任何外部副作用。如果确实需要根据初始值发一次请求那更适合放在useEffect里。第二组件重新挂载时惰性初始化函数会重新执行。也就是说它省的是“同一个组件实例后续重渲染”的重复计算而不是“整体应用生命周期”的缓存。如果一个组件因为父级key变化、条件渲染被卸载后再创建它的所有state都会重置初始化函数也会重新跑来生成新state。理解了这一点就不会产生“初始化函数只执行一次所以可以当全局缓存用”的误解。第三不能把初始化函数直接写错成返回函数。比如// 错误把函数本身当作state保存了 const [fn, setFn] useState(() expensiveCalcuation);这里的expensiveCalcuation没有被调用state保存的是整个函数体后续想当值用时会得到NaN或undefined。正确写法是// 正确箭头函数体里调用了目标函数 const [value, setValue] useState(() expensiveCalcuation());在脚手架里常见的坑是忘了加()或者返回语句写了包对象却不带括号导致行为偏离预期。以后看到useState(() someFn)一定要先确认函数后面有没有括号。5. 实践指南在真实项目中如何判断要不要用箭头函数5.1 判断标准与典型案例我在代码审查里经常用下面这套标准快速判断是否该用惰性初始化。你可以直接抄走。初始值来源推荐写法理由简单字面量如0、空字符串、falseuseState(0)无计算开销包函数只会增加阅读负担从外部props直接取值useState(props.value)值已在外部算好不需要再包一层Math.random()、Date.now()等简单调用普通传值或惰性初始化均可开销很小看团队代码风格数组大排序、大数组filter、多层对象转换useState(() computeInitial())避免每次重渲染重复昂贵计算localStorage读取 JSON解析useState(() parseStored())读取和解析都可能较慢且要处理异常根据props计算一份较大的state对象useState(() buildFromProps(props))只算一次后续反复render不重算需要调用多个函数、执行一段逻辑才能得到初始值useState(() { ... return value; })把初始化逻辑收敛在块级函数体里典型案例我遇到过好几个。一个管理后台的筛选组件初始化时要根据当前URL的query参数生成约两万个节点的树结构。一开始代码写的是useState(buildTree(query))结果每次输入关键词筛选列表整个页面会卡顿几百毫秒。改成useState(() buildTree(query))之后只有进入页面那一刻会慢一次后续交互完全流畅。还有很多表单页面需要回填数据初始state通常来自一轮复杂的对象合并。这里同样适用把“合并补充默认值”放进惰性初始化比在组件函数体顶部散落一堆中间变量清晰得多顺带把重复计算也省了。5.2 我的习惯和踩坑记录个人经验处理useState初始值时有三个习惯值得坚持。一是先想“初始值从哪里来”再决定要不要包函数。如果你写的时候初始值后面跟着一大长串表达式那就果断包成箭头函数。如果只是简单变量多一层思考都不需要。二是惰性初始化函数尽量独立命名。如果逻辑超过三行我会抽一个单独的函数比如createInitialState、buildUserFromStorage。这样组件顶部看起来很干净测试也方便。直接在useState里写一大坨箭头函数虽然能用但可读性一般。三是警惕闭包陷阱。useState(() compute(props.id))里的初始化函数可以读取当前渲染的props。但要注意这个函数只在首次挂载时执行。如果后续props变化了初始化函数不会被再次调用state也不会自动同步。想要根据props变化重置state不要指望惰性初始化可以考虑在父级给组件设置key或者用useEffect配合setState手动同步。我早期就踩过这个坑。组件接收一个外部传入的userId我希望user变化时重新初始化一个表单于是把初始state写成useState(() buildForm(userId))。结果切换用户时表单内容纹丝不动因为组件没有重新挂载惰性初始化根本没机会再跑。最后给组件加了个key{userId}切换用户时强制重挂载才符合预期。5.3 别为了优化而优化写到这里还得泼一盆冷水惰性初始化不是所有性能问题的银弹。很多情况下初始计算本身只有几微秒包一层箭头函数带来的收益几乎测量不到。这时候却让代码多了一层缩进、多了一个函数反而拉低了整体可读性。我见过一个“优化狂魔”风格的仓库所有useState全部写成useState(() 0)这种形式。结果大家每次看代码都要停下来揣摩这里到底为什么懒沟通成本肉眼可见地升高。性能优化要算ROI不能只看技术正确。更值得优化的是那些真正在大循环、大树遍历、高频交互中反复执行的逻辑。比如你有一个memo组件仍然渲染频繁或者某个useContext导致整个子树都重渲染这些问题的收益通常比纠结初始值大得多。惰性初始化应该在正确的地方使用而不是作为“专业感”的装饰。如果你能举出具体场景这这个初始计算确实昂贵、确实会在重渲染时反复执行那就放心用箭头函数。否则保持简单就好。衡量标准很简单——删掉这层箭头函数代码会不会少一个思考步骤会的话就别加。React把“是否需要惰性”的选择权交给你就是希望你能在理解原理的基础上做决定。现在我已经把useState的参数求值时机、Fiber中hook链表的存储方式、更新渲染时的执行路径都拆了一遍也给了判断标准。以后再看到useState(expensive())你能立刻意识到每次重渲染都会白算一遍。看到useState(() expensive())你也知道React会在首次挂载时把计算一次的结果挂到Fiber节点的hook链上。技术很多时候就是这样捅破那层窗户纸之后剩下的就是熟能生巧。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑