Max
搜索
返回故事会

为什么 Vue 不需要 React 的 Fiber 架构?

62 分钟阅读3Max ZhangFrontend
VueReact

凌晨三点,你盯着屏幕上那个该死的输入框。

用户在搜索框里打字,页面卡得像在泥浆里游泳。每次按键都要等半秒才出字。你检查了网络请求——没问题,数据早回来了。你看了 CPU 占用——飙到 100%。你的手指悬在键盘上方,脑子里只剩一个念头:卧槽,这他妈的怎么回事?

你用的是 React。用的还是最新版本。所有的 best practice 都跟了——该用 useMemo 的地方用了,该用 useCallback 的地方也用了,React.memo 裹了一层又一层。但搜索框还是卡。

你同事在旁边工位,用的 Vue。他那个项目数据量比你大十倍,表格五千行,还带实时筛选。他的搜索框打字跟本地记事本一样丝滑。你忍不住问他怎么做到的。他把耳机摘下来,眨了眨眼,想了想,说了一句:

"Vue 不需要 Fiber 啊。"

你点头。你知道他在说什么,又不知道他在说什么。

"对,Fiber。"你重复了一遍。然后你偷偷打开了一个无痕窗口,开始搜索。

别装了。你第一次听到"Vue 不需要 Fiber"这个说法的时候,心里也是一片空白的。什么叫 Fiber?为什么 React 需要而 Vue 不需要?既然不需要,那 Fiber 到底是在解决问题还是在制造问题?

我花了一整周才搞明白。读了 React 和 Vue 的源码。画了一堆图。跟同事在会议室里争论了三个下午。下面就是我想通的全过程——不是那种教程式的讲解,就是一个朋友在跟你聊聊他最近搞懂的事。


你卡了,但不是网速的问题

先搞清楚一个基本问题。你那个搜索框为什么卡?

你是不是第一反应:"网络请求太慢了?"

不是。数据早就在内存里了,你只是在前端做过滤和渲染。

"是不是写了死循环,是不是 useEffect 的依赖写错了?"

也不是。React Profiler 明明白白告诉你 render 函数执行完了,返回的值也是对的,DOM 也更新了。

"那他妈的到底在卡什么?"

浏览器的主线程被一个巨长的任务给堵死了。而且这个任务是你的框架扔进去的。

浏览器的工作原理大概是这样:它有一个主线程(Main Thread),就一根。这根线程就像一家餐厅里的大堂经理——只有一个人,要同时处理所有事情。客人进门要接待(DOM 事件),客人问菜(用户输入),厨房出菜要核对(渲染更新),收银结账要处理(动画帧),电话响了要接(网络回调)。

所有这些请求排成一条线,大堂经理一件一件处理。

正常情况下,每件事的处理时间都很短。你点击一个按钮——事件处理 + 状态更新 + DOM 操作——加起来可能就 3 毫秒。短到你完全感知不到排队的存在。

但假如大堂经理接到一道满汉全席级别的菜——需要 500 步操作才能完成。在这 500 步操作执行期间:

  • 有人在门口按铃——排队等
  • 有人在柜台问账单——排队等
  • 厨房紧接着催下一道菜——排队等
  • 电话响了——排队等

全部堆在这道满汉全席后面。

对用户来说,这 500 步操作可能持续 200 毫秒。200 毫秒是什么概念?人类对延迟的感知阈值大概在 100 毫秒左右。超过 200 毫秒你会明显感觉"卡了"。超过 500 毫秒你会感觉页面死掉了。

这就是"主线程阻塞"。

现在让我们把镜头拉近到 React 身上。

早期 React——在 Fiber 出现之前的 React——它的渲染逻辑简单粗暴得让人发指。你敢改一个状态?它就把整个组件树当成一道菜,从头到尾重新处理一遍:每个组件的 render 函数挨个执行,生成一棵全新的虚拟 DOM 树,从头 Diff 到脚,找出所有变化,一次性应用到真实 DOM。整个过程一气呵成,中间不休息。

页面 10 个组件?还行,5 毫秒搞完。页面 500 个组件?你等吧。500 次 render 调用 + 500 个虚拟节点创建 + 500 个节点的 Diff 对比——这个过程吃掉 150 毫秒不在话下。

这 150 毫秒里,用户的所有操作——点击、输入、滚动——全部失效。

这时候你可能会跳出来说:"等等!虚拟 DOM + Diff 本身就是优化啊!没有虚拟 DOM 的话,直接操作真实 DOM 不是更慢吗?"

对。但你把两件事搞混了。

优化"总工作量"和优化"阻塞时间"是两件完全不同的事。

虚拟 DOM 和 Diff 是为了减少总工作量——别去碰昂贵的真实 DOM,先在廉价的 JavaScript 对象里比对比对,找出最小变更集。这确实减少了总工作量。但有一个前提你没注意到:无论总工作量多小,只要这些工作是在主线程上一次执行完的,它就独占主线程。如果独占的时间超过了人类感知阈值(100~200ms),用户就感觉卡。

所以 React 的核心困境不是"Diff 算法太慢了怎么办"——它的 Diff 已经优化到 O(n) 了。React 的核心困境是"怎么让 Diff 这件事不要独占主线程"。

前一个问题是"优化算法"。后一个问题是"优化调度"。两个维度。

这就是 Fiber 存在的全部意义。


Fiber 到底干了什么:一个搬家工人的故事

2017 年,React 团队几乎重写了框架核心。新架构的名字叫 Fiber。

"Fiber"——纤维。一种可以无限拆分、无限细化的东西。这个名字精准描述了 Fiber 的核心思路:把一整坨渲染工作拆成无数个微小的、独立可执行的任务单元。

