☰
React useState惰性初始化:从执行时机到Fiber存储机制全解析
2026/10/17 14:03:27 网站建设 项目流程

1. 起因:一段再常见不过的 useState 代码

做 React 开发时,几乎每个组件里都躲不开useState。时间长了,大家会形成一套“顺手就写”的习惯,比如:

const [list, setList] = useState(createInitialTodos());

看起来没什么问题:调用createInitialTodos()得到一个数组,作为list的初始值。但如果createInitialTodos做的事情很重——解析一份上千条的数据、做复杂的排序去重、从本地存储读取并反序列化——那问题就来了:这个函数会在组件每次渲染时都被调用一次,而useState只在首次渲染时真正需要它的返回值。

于是官方推荐写成箭头函数:

const [list, setList] = useState(() => createInitialTodos());

一个不起眼的改动,背后是 React 的“惰性初始化”(Lazy Initialization)机制,再往深处挖,还连着 Fiber 架构中状态是怎么挂在组件节点上的。很多同学知道“要这么写”,但说不清“为什么这么写”,更说不清“它到底优化了什么”。这篇文章我就把这条链路完整拆开:从执行时机、Fiber 内部的状态存储,到实际场景和容易踩的坑,一次讲透。

2. 两种写法,差的不是“语法”而是“执行时机”

很多人把useState(() => createInitialTodos())理解成“换了一种写法”,其实两者在 React 内部走的是完全不同的路径。

2.1 直接传值与传箭头函数的本质差异

// 写法 A:直接传入计算结果 const [todos, setTodos] = useState(createInitialTodos()); // 写法 B:传入一个返回初始值的函数 const [todos, setTodos] = useState(() => createInitialTodos());

先看写法 A。JavaScript 在调用useState之前,会先计算参数:createInitialTodos()被立即执行,执行完得到一个数组,然后这个数组才被传给useState。也就是说,不管组件渲染多少次,createInitialTodos都会跟着执行多少次。

写法 B 传的是一个函数引用,useState拿到这个函数,并不会立刻调用它。React 只在组件“挂载”阶段(第一次渲染)把它当作初始化函数执行一次,拿到初始状态;之后组件再更新、再渲染,初始状态直接从 Fiber 节点上读,这个函数就不再执行了。

这有点像买一台带“预约功能”的洗衣机:写法 A 是你每次进门就把衣服洗一遍,写法 B 是机器只在你第一次使用时启动一次洗衣程序,后面你只是取放衣服而已。

2.2 用日志实测:一次渲染一次调用

光说不练没意思,写个小例子验证一下:

function ExpensiveInit() { console.log("createInitialTodos 被执行了"); const [todos, setTodos] = useState(() => createInitialTodos()); const [count, setCount] = useState(0); return ( <div> <span>当前数量:{todos.length},点击次数:{count}</span> <button onClick={() => setCount(count + 1)}>触发重渲染</button> </div> ); }

如果使用useState(createInitialTodos()),每点一次按钮,控制台都会打印一次createInitialTodos 被执行了。而换成useState(() => createInitialTodos())后,只有第一次渲染会打印,之后怎么点按钮都不会再执行。

这背后是性能成本的差别:如果初始化逻辑是new Array(10000).fill(...).map(...)这种量级,写法 A 会让 React 每次更新都白白做一遍重复计算,轻则增加渲染耗时,重则造成肉眼可见的卡顿。

提示:判断一个初始化逻辑重不重,别只看代码行数,要看它的时间复杂度和数据量。真实项目里常见的高成本初始化包括:读取localStorage并JSON.parse、解析查询字符串、对超大数组排序/去重、生成树形结构等。

3. 深入 Fiber:初始状态到底被存在哪里

知道“什么时候执行”只是第一步。真正理解惰性初始化,必须走进 React 的 Fiber 架构,搞清楚状态在内存里是怎么保存的。

3.1 每个函数组件都对应一个 Fiber 节点

