跳转到正文
ieuforuruntime notes
↩ WRITING

React Runtime / 09 MIN

从组件树到反应图:函数只执行一次与细粒度响应式的终局解脱

当 Render Count 永远钉死在 1,我们才意识到:Fiber 只是为了抢救整树重渲染而发明的昂贵止痛药。

9 MIN READ

我们在 React 里被规训了太久。

久到我们以为:

“状态改变,组件函数就理所当然该重新执行一遍。”

久到当一个输入框卡顿、列表掉帧时,我们习惯性反思自己:

  • 是不是少写了 useCallback?
  • 是不是传递了未稳定的内联对象破坏了浅比较?
  • 是不是没遵从 ESLint 依赖项规则导致了陈旧闭包(Stale Closure)?
  • 为什么把 deps 填全之后,反而陷入了无限重渲染的死循环?

我们把一个架构设计上的根本性缺陷,当成了自己的能力不足。

直到我们在 SolidJS 里写下了一个层级嵌套组件,把顶层状态累加到 100:

App (根组件)
 ├── Render Count: 1
 │
 ├── Child A
 │    ├── Render Count: 1
 │    └── GrandChild A (消费 Signal)
 │         ├── Render Count: 1
 │         └── 实时值: 100
 │
 └── Child B
      ├── Render Count: 1
      └── GrandChild B (静态孙组件)
           └── Render Count: 1

所有组件函数的执行次数,永远钉死在 1。

屏幕上的数字在毫秒级跳动,真实 DOM 节点精准变化。 但从顶层 App 到中间容器再到底层孙组件,所有的函数体从初次挂载完成后,就再也没有被调用过第二次。

在原生 JavaScript 里,函数执行一次就结束,本就是最朴素的语言直觉。 到底是谁把前端开发拖进了“无休止重执行”的心智泥潭?


一、被绑架的组件树(The Entangled Tree)

React 的原罪,始于那个著名的函数式公式:

UI = f(state)

这个公式在理论上极其优雅,但在工程落地时被偷换了概念。

React 把两件在物理上完全独立的事情,强行绑定在了同一个载体上:

  1. DOM 结构的装配关系(UI 拓扑)
  2. 状态更新的调度范围(更新拓扑)

在 React 的世界观里: 组件树既是搭建页面的积木图纸,又是状态更新瀑布的唯一下泄管道。

Parent State 改变
       ↓
Parent 函数重新执行
 ├── Child A 函数重新执行
 │    └── GrandChild A 函数重新执行
 └── Child B 函数重新执行
      └── GrandChild B 函数重新执行

由于影响范围默认是整棵子树全量级联(Cascading Re-render),开发者的日常工作被迫蜕变成了一个“人肉修剪工”:

  • React.memo:人肉在树干上设卡拦截。
  • useCallback:防止下游缓存因为函数引用失效而被击穿。
  • useMemo:阻止昂贵的子树发生连带重算。

更讽刺的是: 为了让 memoizedProps === pendingProps 成立,你必须时刻提防内联对象、解构赋值和回调闭包。 稍微漏掉一个依赖项就是 Bug,全写上去又容易引发级联重算。

我们写的大部分防御性代码,根本不是在实现业务逻辑,而是在替 React 的整树 Re-render 擦屁股。


二、Fiber:昂贵的止痛药

既然整树递归 Diff 会卡死主线程、导致输入框无响应,React 官方该如何收场?

摆在架构师面前的其实有两条路:

  • 方案 A(根治问题):干脆放弃整树 Diff,转向细粒度响应式。
  • 方案 B(坚持教条):继续坚持“函数必须全量重算”的纯函数哲学,但在外层包一套极为复杂的调度引擎。

React 选择了方案 B,这就是 Fiber 架构。

它用普通对象链表替换了浏览器的原生调用栈:

Fiber Node
 ├── return        // 父节点
 ├── child         // 首个子节点
 ├── sibling       // 兄弟节点
 ├── memoizedState // hooks 链表
 └── lanes         // 优先级位掩码

配合独立的 scheduler 包,利用 MessageChannel 启动宏任务:

workLoopConcurrent
       ↓
执行 5ms
       ↓
shouldYield() === true
       ↓
让出主线程给浏览器排版与绘制
       ↓
下一个宏任务继续从断点恢复

这套设计在工程学上极其宏大,在学术上近乎完美,衍生出了后续一整套宏伟的叙事:

  • Concurrent Mode(并发渲染)
  • Time Slicing(时间切片)
  • Suspense(基于 Promise 暂停的 Fiber 子树)
  • React Server Components(将部分 Fiber 挪至服务端串流)

