React 组件重新渲染时,到底重新执行了什么

本文是「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 每次仍然是 99totalPrice 会根据新数量重新计算。

这里不需要把 totalPrice 再保存成一份 State。它完全由 unitPricequantity 得到,在渲染期间直接计算,能避免数量更新后还要额外同步总价。

事件处理函数 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
})

即使这些表达式再次执行,只要相同输入得到相同结果,就不会因为执行次数改变外部状态。

七、组件函数重新执行是否需要立刻优化

页面中打印出多次组件日志,不等于已经出现性能问题。

重新执行包含的工作很轻时,例如字符串拼接、条件判断和少量数组处理,额外增加 useMemouseCallbackmemo 可能只会让代码更难读。

需要继续排查的情况通常更具体:

  • 一次渲染执行了明显昂贵的计算;
  • 大型列表在频繁输入时持续卡顿;
  • 高层状态更新带动很大一片组件树执行;
  • 子组件依赖稳定引用,却持续收到新对象或新函数;
  • 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 变化。只有确认渲染范围或计算成本造成实际问题后,才需要继续考虑组件拆分和记忆优化。

参考资料

配图与流程图建议

图 1:一次状态更新中的渲染与提交

建议插入位置:

放在"## 四、返回新的 JSX 后,DOM 为什么没有全部重建"的对比表格之后。

图前过渡句:

图后解释句:

图注:

图 1:数量更新后,组件重新计算 JSX,React 只提交数量与小计的变化。

中文绘图提示词:

发布前自查

  • 从订单数量更新后日志再次打印的页面现象开始。
  • 默认使用函数组件与 Hooks,没有混入类组件、Next.js 或状态管理库。
  • 没有从 useState 的基础定义开始罗列 API。
  • 清楚区分了状态更新、组件函数执行、JSX 计算和 DOM 提交。
  • 说明了局部变量、派生结果、事件函数和 State 在重新渲染中的不同表现。
  • 没有把 Effect 当成派生状态工具。
  • 说明了 Strict Mode 可能影响开发环境日志观察。
  • 没有把 useMemouseCallbackmemo 写成默认配置。
  • 代码块后说明了运行结果与未覆盖范围。
  • 小结给出了重新渲染问题的排查顺序和性能判断边界。
相关推荐
林焱_RPAAI1 小时前
影刀RPA技术深度:CSS选择器高级实战指南——伪类属性选择器与性能对比完全解析
vue.js·react.js
用户2181697049303 小时前
Flutter(四)Dart语法 空安全 运算符 流程控制
前端
许彰午3 小时前
政务督办的分合模式:主办协办的并发审批
前端·javascript·政务
用户69371750013843 小时前
AI时代,程序员该往哪走?
前端·后端
ttwuai3 小时前
GoFrame 后台日志清空失败:无 WHERE 删除为什么被拦住
前端·golang
易筋紫容3 小时前
创建型模式:对象的诞生艺术
开发语言·前端·javascript
Revolution613 小时前
第一次运行 Node.js:终端里的 JavaScript 怎样执行
后端·面试·node.js
半句唐诗3 小时前
我是如何通过 Access Token 成功发布第一个 npm 包的
前端·npm·node.js
程序员黑豆4 小时前
鸿蒙应用开发:@Provider 与 @Consumer 跨组件双向同步详解
前端·harmonyos