React 16 之后的核心架构是 Fiber。简单理解:页面上每个组件实例,在调和阶段都会对应一个 Fiber 节点对象,这个节点上保存着组件的各种信息。函数组件没有自己的类实例,那useState的状态放哪?答案就是存在 Fiber 节点上。

Fiber 节点上有一个字段叫memoizedState,对函数组件来说,它保存的不是某一个状态,而是一整条 Hook 链表。每次调用useState、useEffect、useRef时,React 都会在当前 Fiber 的 Hook 链表上追加或查找一个 Hook 对象。每个 Hook 对象内部又有一个memoizedState字段,用来保存这个 Hook 自身的数据。

拿useState来说:

// Fiber 节点内部示意(非真实源码,帮助理解结构) const fiber = { memoizedState: { // 第一个 Hook:useState memoizedState: "当前state值", // 这里存状态 queue: { ... }, // 更新队列 next: { // 第二个 Hook:另一个 useState memoizedState: 0, queue: { ... }, next: null } } };

你可以把 Fiber 节点想象成一张“登记表”,每个 Hook 对应表上的一行。useState的当前值,就写在对应那一行的memoizedState格子里。

3.2 挂载阶段和更新阶段走的是两条路

useState在 React 底层实现中分成两个阶段函数:mountState(挂载时使用)和updateState(更新时使用)。React 怎么判断当前是挂载还是更新?看的是当前有没有对应的 Fiber 节点,以及 Hook 链表是否已经存在。

首次渲染时,组件还没有状态,React 走mountState:

  • 创建 Hook 对象,并把它挂到 Fiber 的memoizedState链表末尾。
  • 取传入的参数。如果参数是函数,就先执行这个函数,把返回值作为初始状态;如果参数是普通值,就直接把它作为初始状态。
  • 把初始状态写入hook.memoizedState。

后续更新时,React 会沿着 Fiber 上的 Hook 链表,找到与当前useState调用位置对应的那个 Hook,走updateState:

  • 不再看传入的参数是什么。
  • 直接从hook.memoizedState里读出上一次保存的状态。
  • 如果期间有setState触发过更新,就把更新队列里的计算结果合并到状态上。

关键点就在这里:updateState阶段根本不关心初始值是多少。你传入的是一个函数,还是100,还是某个运算表达式的结果,都不会再次参与计算。所以“箭头函数”真正的价值,是确保那个昂贵的初始计算不会在mountState之外的每次更新时白白发生。

3.3 为什么“只执行一次”是架构设计的结果

有人会问:既然 React 知道初始值只在挂载时用,那直接传值为什么不去优化掉重复计算?

这里有一个 JavaScript 的语言现实:useState(createInitialTodos())在调用发生之前,参数已经被求值了。React 完全没有机会拦截“求值”这一步,它拿到手的已经是算好的数组。React 能做的最优解,就是提供一种“把求值过程推迟到 mount 阶段”的协议——也就是接受一个函数,函数体里的计算由 React 在需要时自己触发。

所以惰性初始化不是 React 强加的一种“性能玄学”,而是它给开发者的一个逃生舱:当你想把“昂贵计算”从“每次渲染都要做”变成“只做一次”,就用函数把计算包起来,明确告诉 React:这个初始值需要你按需调用。

4. 什么场景适合用惰性初始化

惰性初始化不是银弹,用对了才有收益。很多项目里大家一看到useState就条件反射套() =>,其实没必要。我按实战经验把场景分成了几类。

4.1 典型场景一:初始值来自“重计算”

最常见的就是列表、树、映射这类数据结构的构建。比如一个任务管理应用,初始化时要根据一批原始记录生成一棵任务树:

function TaskBoard({ rawRecords }) { const [taskTree, setTaskTree] = useState(() => buildTaskTree(rawRecords)); }

注意,这个场景下buildTaskTree可能在组件首次渲染前就要执行,而且组件更新时rawRecords变化了,taskTree也不会自动重建——这正是惰性初始化的预期行为:初始状态是“一次性快照”。如果你希望rawRecords变化后重新构建树,那是另一套逻辑,该用useMemo或useEffect同步,而不是指望useState的初始化函数。

