从一次“按钮自己执行”出发:理解 React 的渲染语义、JSX 求值与副作用边界(并对照 Vue)

你在 React 里写下这样一行:

<button onClick={handleDelete(id)}>Delete</button>

页面一渲染就把数据删了——你甚至还没点。

很多人会把这当成“语法坑”:记住传参时要写成 () => handleDelete(id) 就完事了。但如果停在这里,你只是背了一个口诀;下一次遇到 StrictMode、闭包旧值、依赖数组、memo 误重渲染等问题,仍然会觉得“React 怎么这么多坑”。

更有价值的视角是:这不是语法细节,而是 React 的运行时契约在向你露出冰山一角。React 的核心不在“模板写得像不像 HTML”,而在它如何组织“渲染”和“更新”:

  • render 阶段做纯计算
  • 副作用必须出现在明确边界(事件回调或 effect)
  • JSX 不提供模板语义特例,它直接服从 JavaScript 的求值规则

本文就用这个小问题为入口,系统解释 React 的侧重点:render 是什么、JSX 如何运行、副作用为什么敏感,并和 Vue 的模板语义对照。最后还会加一段工程实践味的收束,告诉你什么时候需要关心“箭头函数带来的引用变化”。

0. 先建立一个“运行时坐标系”:render 与副作用到底是什么

很多困惑来自于:你以为你在写“页面”,但 React 让你在写“函数执行后的结果”。因此我们先把两块地基补齐。

0.1 render(渲染阶段)是什么?

在 React 中,组件本质上就是一个函数:

function App(props) {
  return <div>Hello</div>
}

一次更新大致分为两段(概念上):

  1. Render phase(渲染阶段):React 执行组件函数,计算出“下一次 UI 应该长什么样”(一棵元素树/虚拟结构)。
  2. Commit phase(提交阶段):React 把差异落地到真实 DOM,处理 ref,并在适当时机运行 effect 等。

所以更贴近本质的说法是:

render 不是“画页面”,而是“计算 UI 描述”。
渲染阶段更像一次纯函数求值:同样的输入(props/state),应该尽可能得到同样的输出(UI 描述)。

React 越是强调这个“计算”属性,你就越能理解它为什么讨厌在 render 里做某些事。

0.2 副作用(side effect)是什么?

副作用就是:

任何会影响组件外部世界的操作,或依赖外部世界导致结果不稳定的操作。

常见副作用包括:

  • 发请求、读写 localStorage/cookie
  • 直接操作 DOM、读 layout 信息
  • 开定时器、订阅事件、监听 websocket
  • 路由跳转、弹窗、写日志
  • 提交/删除数据(会改变外部数据源)

React 希望副作用发生在明确边界里(事件回调、useEffect),而不是混在渲染计算路径中,因为渲染阶段可能会被重复执行、重算、甚至中断重来(这对正确性和可预测性极其重要)。

你现在提的“为什么要多包一层箭头函数”,本质上就是:把副作用从 render 边界内挪出去

1. JSX 的真相:不是模板语言,而是“表达式树的语法糖”

很多人把 JSX 当成模板语法:{} 像模板插值。更准确的理解是:

JSX 是一种更好写的函数调用形式;{} 只是把 JavaScript 表达式嵌进去,并在执行时立即求值。

例如:

<div className="a">{x + 1}</div>

会被编译成类似:

React.createElement("div", { className: "a" }, x + 1)

注意这里的 x + 1:这就是普通 JavaScript 表达式,它会在这行代码执行时求值。
而这行代码又发生在什么时间?发生在 render 阶段执行组件函数 时。

因此可以把 JSX 的 {} 理解为一句非常硬核的宣言:

这里不是模板引擎,这里就是 JavaScript。你写的表达式,会在渲染时被执行/求值。

这也是为什么很多 React “坑”看起来像框架问题,实际上是 求值时机 问题。

2. 事件处理器需要“函数”,而你写的是“函数调用”