在 Fiber 之前,React 渲染组件就像搬家工人试图一口气扛起整个沙发——中途不能放下。放着沙发就倒了,倒了就得重来。

Fiber 的做法是:把沙发拆了。

先把坐垫搬出去。坐垫放下了,喝口水。再把靠垫搬出去。看一眼手机,有没有人发消息。再把扶手搬出去。如果有人敲门——先去开门,回来继续搬。最后搬底座。

翻译成技术语言:React 把一整轮渲染工作切成了无数个微任务。每个微任务执行时间极短——大概 1-2 毫秒。每完成一个微任务,React 去问浏览器:"在吗?有没有更紧急的事情需要处理?比如用户刚刚点了个按钮?"

如果有——React 就把当前的渲染放一放,先去响应用户操作。处理完用户的紧急请求之后,回到刚才放下的地方,继续渲染。

这三样东西共同撑起了这套机制:

时间切片。 你在看一部两小时的电影。传统方式是你必须一口气看完——不能暂停,不能上厕所,不能接电话。你的膀胱都快炸了但电影还在播。时间切片把这部电影切成无数个 30 秒小片段。每一段放完,播放器暂停问你:"继续看下一段吗?还是先看看别的频道?"

React 就是那个播放器。每个切片只有 1-2 毫秒,切片结束就检查有没有"被打断"的事。

优先级调度。 你在医院急诊室见过。胸痛的病人进来——直接插队到最前面。轻微咳嗽的——接着等。所有人都认可,因为优先级是显而易见的。

React 把用户点击、键盘输入标记为"胸痛"——最高优先级。把后台渲染、数据预加载标记为"轻微咳嗽"——低优先级。高优先级的永远可以打断低优先级的。

可中断渲染。 前两者的自然结果。既然任务被切成了时间切片,又有优先级排序,那中断和恢复就变成默认行为。


但这是我必须要讲清楚的一个技术细节——它比表面上看起来重要得多。

React 放弃用树结构来组织组件。改用链表

为什么?因为树的遍历通常靠递归——而递归有一个致命的问题:一旦进入递归调用,你就停不下来了。必须等到整个调用栈完全解开才能退出。你不能在递归中途暂停——没有"暂停递归"这个语义。

链表的遍历则完全不同。你只需要维护一个"当前节点"的指针。想停下来?把当前节点存好,退出。想恢复?从之前存的节点继续走。

在 Fiber 架构里,每个组件被表示为一个 Fiber 节点:

const fiber = {
  type: 'div', // 组件类型
  props: { className: 'container' },
  child: childFiber, // 指向第一个子节点
  sibling: nextFiber, // 指向下一个兄弟节点
  parent: parentFiber, // 指向父节点
}

三个指针——childsiblingparent——把一棵树变成了一条可以线性遍历的路径。

遍历的时候不需要递归。只需要维护一个 currentFiber 变量。想停?存好 currentFiber。回来继续?拿出 currentFiber,接着走。

这就好像看一本书。递归遍历就是"我必须一口气读完这一章"。链表遍历就是"我读到第 47 页第 3 行了——夹个书签——明天继续"。

这个"书签"机制是 Fiber 所有能力的地基。没有它,时间切片就毫无意义——你切了片但不知道怎么恢复,不如别切。

在链表之上,React 还架设了一个调度器(Scheduler)。调度器维护了那个优先级任务队列——急诊室里的排队系统。它在浏览器空闲的毫秒间隙里从队列取任务执行,每次只执行一小段,然后检查有没有新的高优先级任务需要插队。

这就是完整的 Fiber:链表做书签,时间切片做拆分,优先级调度做排队管理。三者合一,用户就可以在任何时刻打断正在进行的渲染。

再往细了说:Fiber 的工作分两个阶段。

Render 阶段(也叫 Reconciliation 阶段):这个阶段是可中断的。React 遍历 Fiber 树,对比新旧节点,找出需要更新的地方。这个阶段的所有计算都是"纯"的——不产生副作用。因为它可以被随时打断和丢弃,丢弃了也不影响任何事情。

Commit 阶段:这个阶段不可中断。React 把 Render 阶段算好的所有变更一次性应用到真实 DOM。这个阶段必须一气呵成——因为真实 DOM 操作不能做一半。但好消息是 Commit 阶段的工作量通常很小——因为在 Render 阶段已经把所有变更都算好了,Commit 只是执行。

把重活放在可中断的 Render 阶段,把轻活放在不可中断的 Commit 阶段——这就是 Fiber 为什么能做到"感觉不卡"的全部秘密。


Fiber 顺带给了 React 两个惊喜

Fiber 是冲着解决卡顿去的。但"可中断渲染"这个能力一旦有了,两个以前不敢想的事情就变成了可能。

Suspense。 解决的是"等着加载数据"的糟糕体验。

没有 Suspense 的时候,任何需要异步加载数据的组件都是这个模板:

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setLoading(true)
    fetchProduct(productId)
      .then(setProduct)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [productId])

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorDisplay error={error} />
  return <ProductDetails product={product} />
}

Loading 状态、Error 状态、数据就绪状态——每个异步组件都得手写一遍。而且你经常遇到"瀑布加载":子组件数据早到了,但父组件还在等,整个页面什么都不显示。

Suspense 反过来了:

<Suspense fallback={<LoadingSpinner />}>
  <ProductDetails id={productId} />
  <RelatedProducts id={productId} />
</Suspense>

它的意思是:"在这两个组件的数据全部就绪之前,显示 LoadingSpinner。都好了?一次性换成真实内容。"

更要命的是——用户在数据回来之前就离开了这个页面?React 直接取消还在飞的请求。不浪费请求,不浪费渲染。这个取消能力靠的就是 Fiber 的"随时可中断"——正在跑的渲染分支,Fiber 说不要就不要了。