4.2 典型场景二:从本地存储或缓存恢复状态

页面刷新后,需要从localStorage读取用户偏好设置,再解析成对象作为初始状态。如果每次渲染都读一次localStorage,不仅耗时,还可能在渲染期间触发不必要的存储读取。换成惰性初始化正好:

const [settings, setSettings] = useState(() => { const raw = localStorage.getItem("settings"); return raw ? JSON.parse(raw) : defaultSettings; });

4.3 典型场景三:生成初始随机值或唯一 ID

比如一个编辑器组件,初始时需要一个唯一的文档 ID。用Math.random().toString(36)或crypto.randomUUID()这类操作,其结果只在创建时有效。写成:

const [docId] = useState(() => crypto.randomUUID());

如果直接写useState(crypto.randomUUID()),每次渲染都会生成一个新 ID,虽然你只有在首次渲染时会用到它,但多余的随机数生成还是会白占资源,而且值每次刷新都不一样,日志排查时会造成干扰。

4.4 哪些场景不需要箭头函数

如果你的初始值就是一个基本类型常量,比如useState(0)、useState("")、useState(false),直接传值完全没有问题,计算成本几乎为零,引入箭头函数反而多一次函数创建和调用开销(虽然微乎其微)。

另外,如果初始值直接来自 props 或外部模块的常量引用,而且本身没有“重复计算”问题,也不需要惰性初始化:

const [name, setName] = useState(props.defaultName); // 没问题

这条规则可以总结成两句话:初始值的计算成本够高,或计算过程有明显副作用倾向,才用惰性初始化;否则保持简单。

5. 最容易踩的坑与排查经验

惰性初始化用起来简单,但我在团队代码评审和自测中见过不少反例。这里整理了几个高发问题。

5.1 初始化函数必须保持“纯”

这是最重要的一条。惰性初始化函数里不应该产生副作用,比如修改外部变量、写 localStorage、发请求、改变 DOM。React 官方把它称作“初始化函数”,要求它像一个纯函数:同样的输入,永远返回同样的输出,并且不影响外部。

为什么这么严格?核心原因是开发模式下的StrictMode。React 18 开始,开发环境里组件挂载时会被故意“双调用”一次,目的就是帮你暴露非纯函数带来的 bug。如果你的初始化函数里有副作用,它可能会被执行两次,比如往一个外部数组里push了两次,或者发了两次初始化请求。

