ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

前阵子在代码评审里,看到一位同事写了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的初始表达式看作两种开销:常量开销和重复开销。如果是下面的场景,用箭头函数几乎是必须的。

第一,初始值依赖不可靠的外部环境。最典型的是读localStorage:

const [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也有init

React另一个与惰性初始化直接相关的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。组件里每调用一个hook,React就会在内部创建一个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链上。技术很多时候就是这样,捅破那层窗户纸之后,剩下的就是熟能生巧。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进