并发模式(Concurrent Mode)。 更进一步。它允许 React 同时准备多个 UI 版本。

什么概念呢?用户在搜索框里输入"react tutorial"——14 个字符。传统模式触发 14 次完整的列表渲染。每次渲染都可能阻塞下一次输入。

并发模式的打法不同。你敲了"r"——React 开始渲染"r"的结果,但标记为低优先级。你紧接着敲了"e"——React 直接丢弃"r"的渲染,开始渲染"re"的结果。你继续敲"a"——又丢弃"re"的。

整个过程中用户的输入从未卡顿——因为渲染从来没真正阻塞过。React 随时准备扔掉半成品,响应新的指令。用户停下来,React 渲染最终版本。

startTransition 就是这个机制的入口。你把非紧急更新包进去,React 就知道"这个不重要,用户输入优先"。还有一个相关的 API 叫 useDeferredValue——它能创建一个"延迟版本"的值。即时版本响应用户输入,延迟版本用于渲染。输入框永远即时更新,渲染结果可以慢慢来。

然后到了 React 19,又多了一个大招——编译器自动优化

之前你需要手动写 useMemouseCallbackReact.memo。API 本身不复杂。但判断"这里到底要不要包"这件事——成了前端社区最折磨人的话题之一。无数工程师花过无数小时争论一个函数到底值不值得被 memoize。

React Compiler 终结了这场痛苦。它在构建时分析你的 JavaScript,自动决定哪里需要缓存,哪里需要记忆化。你写出来的就是最自然的 JavaScript——编译器在背后把优化悄无声息地插进去。

// 你写的(React 19)
function ProductList({ products, onSelect }) {
  const sorted = [...products].sort((a, b) => b.price - a.price)
  const handleSelect = (p) => onSelect(p)
  return sorted.map((p) => <ProductItem key={p.id} product={p} onSelect={handleSelect} />)
}

// 编译器自动帮你变成差不多这样
// function ProductList({ products, onSelect }) {
//   const sorted = useMemo(() => [...products].sort(...), [products])
//   const handleSelect = useCallback((p) => onSelect(p), [onSelect])
//   ...
// }

你再也不用为"这里该不该加 useMemo"失眠了。编译器替你失眠了。

编译器的工作规则其实也不复杂:如果一个变量的值完全来自 props 或 state,且在组件内没有被修改——编译器就会缓存它。如果一个函数的返回值只依赖 props 或 state,且没有副作用——编译器也会缓存它。只要你的代码遵守 React 的基本规则(不直接修改 state、不做裸 DOM 操作),编译器就能安全地自动优化。


等一下——Fiber 绝不解决什么

这里有一个几乎所有初学者都会误解的点。这也是你理解"Vue 为什么不需要 Fiber"最关键的前置知识。

Fiber 不解决"渲染太慢"。

Fiber 从来不解决。现在不解决。以后也不解决。

Fiber 不减少你要做的总工作量。render 函数该跑多少次还是跑多少次。虚拟 DOM 该创建多少节点还是多少节点。Diff 该遍历多少对象还是多少对象。真实 DOM 该更新多少还更新多少。

Fiber 改变的是这些工作的执行方式。不是你搬多少砖,而是你怎么搬。

再用一次搬砖的比喻。

你在工地上。需要把 100 块砖从东头搬到西头。

没有 Fiber:你必须一口气搬完 100 块。不能中途放下——放下的砖会倒,倒了等于白搬。不能喝水。不能看手机。如果有人在搬砖中途喊你——等着,搬完 100 块再说。

有 Fiber:你每搬 5 块就暂停一下。喝口水。看看有没有人找你。翻一眼手机。搬 5 块,检查一下。搬完 100 块砖的总时间跟一口气搬几乎一样——甚至可能因为"暂停-恢复"本身的开销,总耗时多了一丁点。

但区别在这里:任何一次"搬 5 块"的间隙,你都可以立刻回应别人。用户不会感觉你是死的。

这就是 Fiber 的全部意义。不是更快,是不阻塞。不是降总耗时,是提响应性。

现在反过来想。

如果你只需要搬 3 块砖呢?

3 块砖。一只手拎起来就走了。不需要分组。不需要中途休息。不需要检查有没有人找你。总共 2 秒搞定。就算恰好有人在你这 2 秒内喊你——2 秒太短了,你根本感觉不到延迟。

如果你大部分时候只需要搬 3 块砖,那你费尽心机搭建的那整套"分组搬运 + 书签记录 + 优先级调度"系统是干嘛的?它不帮到你,它给你添乱。

这句话就是整篇文章最重要的核心。你把它放在脑子里,后面关于 Vue 的所有推理都会自然而然。


Vue 的路子:从根源让问题不存在

终于说到 Vue。

Vue 的思路跟 React 完全反过来了。React 说:"渲染可能会慢,所以让它可被中断。" Vue 说:"让渲染快到来不及慢。"

这听着像吹牛。但你搞懂了它的响应式系统之后——它只是说了实话。

回到公寓楼的故事。

一栋 20 层公寓。200 户住户。楼下有个公告栏。

React 的方法:

物业在公告栏贴了张通知。打开全楼广播:"所有住户请注意!公告栏有更新!请大家自行前往查看!"

200 号人全涌下楼。每个人自己读一遍。每个人自己判断"这事跟我有没有关系"。170 个人看完发现没关系——下楼白跑了,阅读白读了。30 个人发现有关系——记下内容上楼。

整个过程里,物业完全不管谁关心什么。物业就喊一嗓子,剩下的全是住户各自的事。