React 的事件属性(例如 onClick)需要你提供一个回调函数:

<button onClick={handleDelete}>Delete</button>

这句传递的是函数引用。React 会在点击发生时调用 handleDelete

但你写的这一句:

<button onClick={handleDelete(id)}>Delete</button>

在 JavaScript 中只有一个含义:立刻调用 handleDelete,并把返回值交给 onClick

结合前面 JSX {} 的“渲染时求值”语义,实际执行顺序就是:

  1. 组件 render:执行组件函数
  2. JSX 求值:执行 handleDelete(id)
  3. 删除发生(副作用发生在 render 阶段)
  4. onClick 收到的不是函数,而是返回值(常见是 undefined

你以为自己在“绑定点击事件”,实际你在“渲染时就执行删除”。

这就是 bug 的根因:把副作用塞进了渲染计算路径

3. () => handleDelete(id) 不是样板:它在做两件“非常 React”的事

于是你会看到正确写法:

<button onClick={() => handleDelete(id)}>Delete</button>

很多人只把它理解为“延迟执行”。延迟执行确实是第一层,但它还暗含第二层:捕获渲染帧(闭包)

3.1 把“现在执行”改为“以后执行”:副作用从 render 转移到 event

onClick 需要函数,那我们就把它变成函数:

onClick={() => handleDelete(id)}

等价于:

onClick={function () { handleDelete(id) }}

render 阶段只创建回调函数,不执行删除。点击时 React 调用回调,副作用才发生。

你显式地写出了一个边界:

  • render:算 UI
  • click:做事

这就是 React 的核心契约之一。

3.2 闭包捕获“当前渲染帧”:解释很多后续现象

(() => handleDelete(id)) 会捕获当次 render 的变量环境:id、props、state、以及当前函数引用。
这意味着:每次 render 都在生成一套新的 UI 描述 + 新的一组回调,它们代表“这一帧”的视图。

理解这一点后,许多 React 现象会变得自然,例如:

  • 为什么 setCount(c => c + 1) 更稳健:因为它不依赖闭包里的旧 count
  • 为什么依赖数组重要:因为 effect/callback 捕获的是某一帧的数据
  • 为什么会出现 stale closure:因为你拿着旧帧的回调在未来执行

因此,“多包一层箭头函数”不是小技巧,而是把你引向 React 的运行时模型:渲染是一帧帧的计算

4. 为什么 React 对“render 里做副作用”如此敏感:可重复、可中断、可组合

到这里,可以把这次 bug 提升为框架设计原则:

如果 render 阶段是纯计算,React 就拥有很强的自由度:

  • 可以重复执行以校验一致性
  • 可以在某些情况下提前计算下一帧
  • 可以更好地做 diff、做优化、做组合(比如把子树跳过、重用等)

一旦你把副作用塞进 render,事情就变得危险:

  • render 多执行一次,副作用就多执行一次
  • 渲染中断重来,副作用可能“半途发生”
  • UI 和数据之间的因果关系变得不可推理

所以 React 才会提供明确的副作用出口:

  • 事件回调(点击、输入、提交等用户触发的时刻)
  • EffectsuseEffect 等在提交后运行的副作用通道)

这也解释了为什么 onClick={handleDelete(id)} 被视为“概念性错误”:它把删除动作放进了渲染计算路径。

5. 对照 Vue:为什么 Vue 模板里 handleDelete(id) 不会立刻执行?

同样需求在 Vue 里常写成:

<button @click="handleDelete(id)">Delete</button>

很多人因此觉得 Vue “更不容易踩坑”。但关键原因不是 Vue 更聪明,而是 Vue 的模板语义层做了特例

5.1 Vue 模板是带语义的 DSL:事件表达式由框架安排求值时机

在 Vue 模板里,@click="handleDelete(id)" 不是“渲染时立刻调用函数”,而是“事件处理表达式”。Vue 编译器会把这段表达式编译成一个事件处理函数,点击时才执行。

