本文是「React 页面问题手记」系列的第 2 篇。
一个订单编辑页显示商品单价、购买数量和订单小计。点击加一后,页面只改了一个数字,控制台里放在组件函数中的日志却又打印了一次。
text
OrderEditor 执行
OrderEditor 执行
继续在计算总价的方法、事件处理函数和 Effect 中加日志,输出次数还不一样,这又是什么问题呢?
一、一次状态更新为什么会让组件函数再次执行
订单编辑组件持有购买数量。当前需要观察的是组件函数中的日志,以及点击按钮后状态更新发生在哪里。
jsx
import { useState } from 'react'
export default function OrderEditor() {
const [quantity, setQuantity] = useState(1)
console.log('OrderEditor 执行,quantity =', quantity)
function increase() {
setQuantity(previous => previous + 1)
}
return (
<section>
<p>购买数量:{quantity}</p>
<button onClick={increase}>
加一
</button>
</section>
)
}
首次显示时,React 调用 OrderEditor(),这次函数执行拿到:
text
quantity = 1
组件返回包含购买数量:1的 JSX,随后页面显示对应内容。
用户点击加一以后,事件处理函数提交一次状态更新。React 会使用新状态再次调用组件函数,这次得到:
text
quantity = 2
因此控制台再次打印:
text
OrderEditor 执行,quantity = 2
这里表示 React 再次调用组件函数,计算新的界面描述。它不等于浏览器已经修改 DOM,更不等于整个网页重新加载。
二、组件函数再次执行时,哪些代码会重新计算
组件函数体内的普通代码,会随着这次函数调用重新执行。下面加入单价、总价计算和按钮文案:
jsx
import { useState } from 'react'
export default function OrderEditor() {
const [quantity, setQuantity] = useState(1)
const unitPrice = 99
const totalPrice = unitPrice * quantity
const buttonText = quantity >= 5
? '已达到单次购买上限'
: '加一'
function increase() {
setQuantity(previous => {
return Math.min(previous + 1, 5)
})
}
return (
<section>
<p>单价:{unitPrice} 元</p>
<p>数量:{quantity}</p>
<p>小计:{totalPrice} 元</p>
<button
disabled={quantity >= 5}
onClick={increase}
>
{buttonText}
</button>
</section>
)
}
每次 quantity 更新后,组件函数再次执行,下面这些内容都会重新得到结果:
text
unitPrice
totalPrice
buttonText
increase
返回的 JSX
unitPrice 每次仍然是 99,totalPrice 会根据新数量重新计算。
这里不需要把 totalPrice 再保存成一份 State。它完全由 unitPrice 和 quantity 得到,在渲染期间直接计算,能避免数量更新后还要额外同步总价。
事件处理函数 increase 也会在本次组件执行中重新创建。它会使用这一轮渲染中的变量和 Props。函数重新创建本身不等于页面一定变慢,只有它确实扩大了昂贵计算或子组件更新范围时,才值得继续优化。
三、重新执行组件函数,不会重新初始化所有 State
组件函数重新调用以后,代码又运行到:
jsx
const [quantity, setQuantity] = useState(1)
页面却不会重新变回 1。
useState(1) 中的 1 用于首次初始化状态。后续重新渲染时,React 会把当前保存的状态返回给这次组件执行。
第一次执行:
text
useState(1)
→ quantity = 1
点击一次后重新执行:
text
useState(1)
→ quantity = 2
因此,组件函数虽然从开头重新运行,State 并没有因为看到初始值 1 就被覆盖。
普通局部变量没有这份保留关系:
jsx
export default function OrderEditor() {
const [quantity, setQuantity] = useState(1)
let renderCount = 0
renderCount++
return (
<>
<p>数量:{quantity}</p>
<p>执行次数:{renderCount}</p>
<button
onClick={() => {
setQuantity(previous => previous + 1)
}}
>
加一
</button>
</>
)
}
每次组件执行都会重新创建 renderCount,所以它总是从 0 开始,再变成 1。普通局部变量适合保存本轮计算结果,不适合保存需要跨多次渲染延续的数据。
需要显示到页面并触发更新的数据通常放在 State 中;需要跨渲染保存、但变化后不需要更新页面的数据,可以再考虑 Ref。两者的选择取决于数据是否参与界面显示。
四、返回新的 JSX 后,DOM 为什么没有全部重建
组件函数再次执行后,会返回一份新的 JSX:
jsx
return (
<section>
<p>单价:99 元</p>
<p>数量:{quantity}</p>
<p>小计:{totalPrice} 元</p>
<button onClick={increase}>
加一
</button>
</section>
)
JSX 是本次渲染对界面的描述。React 会把这次结果和上一次结果进行比较,再把需要变化的部分提交到 DOM。
数量从 1 变成 2 时:
| 页面内容 | 前一次结果 | 新结果 | DOM 是否需要变化 |
|---|---|---|---|
| 单价 | 99 | 99 | 不需要 |
| 数量 | 1 | 2 | 需要 |
| 小计 | 99 | 198 | 需要 |
| 按钮文字 | 加一 | 加一 | 不需要 |
组件函数执行和 DOM 修改发生在不同阶段。下面把一次点击中的状态更新、重新计算和页面提交放到同一条线上。