在 React 的世界里:物业的广播 = setState 调用。住户下楼查看 = 组件 render 函数执行。自己判断有没有关系 = 虚拟 DOM Diff。白跑一趟 = 不必要的 re-render。

Vue 的方法:

住户搬进公寓第一天,Vue 就来敲了门。

"你好。请问你对公告栏上的哪类信息比较在意?"

"嗯……我对 3 楼的装修通知比较在意。还有 5 楼的遗失物品告示。" "好的。记下了。——住户 A,关注:3 楼装修通知,5 楼遗失物品。"

"我对停电通知比较在意。" "好的。记下了。——住户 B,关注:停电通知。"

Vue 建好了一张完整的对照表。

后来公告栏贴了 3 楼装修通知。Vue 瞅了眼自己的对照表:"3 楼装修通知……只有住户 A 关心。"它就只敲了住户 A 的门:"3 楼装修通知更新了,你有空去看看。"

住户 B 呢?从始至终不知道公告栏有动静。没有任何开销——因为那本就不关他的事。

在 Vue 的世界里:对照表 = 响应式依赖图谱(dependency graph)。只敲一个人的门 = 精准组件级更新。不知情 = 不受影响的组件完全不动。

这就是为什么 Vue 的更新成本天然就低。页面 100 个组件,但只有 2 个依赖了刚才变化的数据——Vue 只更新那 2 个。剩下 98 个连碰都不碰。

React 呢?100 个全跑一遍——然后让它们各自判断自己需不需要更新。

Vue 不需要 Fiber,不是因为它偷偷实现了一套类似的中断机制。而是因为它从源头避免了"渲染工作量太大"这个问题。它从来没有什么重到需要被中断的渲染——所以 Fiber 对它毫无意义。


代码层面:看看两者怎么处理一次简单的更新

光举例子可能还不够直观。我们直接上代码,看看到底差在哪。

假设你有一个简单的状态——一个用户对象,带 name 和 age。页面上有两处用到它:一处显示 name,一处显示 age。

React 版本:

function UserProfile() {
  const [user, setUser] = useState({ name: '张三', age: 25 })

  const updateAge = () => {
    setUser({ ...user, age: user.age + 1 })
  }

  return (
    <div>
      <NameDisplay name={user.name} /> {/* 只依赖 name */}
      <AgeDisplay age={user.age} /> {/* 只依赖 age */}
      <button onClick={updateAge}>年龄+1</button>
    </div>
  )
}

const NameDisplay = React.memo(({ name }) => {
  console.log('NameDisplay 渲染了')
  return <span>{name}</span>
})

const AgeDisplay = React.memo(({ age }) => {
  console.log('AgeDisplay 渲染了')
  return <span>{age}</span>
})

当你点"年龄+1"按钮,user.age 变了。user 对象是新创建的。UserProfile 组件重新渲染。

NameDisplay 接收到的 name 没变(还是 '张三'),因为 React.memo 做了浅比较,NameDisplay 不会重新渲染。AgeDisplay 接收的 age 变了,重新渲染。

看似没问题。但注意——你必须在 NameDisplay 上手动加 React.memo 才阻止了不必要的渲染。如果你忘了加,NameDisplay 会白白跑一次 render。

在 React 19 里,编译器会自动帮你加——但底层机制没变:React 不知道 NameDisplay 不依赖 age。它只能通过对比 props 来"猜"。

Vue 版本:

<script setup>
import { reactive } from 'vue'

const user = reactive({ name: '张三', age: 25 })

const updateAge = () => {
  user.age++ // 直接修改!不需要创建新对象
}
</script>

<template>
  <div>
    <span>NameDisplay: {{ user.name }}</span>
    <!-- 只依赖 name -->
    <span>AgeDisplay: {{ user.age }}</span>
    <!-- 只依赖 age -->
    <button @click="updateAge">年龄+1</button>
  </div>
</template>

当你点"年龄+1"按钮,user.age++ 触发 Proxy set trap。

Vue 查依赖图谱——发现只有 {{ user.age }} 这个绑定之前读过 age。它只更新 AgeDisplay 对应的那个 <span>。NameDisplay 的 <span> 连碰都不碰——不需要 React.memo,不需要编译器,什么都不需要。

Vue 精确知道 NameDisplay 不依赖 age——因为它在 {{ user.name }} 被读取的时候记录了这个依赖关系。它从一开始就"知道",不需要"猜"。

这就是为什么 Vue 的更新成本天然低。不是因为它渲染得更快——是因为它渲染得更少。


Push 和 Pull:这场讨论里最要命的一对概念

如果你脑容量只够装两个词——就把这两个装进去。Push 和 Pull。它们解释了一切。

React 是 Pull 模式。

数据变了。框架自己不知道哪些组件需要更新。它的做法是:"我不知道谁需要知道这件事。所以我把所有相关的人都叫醒——你们自己查去吧。"

公司群里的全员通知。HR 发了条政策更新。消息推给 500 人。500 人全点开看了。480 人发现跟自己无关。但已经花了阅读时间。

Pull 模式的特征:数据生产者不追踪消费者。消费者自己"拉取"需要的信息。系统不替任何人判断。

Pull 的好处:实现相对简单。你不需要维护一张"谁依赖了谁"的关系图。组件就是一个纯函数——state 和 props 进,UI 出。状态变了?把函数再调一遍。非常函数式,非常优雅。这就是 React 的设计 DNA。

Pull 的代价:大量不必要的重复执行。500 个组件里可能只有 3 个需要更新,但你让 500 个都跑了。跑多了就慢,慢了就阻塞主线程。所以 React 需要 Fiber——既然不能完全消除不必要的工作,那就让这些工作别堵着用户。

Vue 是 Push 模式。

数据变了。框架精确知道哪些组件依赖了它。它主动"推送"通知给那些组件。不依赖的组件——完全不被触碰。