但它掩盖了一个残酷的事实:

Fiber 并没有消灭哪怕一次多余的无效计算。它只是把本该卡死的巨量计算切成碎片,分期付款。

为了维持这套能够在后台随时暂停、恢复、丢弃的复杂调度系统,React 付出了昂贵的心智代价:

  1. 极其严苛的函数纯度要求:组件函数随时可能被执行一半就扔掉,因此不能有任何不可控的副作用;StrictMode 故意在开发环境挂载两次,逼迫开发者处理代码中的不纯行为。
  2. 闭包地狱(Stale Closures):函数不断重跑,回调函数容易捕获上一帧的旧状态,必须借助 ESLint 规则和庞大的依赖项数组来人工防灾。
  3. 沉重的运行时体积:数万行链表操作、优先级位运算与启发式调度算法,全部打包进了客户端 Runtime。

如果一次状态更新从源头上只需要 0.1ms,谁还需要时间切片?


三、编译底牌:从 JSX 抽象到 cloneNode 原生突变

很多人以为 Solid 只是“另一个写 JSX 的框架”。 看一眼两者编译后的物理产物,就能看清底层的鸿沟。

React 的编译产物:不可休止的 Plain Object 分配

在 React 里:

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <button className="btn" onClick={() => setCount((c) => c + 1)}>
      Count: {count}
    </button>
  )
}

Babel / SWC 会将其编译为:

import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime'

function Counter() {
  const [count, setCount] = useState(0)
  return _jsxs('button', {
    className: 'btn',
    onClick: () => setCount((c) => c + 1),
    children: ['Count: ', count],
  })
}

每次点击,Counter 重新执行。 _jsxs 重新分配全新的 Object 内存,Reconciler 重新比对 Props 与 Children。所有的 JavaScript 引擎都在为垃圾回收(GC)和对象分配疯狂空转。

Solid 的编译产物:浏览器原生 Template 克隆

同样的逻辑在 Solid 源码中:

function Counter() {
  const [count, setCount] = createSignal(0)
  return (
    <button class="btn" onClick={() => setCount((c) => c + 1)}>
      Count: {count()}
    </button>
  )
}

Solid 编译器(babel-plugin-jsx-dom-expressions)生成的真实产物本质上是:

import { template as _$template } from 'solid-js/web'
import { createRenderEffect as _$createRenderEffect } from 'solid-js'

// 1. 静态 HTML 模板,仅在模块加载时解析一次
const _tmpl$ = /*#__PURE__*/ _$template(`<button class="btn">Count: <!></button>`)

function Counter() {
  const [count, setCount] = createSignal(0)

  return (() => {
    // 2. 利用浏览器底层 C++ 极速克隆原生 DOM 树
    const _el$ = _tmpl$.cloneNode(true)

    // 3. 事件委托挂载到原生节点
    _el$.$$click = () => setCount((c) => c + 1)

    // 4. 找到动态占位符节点(TextNode)
    const _el$2 = _el$.firstChild.nextSibling

    // 5. 唯一定死在内存中的细粒度响应式长连接
    _$createRenderEffect(() => (_el$2.data = count()))

    // 6. 返回原生真实 DOM 元素
    return _el$
  })()
}

这就是物理现实的差距:

  • 没有 Virtual DOM:初次挂载直接调用原生的 cloneNode(true),由浏览器底层的 HTML 解析器直接创建真实 DOM。
  • 函数执行完毕即销毁:Counter() 执行返回 _el$ 后,其函数执行上下文彻底结束。
  • 直击 DOM API:留在内存中的只有那行 createRenderEffect。当 setCount 触发时,直接调用原生 DOM API _el$2.data = count()。

没有调和器,没有树形比对,没有中间抽象。


四、所有权图谱:Solid 如何实现 Law 3 (Ownership Tree)

在深入 React 时,我们总结过 Law 3 — Fiber Ownership(谁创建?谁负责维护?谁负责清理?)。

许多人对细粒度响应式的第一反应是:

“如果 Signal 是全局平铺的长连接,没有 Fiber 树保驾护航,组件卸载时副作用怎么清理?不会内存泄漏吗?”

答案是:Solid 把“UI 树”与“所有权树(Ownership Tree)”彻底解耦了。

1. 内存中的计算所有权树