你可以把它理解为:Vue 替你生成了那层包裹。

5.2 React 的 JSX 坚持:没有模板特例,只有 JavaScript 求值规则

React 不把 JSX 当作 DSL,它更像“写 JS 生成 UI 描述”。
因此 onClick={...} 内的表达式就是普通 JS 表达式,在 render 求值。想延迟执行,就必须显式提供函数。

一句话总结差异:

Vue 更倾向在模板语义层替你安排“何时求值”;React 更倾向把求值时机完全交给 JavaScript,让你显式表达运行时边界。

这不是优劣,而是取舍:

  • Vue 的“模板语义”帮助你少踩一些求值时机坑
  • React 的“一致性”让你拥有更统一的抽象(但也要求你更理解 JS 与运行时)

6. 什么时候需要担心“JSX 里的箭头函数”?

读到这里,可能会被追问:
“那我是不是应该尽量避免在 JSX 里写 () => ...?会不会性能差?”

这里的答案应该更像工程决策,而不是口号。

6.1 先说结论:大多数情况下,直接写箭头函数完全没问题

在绝大多数业务 UI 中:

  • 创建一个小回调函数的成本很低
  • 事件触发频率远低于 render 的频率
  • 真正的瓶颈通常在渲染树规模、列表、重排、数据处理等

所以不要因为“听说会创建新函数”就过度重构。React 的首要目标仍然是:语义正确、边界清晰

6.2 真正会“产生影响”的场景:你把回调作为 props 传给 memo 子组件

问题通常出现在这种组合里:

  • 父组件每次 render 都创建新函数:() => handleDelete(id)
  • 这个函数作为 props 传给子组件
  • 子组件被 React.memo(或类似)包裹,依赖 props 浅比较
  • 因为函数引用每次都变,浅比较认为 props 变了 → 子组件仍然重新渲染

此时你会观察到:明明数据没变,子组件却一直渲染。
这不是“箭头函数错”,而是 你开始在意引用稳定性

6.3 需要稳定引用时的常见处理方式

方式 A:useCallback 稳定函数引用(最常见)

const onDelete = useCallback(() => {
  handleDelete(id)
}, [id, handleDelete])

然后:

<Child onDelete={onDelete} />

这样在依赖不变时,onDelete 引用不会变化,memo 子组件可以被有效跳过。

方式 B:在子组件内部接收参数(把“传参”下沉)

有时更合理的是改变接口,让子组件在点击时把 id 交回来:

<Child id={id} onDelete={handleDelete} />

子组件:

<button onClick={() => onDelete(id)}>Delete</button>

这样父组件不用为每个 item 创建一堆闭包回调(尤其在大列表里会更清爽)。

工程上最重要的不是“永远 useCallback”,而是:当你确实需要 memo 生效、确实观察到不必要渲染时,再用稳定引用方案。

6.4 最后强调一次:先正确,再优化

无论你选择哪种工程手段,都不应该回到 onClick={handleDelete(id)} 这种把副作用塞进 render 的写法。
React 的第一原则仍然是:渲染计算保持纯,副作用在明确边界发生

结语:React真正教你的不是“要写箭头函数”,而是“要写出运行时边界”

回到最初那行正确代码:

onClick={() => handleDelete(id)}

它不是样板,也不是 React 的怪癖,它是一句明确的运行时声明:

  • render 阶段只计算 UI
  • 点击发生时才执行删除(副作用)
  • 当前渲染帧的数据通过闭包传递给回调

当你把这条边界刻进心智模型里,React 的很多“看似玄学”的规则会变得统一:hooks 的规则、effect 的存在、StrictMode 对副作用的敏感、闭包旧值问题、以及 memo/useCallback 的工程意义——它们都围绕同一件事:让“函数式渲染”可预测、可优化、可组合。

名词解释 | 点击展开