智能通知系统。政策更新只发给受影响的部门。其他人继续工作——他们根本不知道有更新。

Push 的好处:几乎没有不必要的工作。更新成本天然低,渲染工作量天然轻,主线程阻塞基本不发生。所以不需要 Fiber。

Push 的代价:实现复杂。你需要在每个属性被读取(get)时偷偷记录"谁在读",在每个属性被修改(set)时精确找出"谁依赖它"然后通知。Proxy 让这个过程比 Object.defineProperty 时代简单了很多——但它仍然是一个需要精心维护的系统。

这就是 React 和 Vue 最根本的分歧。

React 选择了"让实现保持简单"。它赌的是:函数式模型——每次状态变化都重新运行纯函数——在概念上足够优雅,在性能上"够用"。然后它砸进大量工程资源(Fiber、Diff 优化、React Compiler)去让"够用"真的够用。

Vue 选择了"让运行时保持高效"。它赌的是:一个有能力的响应式系统可以从根上消除大量重复计算。然后它砸进大量工程资源(Proxy 响应式、编译时优化、依赖图谱维护)去让这个系统对开发者完全透明。

两条路线。React 从简单走向复杂,Vue 从复杂走向简单。但它们在大多数业务场景下最终抵达的性能水平——已经很接近了。

用生活例子来让这个对比更有质感:

Pull 模式就像是公司茶水间里贴了一张公告。你想知道有没有跟你相关的内容?自己走过去看。有些人去了发现不关自己的事——白去了一趟。但好处是——行政部不需要知道每个人的关注点。

Push 模式就像你手机上的消息推送。只有跟你有关系的消息才会震你。你不需要自己去检查。但好处的前提是——系统得知道你是谁、你关心什么。这需要一套额外的登记系统。


再说 React 的"状态管理陷阱"

这里有一个特别真实的困惑——而且是大量 React 开发者踩过的坑。跟 Fiber 有关,但不是直接相关。值得单独讲。

很多人刚开始学 React 的管理状态时会这样想:

"天哪——按照 React 的逻辑,我把一个大 state 放在根组件里,然后往下层层传 props。如果第 5 层的子组件更新了 state 里的一小小个属性,那岂不是从根组件开始,整棵组件树都要重新 render 一遍?这也太可怕了吧……"

于是大家开始做各种奇怪的事情:

  • 把 state 拆得稀碎,分散到各个子组件里(导致状态管理一团乱麻)
  • 疯狂地包 React.memo,逢组件必 memo(反而增加复杂度)
  • 换成各种第三方状态库,以为换了库就能"避免"re-render

真相不是这样。

React 的 re-render 是组件级别的——不是"整棵树"。而且它有一套机制来决定哪些组件真正需要跑 render:

第一,React 确实会从"状态变化发生的地方"开始,往下走。但子组件是否需要 re-render,取决于传给它的 props 有没有变。如果一个子组件接收的 props 跟上次一模一样——默认情况下它还是会 re-render(这确实是 React 的已知问题),但它的虚拟 DOM 子树跟上次一样,Diff 阶段会发现"没变化",最后不会操作真实 DOM。

你用 React.memo 可以阻止这个"没变化的 re-render"——memo 会让组件在 props 没变化时直接跳过。React 19 的编译器更是自动帮你做这件事。

第二,更关键的是——选择器(selector)。

你如果用 Zustand、Redux 这类状态库,决定 re-render 的不是"store 变了",而是"你的选择器返回的值变了"。

// ❌ 坏的写法:选了整个 store
function PostItem() {
  const { posts, comments, user, settings } = useStore()
  // 任何一个字段变化,PostItem 都 re-render——哪怕它只显示 posts
}

// ✅ 好的写法:精确选择
function PostItem({ postId }) {
  const post = useStore((state) => state.posts.find((p) => p.id === postId))
  // 只有这个特定的 post 变化时,才 re-render
}

React 并不会因为你把 state 放在根组件就"渲染整棵树"。它只会渲染那些选了变化数据的组件。问题是——选择器的精度由你决定。选多了,re-render 就多。选精确了,re-render 就少。

而 Vue 呢?从头到尾不需要你管选择器。因为 Proxy 自动帮你选了——它记录的是"这个组件到底读了哪个对象的哪个属性"。精准到属性级别。你不需要写 selector,它自动就是最精确的 selector。


虚拟 DOM:同名不同命

React 和 Vue 都说自己用了"虚拟 DOM"。但这就像两个人一起说"我用筷子吃饭"——一个人是在夹菜,一个人是在织毛衣。根本不是一回事。

React 的虚拟 DOM 是全局大扫除

状态变了。创建一棵全新的虚拟 DOM 树。跟旧的那棵从头 Diff 到尾——找遍每一个节点、每一个 prop、每一个子元素列表。找出所有差异。应用到真实 DOM。

这听着暴力,但打得很聪明。Naive 的两棵树 Diff 复杂度是 O(n³)——两棵树各 n 个节点,每个节点去对方那找匹配,还要处理移动。React 用两个假设把它降到了 O(n):

假设一:不同类型元素一定产生不一样的子树。 <div> 变成 <ul>?React 直接放弃深挖子节点——整棵子树销毁重建。这个假设不是 100% 准,但实际业务里几乎不会误伤。你可以想象,如果一个页面里 div 变成了 ul,它里面的内容结构大概率完全不同了——继续 Deep Diff 反而是浪费。

假设二:key 属性标记列表项身份。 没有 key,React 只能靠位置猜——位置变了的就当新节点。有 key,React 能认出来"key=1 的这个节点只是从第 3 位移到了第 7 位——还是他本人"。只移位置,不销毁重建。

