React useState惰性初始化:从执行时机到Fiber存储机制全解析

发布时间:2026/10/17 14:03:34
React useState惰性初始化:从执行时机到Fiber存储机制全解析
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: { // 第一个 HookuseState 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 的写法。如果确实要响应变化应该用useMemoconst 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 需要经过复杂计算就可以把计算逻辑放进initfunction 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 存储、纯函数约束这三件事想清楚以后再看到类似代码你就能一眼判断该不该改以及改了之后到底在优化什么。