图中需要重点区分返回新 JSX 和修改 DOM。React 会先完成组件计算,再根据前后结果决定哪些 DOM 内容需要更新,至于没有变化的单价和按钮节点不必重新创建,这也是性能优化的常见手段。
组件函数里的计算重新执行了,但页面最终只需要更新数量和小计对应的文本。
React 官方把这个过程分成渲染与提交:
- 渲染阶段调用组件,计算这次应该显示什么;
- 提交阶段把必要变化应用到 DOM。
所以看到组件日志再次打印,只能说明组件函数被调用了,不能直接证明 DOM 节点全部被替换。
五、Effect 会不会跟着每次组件执行
组件重新执行时,组件函数中的 useEffect 调用也会再次经过,但 Effect 中的同步逻辑是否重新执行,要看依赖是否变化。
下面的页面根据 orderId 记录当前正在编辑哪张订单:
jsx
import {
useEffect,
useState,
} from 'react'
export default function OrderEditor({
orderId,
}) {
const [quantity, setQuantity] = useState(1)
useEffect(() => {
console.log('开始同步订单:', orderId)
return () => {
console.log('停止同步订单:', orderId)
}
}, [orderId])
return (
<button
onClick={() => {
setQuantity(previous => previous + 1)
}}
>
数量:{quantity}
</button>
)
}
点击按钮时,quantity 变化,OrderEditor 会重新执行。由于 orderId 没变,这个 Effect 不需要因为数量变化重新同步。
当父级传入新的 orderId 时,React 会在合适的提交阶段先处理旧 Effect 的清理,再使用新 orderId 执行 Effect。
这里需要区分:
text
组件函数重新执行
≠ 每个 Effect 都重新同步
Effect 用来与 React 外部系统保持同步,例如请求、订阅、定时器或浏览器事件。总价、按钮文案这类由当前 Props 和 State 得出的内容,直接在渲染阶段计算,不需要先存 State,再用 Effect 同步一次。
开发环境启用 Strict Mode 时,React 可能额外调用组件函数,并对 Effect 执行额外的设置与清理检查,用来暴露不纯渲染和清理不完整的问题。控制台日志可能因此比预想更多,不能仅凭开发环境日志次数推断生产环境执行次数。
六、为什么渲染期间不适合修改外部数据
既然组件函数可能多次执行,渲染期间的代码应该只负责根据输入计算 JSX。
下面的写法在组件函数里直接发送统计数据:
jsx
const records = []
export default function OrderEditor({
orderId,
}) {
records.push({
orderId,
time: Date.now(),
})
return <p>订单:{orderId}</p>
}
每次重新渲染都会向组件外部的数组追加记录。页面状态更新、父组件更新或开发环境检查,都可能增加记录数量。渲染结果不再只由当前 Props 决定,执行次数也开始影响外部数据。
类似的操作还包括:
- 在组件函数体中直接发请求;
- 修改模块级数组或对象;
- 直接操作 DOM;
- 启动定时器;
- 写入浏览器存储。
用户点击后明确发生的操作,可以放在事件处理函数中;需要跟随组件显示状态与外部系统同步的操作,可以放在 Effect 中,并补上依赖和清理逻辑。
渲染期间可以安全完成的工作,通常是不会改变外部环境的计算:
jsx
const totalPrice = unitPrice * quantity
const canSubmit = quantity > 0
const visibleItems = items.filter(item => {
return item.visible
})
即使这些表达式再次执行,只要相同输入得到相同结果,就不会因为执行次数改变外部状态。
七、组件函数重新执行是否需要立刻优化
页面中打印出多次组件日志,不等于已经出现性能问题。
重新执行包含的工作很轻时,例如字符串拼接、条件判断和少量数组处理,额外增加 useMemo、useCallback 或 memo 可能只会让代码更难读。
需要继续排查的情况通常更具体:
- 一次渲染执行了明显昂贵的计算;
- 大型列表在频繁输入时持续卡顿;
- 高层状态更新带动很大一片组件树执行;
- 子组件依赖稳定引用,却持续收到新对象或新函数;
- Effect 因依赖不稳定反复建立连接或发送请求。
可以先用 React DevTools Profiler 观察哪些组件参与了提交、一次更新耗时多久,再决定是否拆分状态、缩小组件范围或加入记忆优化。
八、排查重新渲染时可以先看哪些位置
遇到类似于这个组件为什么又执行了这类问题,可以按下面的顺序检查。
第一,确认是谁提交了状态更新:
jsx
setQuantity(previous => previous + 1)
状态属于当前组件时,它会请求当前组件重新渲染。
第二,检查组件函数体里有哪些普通计算。局部变量、条件判断、数组处理和函数声明会随着组件调用重新执行。
第三,区分组件执行和 DOM 提交。日志重复不代表所有节点发生修改,需要观察页面结果或使用开发工具检查提交范围。
第四,检查副作用是否放错位置。请求、订阅和定时器不应直接写在渲染过程里。
第五,检查 Effect 依赖。组件重新执行时,Effect 是否重新同步取决于依赖和具体配置,不能把两者直接画等号。
第六,确认开发环境是否启用了 Strict Mode。额外日志可能来自开发检查,也可能来自真实状态更新,需要结合触发操作判断。
九、小结
状态更新后,React 会再次调用相关组件函数。函数体中的局部计算、条件判断、事件函数和 JSX 都会基于这一轮 Props 与 State 重新得到结果,State 本身则由 React 在多次渲染之间保存,不会因为组件从头执行就回到初始值。
组件返回新 JSX 后,React 再比较前后结果,只把需要变化的内容提交到 DOM。组件日志打印两次和页面节点重建两次并不是一回事。Effect 也有自己的依赖与清理规则,不能因为组件执行了,就推断每个副作用都重新执行。
下次排查重新渲染,可以先找到更新来源,再检查函数体中的计算、Effect 依赖和最终 DOM 变化。只有确认渲染范围或计算成本造成实际问题后,才需要继续考虑组件拆分和记忆优化。
参考资料
- React 官方文档:Render and Commit
- React 官方文档:State as a Snapshot
- React 官方文档:Keeping Components Pure
- React 官方文档:Synchronizing with Effects
- React 官方文档:You Might Not Need an Effect
配图与流程图建议
图 1:一次状态更新中的渲染与提交
建议插入位置:
放在"## 四、返回新的 JSX 后,DOM 为什么没有全部重建"的对比表格之后。
图前过渡句:
图后解释句:
图注:
图 1:数量更新后,组件重新计算 JSX,React 只提交数量与小计的变化。
中文绘图提示词:
发布前自查
- 从订单数量更新后日志再次打印的页面现象开始。
- 默认使用函数组件与 Hooks,没有混入类组件、Next.js 或状态管理库。
- 没有从
useState的基础定义开始罗列 API。 - 清楚区分了状态更新、组件函数执行、JSX 计算和 DOM 提交。
- 说明了局部变量、派生结果、事件函数和 State 在重新渲染中的不同表现。
- 没有把 Effect 当成派生状态工具。
- 说明了 Strict Mode 可能影响开发环境日志观察。
- 没有把
useMemo、useCallback和memo写成默认配置。 - 代码块后说明了运行结果与未覆盖范围。
- 小结给出了重新渲染问题的排查顺序和性能判断边界。