这两个假设把复杂度从 O(n³) 拉到 O(n)。天才。但 O(n) 还是 O(n)。1000 个节点,哪怕只有一个变了——React 还是得遍历 1000 个确认"只有 1 个变了"。

Vue 的虚拟 DOM 是定点狙击

它的响应式系统在数据变的那一刻就知道"是哪个组件的哪个数据绑定变了"。它直接定位到那个组件,在组件内部做局部更新。不需要全局 Diff。其他组件的虚拟 DOM 子树——根本没被创建。

结果一样——都正确更新了 DOM。过程完全不同。

拿一个仪表盘组件举例子。

React:主面板的一个数字从 5 变成 6。React 重跑整个仪表盘(包括侧边栏、顶栏、底栏),生成一棵全新的虚拟 DOM 树,从根开始 Diff。一路走下来:侧边栏没变——跳过。顶栏没变——跳过。底栏没变——跳过。找到了——那个数字变了。更新对应 DOM 节点。

Vue:同一个数字从 5 变成 6。Vue 的响应式图谱说:"只有那个显示计数的组件依赖了这个数字。"直奔那个组件,更新 DOM 绑定。侧边栏、顶栏、底栏——从始至终没有被触碰。

两种方式最后都做了正确的事。但 React 遍历了一整棵树,Vue 只碰了一个节点。

而且 Vue 有编译时优化——这是 React 直到最近才开始探索的领地。

Vue 的模板是构建时编译的。编译器能识别出哪部分是静态的(永远不会变)和动态的(可能变化):

<template>
  <div class="container">
    <h1>商品列表</h1>
    <!-- ^^ 静态的。创建一次,之后直接复用。 -->
    <div v-for="p in products" :key="p.id">
      {{ p.name }}: {{ p.price }}
      <!-- ^^ 动态的。只有这些需要持续追踪。 -->
    </div>
  </div>
</template>

编译后的渲染函数把 <h1>商品列表</h1> 做了"提升"(hoisting):创建一次,终身复用。后续每次更新,React 还得重新调用 createElement('h1', null, '商品列表')——即使结果完全一样。

这两种编译器思路也在趋同。React Compiler 分析的是"运行时行为模式"——找出哪些值应该被缓存。Vue 的模板编译器分析的是"模板的静态结构"——找出哪些代码路径永远不会被重新执行。前者更灵活,能处理复杂的 JSX 逻辑。后者更精准,能直接跳过静态部分。两者最终都会走向一个共同的目标:把能预知的事情在编译时搞定,运行时只做不得不做的事。


为什么 React 死磕不可变性?

这个问题跟 Fiber 没有直接关系。但你搞清楚了它,对 React 的整体理解就圆满了。

为什么 React 里不能 user.age = 26,非得 setUser({...user, age: 26})

因为 React 用引用比较来判断数据变没变:

// React 内部的简化逻辑
if (Object.is(oldState, newState)) {
  // 同一个引用 → 肯定没变 → 不渲染
} else {
  // 不同引用 → 变了 → 渲染
}

这是 Pull 模式的必然结果。因为 React 不能拦截 user.age = 26 这种属性赋值,它不知道你的对象内部发生了什么。它只有一个简单粗暴的工具:"你给我的对象引用是不是新的?"

你 direct mutate 了 user.age = 26,user 还是那个对象,引用没变。React 比较 Object.is(user, user)true → "没变化" → 不渲染。你代码改了值,但画面纹丝不动。

所以不可变性不(仅仅)是 React 在设计哲学上非要走函数式路线。更根本的原因是:Pull 模式逼得 React 只能依赖引用比较。它没有别的检测变化的工具。

还有一层原因:不可变性天然支持"撤销/重做"和"时间旅行调试"。因为每次 setState 都产生一个新对象,旧对象还在。你可以把它们全存进一个数组——任何时候想回到第 N 步的状态,把对应的对象拿出来就行。

如果是可变对象,你只能看到最终状态——中间步骤都被覆盖了。Redux DevTools 的时间旅行调试就是靠这个特性工作的。

Vue 不需要这些。Vue 的 Proxy 能拦截对对象属性的每一次读写:

state.user.age = 26
// Vue 的 Proxy set trap 立刻感知:
// a) user.age 从 25 变成了 26
// b) 组件 A 和组件 B 之前都读过 user.age
// c) 通知 A 和 B → 更新对应 DOM

Vue 让你直接改——因为它在改动发生的那一瞬间就知道。

两种做法,用一个比喻来理解:

React 像拍立得。每一张照片都是独立的、不可修改的物理实体。想看变化?拍一张新的。想看历史?翻相册。时间旅行调试就是因为每帧都是独立快照。

Vue 像便利贴。你在上面划掉旧数字写上新数字。贴纸还是那张贴纸,内容变了。旁边有记录员默默在笔记本上写:"下午 3:42,便利贴 A,数字 5→6。"

拍立得给你历史。便利贴给你效率。选择哪个取决于你需要什么。大多数业务场景里你不需要时间旅行调试——你需要的是改数据界面就变,就这么简单。Vue 在这个"就这么简单"上做得更激进。


Vue 什么时候也会疼?

公平地说,Vue 不是完美的。有些场景下它也会遇到麻烦。但它的应对方式跟 Fiber 完全不搭边。

大列表场景。

10 万条数据。什么框架都不可能 16ms 内渲染 10 万个 DOM 节点。Vue 的响应式再牛也敌不过物理定律。

Vue 的办法不是"让渲染可中断"(Fiber 路线)。它的办法是——不渲染 10 万条数据。

虚拟列表(Virtual Scroll)。数据再大,屏幕上能看见的就二三十行。只渲染看得见的:

<script setup>
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'