let cache = []; const [state, setState] = useState(() => { cache.push("x"); // 危险:StrictMode 下可能执行两次 return compute(); });

真正需要副作用时,请把它放到useEffect里,或者保证副作用是幂等的。

5.2 初始化函数里不要依赖会变化的外部数据

很多初学者会写出下面这样的代码:

const [list, setList] = useState(() => filterList(props.allItems, props.keyword));

他们的意图是:props.allItems或props.keyword变化后,重新生成list。但实际结果是:这个函数只在挂载时执行一次,之后 props 怎么变,list都不会重新计算。

这是一个很容易造成隐蔽 bug 的写法。如果确实要响应变化,应该用useMemo:

const list = useMemo(() => filterList(props.allItems, props.keyword), [props.allItems, props.keyword]);

记住:惰性初始化的函数“看一眼”就“再也不见”,它不是响应式逻辑。

5.3 setState 之后,初始化函数会重新执行吗

答案是:不会。

const [count, setCount] = useState(() => { console.log("init"); return 0; }); // 点击按钮触发 setCount(count + 1) // init 只会在首次渲染时打印一次,后续永远不会打印

原因前面说过:更新的渲染阶段走updateState,它读取的是 Hook 链表上的memoizedState,不会再去碰初始值参数。如果你发现初始化函数被多次执行,先查一下组件是不是被重新挂载了(key 变了、父级条件渲染切换了,都会导致组件销毁重建)。

5.4 如何确认组件是否反复挂载

配套的排查方法很简单:在组件里加上一段挂载标记:

useEffect(() => { console.log("mounted"); return () => console.log("unmounted"); }, []);

如果发现“mounted/unmounted”频繁成对出现,说明组件确实在销毁重建。此时惰性初始化函数执行多次不是 React 的锅,而是组件树的结构问题——比如父组件的key写法不稳定,或者某个条件渲染把组件切来切去。

6. 从 useState 出发:useReducer、useRef 里的对应用法

惰性初始化并不只是useState的专利。理解它的机制后,你可以在 React 其他 Hook 里举一反三。

6.1 useReducer 的第三个参数也是惰性初始化

useReducer支持第三个参数:

const [state, dispatch] = useReducer(reducer, initialArg, init);

这里的init就是一个惰性初始化函数,React 会在挂载时执行init(initialArg),并把返回值作为初始 state。如果你的初始 state 需要经过复杂计算,就可以把计算逻辑放进init:

function init(initialCount) { return { count: initialCount, total: initialCount * 2 }; } const [state, dispatch] = useReducer(reducer, 10, init);

可以看到,useReducer的惰性初始化和useState的箭头函数是同一套设计哲学:把计算推迟到真正需要的时刻,并且只执行一次。区别仅在于useReducer可以把计算逻辑单独抽成一个函数,便于测试和复用。

6.2 useRef 没有“惰性初始化”的特殊语法

useRef经常被用来存一些“只需要创建一次”的对象,比如组件里的某个类实例、某个第三方库的实体:

const instanceRef = useRef(new SomeClass());

这里有个和useState非常类似的陷阱:new SomeClass()在每次渲染时都会执行,创建出一堆被丢弃的对象。但useRef不支持“传函数”这样的惰性初始化语法——如果你写成useRef(() => new SomeClass()),Ref 里保存的将是这个函数本身,而不是实例对象。

惯用的解法是:

const instanceRef = useRef(null); if (instanceRef.current === null) { instanceRef.current = new SomeClass(); }

这个if判断也可以看作一种“手写惰性初始化”:判断 ref 里还没有值,就创建一次;之后渲染直接复用。它的执行时机比useState的初始化函数更晚,但同样保证了“只创建一次”。

7. 我的实战判断标准:到底什么时候该写箭头函数

写到这里,我想把自己平时在代码里实际执行的判断标准分享出来。它很简单,就两条:

7.1 两条判断规则

第一条:初始值的计算成本是否高到“每次渲染重复算”会明显浪费资源。如果只是useState(0)、useState("")、useState(false)这样的基础值,完全不需要箭头函数。如果初始化涉及几十毫秒以上的计算、上万条数据的遍历、大对象构建,请果断使用惰性初始化。

第二条:初始值是否需要保持“首次计算后的唯一引用”。这在某些场景下比性能更重要。比如初始值是一个复杂对象,而你会在某些地方缓存它的引用,如果每次渲染都构建一个新对象,后续比较引用就会失败,导致一些“明明没变却触发了更新”的诡异问题。惰性初始化天然保证初始对象只创建一次,引用稳定。

7.2 代码评审里的一句话建议

我个人的经验是:代码评审时看到useState(someFunction())这种直接调用形式,先别急着改,花十秒钟问三个问题:

  • 这个函数每次渲染都执行的话,代价大不大?
  • 函数的返回值是不是每次都是新引用?
  • 这个值真的只应该在首次渲染时确定吗?

如果三个问题里有一个答案偏向“是”,那就值得改成useState(() => someFunction())。

这个小改动经常被人当作“风格偏好”,但它背后其实是用 Fiber 机制换来的确定性:你明确告诉 React,这个初始值的计算不应该出现在更新渲染的热路径上。我见过不少因为省略这一层包装,导致大数据量初始化拖慢整个列表渲染速度的真实案例;也见过因为初始化函数里写了副作用,在 StrictMode 下重复执行而踩坑的教训。把执行时机、Fiber 存储、纯函数约束这三件事想清楚,以后再看到类似代码,你就能一眼判断该不该改,以及改了之后到底在优化什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询