渲染与运行时

  • Render phase(渲染阶段):React 执行组件函数、计算下一棵 UI 描述树(元素树),
    尽量要求纯计算、无副作用
  • Commit phase(提交阶段):把渲染计算出的变化真正应用到 DOM; 在这个阶段周边会处理 ref、触发生命周期
    / 执行 effect 等(总之更接近“落地到外部世界”)。
  • 副作用(Side effect):任何会影响组件外部世界或依赖外部世界导致不可预测的操作,
    例如请求、订阅、写存储、DOM 操作、路由跳转、提交/删除数据等;
    应放在事件回调或 effect 中,而不是 render 中。

 

JSX 与元素树

  • JSX:一种语法糖,编译后是普通 JavaScript 调用 (例如
    React.createElement / 新 JSX runtime 的调用);
    {} 内是 JS 表达式,会在 render 求值。
  • 元素(React element)/ 元素树:组件 render 的返回结果(对 UI 的描述对象); React 用它来进行 diff 和更新
    DOM。
  • Reconciliation(协调 / 调和):React 对比“上一次元素树”和“这一次元素树”, 计算出最小更新集合的过程(diff
    发生在这里)。
  • Virtual DOM(虚拟 DOM):更偏社区通俗说法;严格讲 React 操作的是 “元素树 / 纤程树”等内部结构,
    用来计算如何更新真实 DOM。

 

事件系统与回调

  • 事件回调(Event handler / callback):传给 onClick 等事件属性的函数引用; React
    在事件发生时调用它。
  • SyntheticEvent(合成事件):React 封装的跨浏览器事件对象体系 (统一事件接口,并配合事件系统工作)。
  • 事件委托(Event delegation):把大量子元素事件监听“委托”到更高层统一处理, 减少原生监听器数量; React
    的事件系统与此理念相关 (具体实现会随版本演进)。

 

Hooks 与闭包相关

  • Hooks:以 useXxx 形式在函数组件中使用 React 能力
    (state、effect、memo 等)的 API。
  • Hooks 规则(Rules of Hooks):必须在组件顶层调用,不能在条件 / 循环 / 嵌套函数里调用; 原因是 React
    需要稳定的调用顺序来把 hook 状态绑定到当前组件。
  • 闭包(Closure):函数捕获其创建时的外部变量环境; 在 React 中,事件回调 / effect
    往往捕获“某一次 render 帧”的 props/state。
  • Stale closure(闭包旧值):回调 / effect 捕获了旧 render 的 state/props,
    导致未来执行时读到“过期值”的现象; 常用函数式更新或正确的依赖声明来避免。
  • 依赖数组(Dependencies array)useEffect / useMemo / useCallback 的第二个参数;
    决定何时复用缓存、何时重新计算 / 重新订阅。

 

Effect

  • useEffect:在 commit 后运行副作用(异步、不会阻塞绘制);
    适合数据请求、订阅、写存储等。
  • useLayoutEffect:在 commit 后、浏览器绘制前同步运行; 适合需要读写布局并避免闪烁的场景
    (但更容易阻塞绘制,慎用)。
  • 清理函数(cleanup):effect 返回的函数,用于取消订阅、清理计时器等, 防止泄漏和重复订阅。

 

性能与缓存

  • React.memo:对函数组件做记忆化。
  • 浅比较(Shallow compare):只比较一层引用。
  • 引用稳定性(Referential stability):保持引用不变。
  • useMemo:记忆化一个值/计算结果。
  • useCallback:记忆化函数引用。
  • 函数式更新(Functional update)setState(prev => next)</code >。
  • StrictMode(严格模式):开发环境下暴露不安全模式。

 

与 Vue 对照时的概念

  • 模板(Template)/ DSL 语义:Vue 模板是带语义的声明式语言; 编译阶段替你安排求值时机。
  • 编译期(Compile-time) vs 运行时(Runtime):Vue 更多语义在编译期处理; React JSX 更接近运行时 JS 表达式。