当组件挂载时,Solid 在运行时同步构建了一张独立于 DOM 之外的 Computation Ownership Graph:

[Root Owner] (例如 render() 入口)
   │
   ├── [Owner: Counter]
   │      ├── Effect: _el$2.data = count()
   │      ├── Cleanups: [ () => window.removeEventListener(...) ]
   │      └── Context Map: { Theme: 'dark' }
   │
   └── [Child Owner: <Show>]
          ├── Nested Effect A
          └── Nested Effect B
  • 每个 createEffect、createMemo 在被调用时,都会自动查找全局执行上下文里的 currentOwner,把自己注册为它的子节点。
  • 当你注册 onCleanup(fn) 时,清理函数直接挂载在当前的 Owner 节点上。

2. 确定性销毁(Deterministic Disposal)

在 React 里,组件卸载需要等完整的 Commit 阶段遍历 Fiber 树、比对标记后,再在 passive 阶段异步执行 cleanup。

而在 Solid 里,当一个条件分支被关闭(例如 <Show when={false}>):

Condition 变为 false
       ↓
直接调用该子域 Owner 的 dispose()
       ↓
递归清空其所有的 Computation 订阅与 onCleanup 回调
       ↓
原生 DOM 节点从父容器 removeChild

没有脏标记回溯,没有跨帧调度。 所有权模型依然极其严格,但它完全脱离了组件树渲染的束缚。


五、控制流的颠覆:为什么必须是 <Show> 与 <For>

很多刚接触细粒度响应式的开发者,最常踩的一个坑是: “为什么我在 JSX 里写 {show() ? <Admin /> : <User />} 会发生意外?”

理解这个问题的底层原因,就能彻底理解“函数只执行一次”的思维模型。

1. 三元表达式在 React 中的运作机理

在 React 里,三元表达式之所以能动态切换:

// React
function App() {
  const [show, setShow] = useState(true)
  return <div>{show ? <Admin /> : <User />}</div>
}

是因为每次 setShow,App() 函数就会从头到尾重跑一遍。 重跑时重新计算三元表达式,返回新的 ReactElement,再交由 Reconciler 做 Diff。

2. 函数只跑一次的必然代价

如果在 Solid 里写纯三元表达式:

// Solid (错误的使用方式)
function App() {
  const [show, setShow] = createSignal(true)
  // App 只执行一次!
  // 在执行这一行时,show() 是 true,它直接创建并返回了 <Admin/> 的真实 DOM!
  return <div>{show() ? <Admin /> : <User />}</div>
}

因为 App() 终生只执行一次,这个三元表达式在初始化时求值后就固化了。后续即使 show 变了,外部函数也不会重跑。

3. <Show> 的真身:微型插槽状态工厂

Solid 必须提供 <Show> 和 <For> 等内置控制流组件:

<Show fallback="{<User" when="{show()}"/>}>
  <Admin/>
</Show>

因为 <Show> 根本不是什么模板语法糖,它本身就是一个封装了计算所有权的独立工厂:

  • 它内部包含一个 createMemo 监听 when 的变化;
  • 当状态切换时,它负责调用旧分支的 dispose() 销毁旧节点的所有权图谱;
  • 随后创建新分支的 DOM,利用原生的 replaceChild 或注释节点占位符替换屏幕上的内容。

在细粒度世界里,条件渲染不再是整个函数逻辑的重走,而是局部 DOM 插槽的确定性置换。


六、菱形缺陷(The Diamond Problem)与 Push-Pull 调度

React 官方经常拿并发模式、Batching 和“防止状态撕裂(Tearing)”作为护城河。 那么,平铺的响应式图谱在面对复杂拓扑时,如何保证计算的一致性?

最经典的拓扑挑战就是菱形依赖问题(The Diamond Problem):

         A (Signal)
        / \
       /   \
  B (Memo)  C (Memo)
       \   /
        \ /
     D (Effect)

如果采用简单的 EventEmitter 观察者模式:

  1. A 变更,立刻通知 B,B 重新计算并通知 D;
  2. 此时 D 立即执行,它读到了新的 B,但读到的却是旧的 C(发生中间状态撕裂,产生 Glitch);
  3. 接着 A 通知 C,C 重新计算并通知 D;
  4. D 再次执行。

D 不仅执行了两次,还短暂暴露了不一致的脏状态。

Solid 的解法:Push-Pull 两阶段算法

Solid 通过极轻量的 Push-Pull 算法,在零运行时体积下彻底消灭了 Glitch:

