我们在 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 把两件在物理上完全独立的事情,强行绑定在了同一个载体上:
- DOM 结构的装配关系(UI 拓扑)
- 状态更新的调度范围(更新拓扑)
在 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 付出了昂贵的心智代价:
- 极其严苛的函数纯度要求:组件函数随时可能被执行一半就扔掉,因此不能有任何不可控的副作用;StrictMode 故意在开发环境挂载两次,逼迫开发者处理代码中的不纯行为。
- 闭包地狱(Stale Closures):函数不断重跑,回调函数容易捕获上一帧的旧状态,必须借助 ESLint 规则和庞大的依赖项数组来人工防灾。
- 沉重的运行时体积:数万行链表操作、优先级位运算与启发式调度算法,全部打包进了客户端 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 观察者模式:
A变更,立刻通知B,B重新计算并通知D;- 此时
D立即执行,它读到了新的B,但读到的却是旧的C(发生中间状态撕裂,产生 Glitch); - 接着
A通知C,C重新计算并通知D; 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。 当底层的核心假设是“全量级联重算”时,任何精巧的补丁,都无法掩盖地基上的裂痕。
函数只执行一次,代码不再撒谎。