const items = ref(
  Array.from({ length: 100000 }, (_, i) => ({
    name: `Item ${i}`,
    value: Math.random(),
  })),
)

const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 50,
  overscan: 5,
})
</script>

<template>
  <div v-bind="containerProps" style="height: 400px; overflow: auto;">
    <div v-bind="wrapperProps">
      <div v-for="{ data, index } in list" :key="index">{{ data.name }}: {{ data.value.toFixed(2) }}</div>
    </div>
  </div>
</template>

这不是"优化渲染执行"。这是"改变问题的定义"——你不需要渲染 10 万条。

昂贵计算场景。

如果组件内部有大量数组排序、过滤、聚合——这种计算在主线程上跑,跟框架没关系。

Vue 有这几招:

  • computed 自动缓存——依赖没变就返回上次结果,不重算。
  • v-memo 指令冻结模板片段——直到指定依赖变化才更新。
  • Web Worker——把计算甩到独立线程。
<template>
  <div>
    <span v-for="item in list" :key="item.id" v-memo="[item.name, item.count]">
      {{ item.name }}: {{ item.count }}
    </span>
  </div>
</template>

这些跟 Fiber 的"时间切片"是两种彻底不同的策略。但结果相似——用户不觉得卡。

Vue 3.4+ 的 Vapor Mode。

Vue 团队在探索一种叫 Vapor Mode 的编译策略。它借鉴了 Solid.js 的思路——在编译时生成更接近原生 DOM 操作的代码,跳过虚拟 DOM 的开销。传统的 Vue 渲染是"响应式数据 → 虚拟 DOM → 真实 DOM",Vapor Mode 是"响应式数据 → 真实 DOM"。直接把中间那层砍了。

如果这条路走通了,Vue 的性能会上一个新的台阶。到那时,Fiber 这种"让渲染可中断"的策略跟 Vue 的距离会更远——Vue 根本连虚拟 DOM 都不跑,哪来的渲染可中断?


两大框架在趋同吗?

一个值得思考的问题:Vue 和 React 是不是在往同一个方向走?

有些方面——是的。

React 19 的编译器在做 Vue 一直做的"编译时优化"——虽然在具体实现上很不一样。Vue 的 Vapor Mode 在学 Solid.js 的"跳过虚拟 DOM"——这让 Vue 离 React 的模式更远了,反而更接近 Solid。

两者也从对方身上学到了不少。React 的 Hooks 显然受到了 Vue Composition API 的启发(虽然实现机制完全不同)。Vue 的 v-memo 则允许开发者做类似 React 的"手动控制更新粒度"——这在某种意义上是在给开发者一个 React.memo 式的选择。

但它们最根本的分歧不可能趋同。因为那涉及它们的"数据变化感知方式"——Pull vs Push。

React 不可能突然变成 Push 模式——那意味着重写整个框架核心,放弃函数式哲学,放弃现有的生态兼容性。Vue 也不可能突然变成 Pull 模式——那意味着放弃响应式系统这个最核心的差异化优势。

所以它们会在外围趋同——编译优化、开发体验、SSR 能力——但在内核上保持自己的选择。

这对你意味着什么?意味着你不必纠结"未来哪个会胜利"。两个都会赢。它们只是赢在不同的场景里。


给你一套判断框架

讲完了理论、类比、代码对比、边界条件。现在给你一套直接能用的东西。

下次有人问你"React 跟 Vue 选哪个"——你不要直接回答。你反问他五个问题。问完他自然就有答案了。

第一个问题:你页面的交互密度有多高?

这个问题直接回答"主线程阻塞"在你的场景里到底是不是个真问题。

如果你的页面主要就是展示数据——列表、表格、看板、图表——用户的操作主要是翻翻页、点点详情、滚动浏览。这种场景里,单次渲染的工作量本来就不大。Vue 的精准更新让它几乎不会做任何多余工作——一个数据的变动只更新它真正影响的那一小撮 UI。

如果你的页面有大量实时交互——拖拽式编辑器、画布操作、高频输入带实时预览、每个操作都要触动多个 UI 区域重新计算的复杂动画——React 的 Fiber 和并发模式开始发力了。这种场景里主线程确实可能被堵,优先级调度确实能保证拖拽永不打结。

但你需要诚实地问自己一个问题:你的项目是 Figma 吗?

绝大部分业务应用属于"数据展示 + 偶尔操作"。你不需要为你没面对的问题买单。

第二个问题:你的团队更喜欢哪种思维方式?

这个问题比性能更重要。因为在正常的业务场景里,Vue 和 React 的性能差异肉眼根本不可感知。但心智模型的差异——影响的是每一天写代码的体验。

Vue 是隐式自动的。你定义数据,模板里用,改掉。Vue 在背后自动追踪、自动更新。你不理解 Proxy 也能写出高性能的组件。

React 是显式手动的——正在往自动方向走。你需要理解状态提升,知道什么时候会 re-render,掌握 memo 的时机。React 19 的编译器在减少"手动"的部分,但在复杂状态管理场景里,理解 React 的更新机制仍然有价值。

喜欢"东西自己就好了" → Vue 更舒服。 喜欢"一切都明明白白" → React 更对胃口。

第三个问题:你要"全家桶"还是"自助拼装"?

Vue 的官方生态——Vue Router、Pinia、Vite、VueUse、Vitest——覆盖了日常开发的绝大部分需求。你很少需要纠结"路由用哪个"、"状态管理用哪个"——官方帮你选好了,直接能打。

React 的生态更庞大但更碎片化。状态管理就可以列出 Redux Toolkit、Zustand、Jotai、Recoil、Valtio,各自不同的哲学。路由有 React Router、TanStack Router。数据获取有 TanStack Query、SWR。