阶段一:Push(脏状态标记广播)
A 改变
 ↓
仅向下游传播 "Dirty" 标记位(不执行任何计算逻辑)
B 被标记为 STALE
C 被标记为 STALE
D 被标记为 STALE

阶段二:Pull(拓扑排序按需拉取)
微任务结束,Effect (D) 准备求值
 ↓
D 发现自己的依赖 B 和 C 是 STALE
 ↓
向上 Pull 最新数据:强制 B 和 C 依次求值固化
 ↓
B 和 C 稳定后,D 仅执行一次且读到的必定是完全同步的世界

配合 batch(() => { ... }),所有的状态变更在同一个事件循环周期内都被严密锁合。 不需要几万行的 Fiber 调度器,不需要复杂的 Lane 位运算,纯粹的图遍历算法直接完成了状态的绝对确定性。


七、Trade-off King 与极客原教旨

在响应式阵营中,Vue 3 与 SolidJS 代表了两种截然不同的演进取向:

Vue 的折中:开发者阻力最小化(Trade-off King)

Vue 的目标是兼顾历史包袱与极低的上手门槛:

  • 为了让新手免于写 .value,SFC 编译器在编译期对 <template> 做大量宏替换与隐式解包;
  • 为了允许开发者解构 Props,发明响应式语法糖或编译期引用包裹;
  • 但为了兼容庞大的动态模板生态,Vue 响应式的尽头依然停留在组件级的 VNode Patch。
  • 它牺牲了标准的 JS 语义,换取了工业级低门槛的顺畅。

Solid 的原教旨:语言规范与图论直觉(Pure Determinism)

Solid 彻底拒绝使用编译器黑魔法来掩盖语言语义:

  • 拒绝私有模板:坚持使用纯粹的 JSX/TSX,所有的逻辑都是原生 JavaScript 表达式。
  • 严格禁止解构 Props:因为在标准 ES6 语义中,解构对象属性必然意味着快照拷贝;要想追踪变化,就必须保留 Getter 属性访问(props.count)。
  • 纯粹函数语义:读取状态必须显式调用 Getter(count()),让调用者明确知晓正在进行一次依赖收集。
  • 直击原生底座:彻底剔除虚拟 DOM,把细粒度图谱直接钉在原生 DOM API 上。

对于追求运行时透明度与系统确定性的工程师来说,Solid 这种“不搞黑盒编译欺骗、把底层图论机制完全摊开”的纯粹,带来了前所未有的掌控感。


结语:跳出被规训的思维

回过头看,我们在 React 里耗费的无数心力:

  • 钻研 memo、useMemo、useCallback 的防雪崩组合拳;
  • 梳理 Concurrent Mode 下的多次挂载与竞态调度;
  • 苦苦背诵 Hook 依赖项规则,反复排查闭包时空错乱;
  • 在 Fiber、Lane、Scheduler 的繁复机制里寻找性能瓶颈。

其实都是在一个走偏了的抽象模型上,替系统内耗买单。

当组件回归为执行一次即结束的装配工厂,当状态更新还原为平铺而精准的反应图:

  • 没有依赖数组;
  • 没有无效 Re-render;
  • 没有庞大的 Fiber 链表调度;
  • 真实 DOM 节点以原生的物理极限精准跃迁。

不是你写不好 React。 当底层的核心假设是“全量级联重算”时,任何精巧的补丁,都无法掩盖地基上的裂痕。

函数只执行一次,代码不再撒谎。

同系列文章 13
  1. Thinking in React:从五个不变量理解 React UI Runtime
  2. React 不是框架,是 UI Runtime:从 Fiber 到调度器的完整心智模型
  3. React Effect:为什么副作用必须脱离 Render
  4. 让动态世界拥有确定性(二):React 如何让 UI 更新变得可调度
  5. React 19 并发全景:从用户点击到 UI 更新的完整链路
  6. RSC 设计哲学:React 如何跨越两台计算机
  7. React vs Vue 3 架构全景:从浏览器渲染管线到框架设计哲学
  8. Error Boundary 与渲染失败的 Ownership
  9. Suspense 深度解析:从 Thenable 协议到流式渲染的完整机制
  10. 状态管理的边界:Context、External Store、Atom 模型
  11. 组件设计模式:从 Runtime 约束推导组件 API
  12. React Component Tree:从 Mount 到 Fiber Runtime 的一条主线
  13. 从组件树到反应图:函数只执行一次与细粒度响应式的终局解脱