选择多到可以开超市。但逛超市本身也花时间。有些人觉得这是自由,有些人觉得这是浪费。

第四个问题:你真的理解"Fiber 在解决谁的问题"吗?

这个问题用来面试,也用来问你自己的项目。

Fiber 解决的是 React 自己制造的问题。Pull 模式的"状态变就全量重跑"会在复杂页面上制造大量渲染工作。Fiber 通过调度和中断来降低这些工作的阻塞概率。

Vue 没有这个问题。因为响应式追踪,它的渲染工作量从一开始就很小。工作量小就不阻塞,不阻塞就不需要 Fiber。

就像那两个学生:

张三平时不学,考试前通宵——得喝咖啡(Fiber)撑住。 李四平时就明白了,考试前正常睡觉——不需要咖啡。

都考过了。但"为什么李四不需要咖啡"——跟咖啡好不好没关系。李四不需要解决"熬夜撑不住"的问题,因为他没熬夜。

第五个问题:你的应用到底有多特殊?

别被极端 case 吓着。"渲染一万行"、"每秒 100 次输入"、"800 个组件同步动画"——这些在你的实际代码里出现过几次?

对于绝大多数业务应用,真正的性能瓶颈不是框架的渲染机制。是你的网络请求没防抖,是你的图片没压缩,是你的计算没缓存,是你的 bundle 大到离谱。

Vue 和 React 在正常应用里的渲染性能差,用户是感知不到的。真正影响用户体验的,是你的产品设计和工程实践,不是框架能不能把 Diff 中断。


常见困惑快问快答

Q: Vue 3 的 Composition API 跟 Fiber 有关吗?

完全没有。Composition API 是代码组织方式——让你能在一个可复用函数里管理状态、计算属性、生命周期。它解决的是"逻辑复用"。Fiber 解决的是"渲染调度"。两者解决的问题域根本不在同一个维度。你可以把 Composition API 理解为 Vue 版本的 Hooks——只不过它的响应式特性让它比 Hooks 更灵活(没有依赖数组,没有重复执行的问题)。

Q: Vue 将来会引入 Fiber 吗?

Vue 核心团队说过"目前没有计划"。不是做不到——是没必要。Vue 3 的响应式系统已经让更新足够高效,引入 Fiber 只会加复杂度而不增加价值。就像给一个从来不熬夜的人买咖啡——咖啡是好东西,但对他没用。

但 Vue 确实在不断演化。Vue 3.2 引入了 v-memo——让你能手动标记"只有当这些依赖变了才更新这个模板区域"。Vue 3.4+ 在推 Vapor Mode——跳过虚拟 DOM 的编译策略。这些探索都有一个共性:从源头减少工作量,而不是让工作可中断。

Q: Svelte 和 Solid.js 是不是走得更远?

Svelte 和 Solid 代表第三极:极端编译时优化。

Svelte 在编译时就确定了每个数据变化该操作哪些 DOM。运行时几乎不 Diff。Solid 更进一步——响应式追踪几乎全在编译时完成。

代价也有:编译产物更大,复杂场景调试更难,生态更小。Vue 选择在编译时优化和运行时灵活性之间找到一个平衡——比 Svelte 更灵活,比 React 更高效。

不是谁更好。是你愿意在哪一端做取舍。

Q: React 19 的编译器会不会拉平差距?

会缩小,但不会拉平。

编译器自动加 memo 能显著减少"忘了手动优化"导致的不必要 re-render。但底层的 Pull 模式不会变——React 仍然不知道哪些组件需要更新,它仍然需要让组件跑一遍再判断。编译器只是让"跑一遍"的代价更小了。

在绝大多数业务场景里,React 19 + Compiler 的性能已经足够好,用户感知不到跟 Vue 的区别。但在极端的实时交互场景里(Figma 级别的复杂度),Fiber 的优先级调度仍然是 React 的独特优势——这是 Pull 模式的反向红利。


最后

你可能注意到了。这篇文章从来没有告诉你"该用什么框架"。

这不是懒。这是说实话。

React 和 Vue 的背后,是两条截然不同的工程哲学在同一个战场上竞争:

一条说:"让框架实现保持简洁,让开发者遇到性能问题时再用我们提供的工具解决(Fiber、Compiler、Concurrent Mode)。"

另一条说:"让框架内部更复杂一些,但让开发者写的代码天然就是快的。复杂的事情框架替你做了(响应式追踪、编译时优化)。"

两条路都走出了伟大的框架。两条路都还在高速进化——React 有了编译器,Vue 有了 Vapor Mode。它们之间的差距在缩小。

但回到你——那个凌晨三点盯着搜索框的你。

你缺的不是一个更好的框架。你缺的是搞清楚你手里的工具到底在替你做什么,又让你付出了什么代价。

如果你只能从这篇文章带走两样东西,就带这两个问题。以后看任何新技术,都拿出来掂量:

一、它是怎么检测"有变化"的?

React:你主动调 setState。显式。 Vue:Proxy 自动截获。隐式。

二、它检测到变化之后,做了多少不必要的工作?

React:重新执行整个组件函数 + 全局虚拟 DOM Diff。做了很多不必要的工作,然后用 Fiber 来保证这些多余工作不会卡住用户。 Vue:只更新依赖了变化数据的组件。几乎不产生不必要的工作。所以不需要 Fiber。

这两句话装进脑子里,面试官问不住你,技术文章带不偏你,更重要的是——你不会再在凌晨三点对着一个卡死的搜索框怀疑人生。

你本来就知道答案。

对了。你那个搜索框现在不卡了吧?

如果还卡。也许不是你代码的问题。也许是你选的工具,在让你不停地跟一个自己制造的问题较劲。

读者来信

0/1000

暂无来信,期待你的分享。