前言
作为Vue转React的选手,相信不少人会和我有一样的困惑:
- 为什么
React的心智负担这么重,明明我在Vue中直接修改响应式数据就可以了 - 作为开发者,有时候还得停顿下来思考:这个组件是否需要
memo避免不必要的更新;这个Hook的依赖项是否传多/传少了;有时候依赖项的传染性很容易绕进去...可我在Vue中响应式数据似乎更加“自动”一点 - 刚开始从
Vue转型而来,应该从什么角度理解更加自由的React?
本文将从Vue的更新机制讲起,并对比与React的差别,以个人见解分享一下理解的角度
Vue响应式系统 & 更新策略:
- Vue2基于
Object.defineProperty()、Vue3基于Proxy建立的响应式系统,会将对应的watchers收集起来,后续响应式数据一发生变化,就通知对应的watchers重新执行;换句话说:当响应式数据变化,通知对应用到当前响应式数据的render()重新执行,生成新的VDomreactivity(传送门)与runtime-core模块的结合也不会是此次的讨论重点;此处我们主要讨论Vue的更新策略(此外,此处也不会讨论props的更新,而是聚焦在children的更新逻辑上)v-node指的虚拟节点;v-dom指的虚拟节点构成的虚拟树
diff算法:
- 同级比较,通过
type``key判断是否属于相同- 不相同,直接
return - 相同,下探递归进行对比
- 不相同,直接
- 双端对比,缩小乱序范围
- 中间乱序部分采用
最长递增子序列,尽可能减少移动的次数
- 这里有个容易绕进去的点,很多时候我们都会想:对于更新过程中同时存在的
n1n2,到底最终要使用哪个?是不是在比较的过程中把所有更新的点移植到某一方身上,再以某一方的最终值为参照物实现更新❌- 更新过程中是不断利用
n1n2的过程,不存在一方附加到另一方的情况,二者存在的意义就是比较
一图胜千言
有关LIS是否执行的优化:
这部分建议看图
尽管使用了LIS算法减少了移动的次数,但是LIS自身的执行就是耗时的,对于乱序部分变化前后位置均保持不变的情况下,其实无需执行LIS
思路:
- 在遍历过程中,维护两个变量:
maxNewIndexSoFar记录目前遍历过程中最大的newIndex即记录上一个VNode的newIndexmoved标记newChildren乱序部分是否真存在“乱序”的情况(即当前的newIndex<maxNewIndexSoFar)
- 如果当前
newIndex>maxNewIndexSoFar说明这个节点相对位置没有发生变化,更新maxNewIndexSoFar - 如果任何一个当前
newIndex<maxNewIndexSoFar说明出现了乱序,moved标记为true,表明后续需要执行LIS
React:
基础架构:
Scheduler调度器:根据任务优先级调度。在每一帧中,JS线程预留5ms进行计算更新;当时间不足时,React需要出让线程控制权给浏览器,进行UI界面更新;等下一帧时间到来,继续执行上次被中断的任务——于是我们需要一种机制,当浏览器有剩余时间时通知我们Reconciler调度器:负责找出变化的组件;生产虚拟DOM,执行diff算法,通知Renderer将变化的虚拟DOM渲染到页面上Renderer渲染器:在commit阶段执行不可中断更新,将新的虚拟DOM更新为视图界面
1. 触发更新(如 setState)→ Scheduler 根据优先级安排任务。
│
2. Scheduler 将控制权交给 Reconciler → Reconciler 开始构建/更新 Fiber 树(Mount/Update)。
│ ├─ 期间可能被 Scheduler 中断(高优先级任务插入)。
│ └─ 完成 Diff 后生成副作用列表。
│
3. Reconciler 退出 → Renderer 同步执行 Commit(更新 DOM、调用生命周期等)。
Scheduler调度
目的:通过优先级策略,实现高优先级任务快速响应、低优先级任务正常执行
需要Filber和优先级策略两部分
Filber:
将大任务切分成任务切片,由链表进行连接,每次循环都会使用shouldYield()判断是否有剩余时间,实现了可中断执行;React会在Reconciler全部完成之后,将workInProgress Tree交由Renderer一次性渲染,
优先级策略:
光有时间切片还做不到真正意义上的并发,还需要有一个优先级策略
这要求Scheduler具备以下功能:
- 暂停
JS执行,将控制权出让给浏览器,让浏览器更新界面 - 将来某个时刻继续调度任务,执行上次未完成的任务
满足这两点就需要调度一个宏任务,因为一个宏任务执行完毕之后,会检查是否需要渲染或执行其他任务
为啥不是微任务? 因为微任务会在页面更新前将队列中的任务全部执行完,就做不到【出让控制权给浏览器】
React是如何出让控制权的?
解决方案:React是通过MessageChannel+requestAnimationFrame实现出让控制权的
raf()记录每一帧开始的时间(T_start = performance.now())MessageChannel的回调是宏任务,当其执行完毕发现帧剩余时间不足时(performance.now() - T_start),通过postMessage通知下一个MessageChannel回调(根据浏览器的事件循环机制,该回调会被塞入下一帧的宏任务队列中)- 然后出让控制权给浏览器,执行渲染、响应用户交互等
- 在下一帧中重复上述动作...通过
MessageChannel回调继续上一次未完成的任务
方案对比
- 为啥不是
setTimeout?- 因为递归层级过深时,
setTimeout延时为4ms,会增加延时时间
- 因为递归层级过深时,
- 为啥不是
requestAnimationFrame?raf()执行时间在微任务之后、界面渲染之前,本来时机还算合适,但是raf()设计初衷是与浏览器渲染同步,严格受到浏览器刷新频率的影响,单帧内执行一次raf()需要完成所有任务分片(比如100个分片)如果完不成就会阻塞主线程,导致页面卡顿
- 为啥不是
requestIdleCallback()?- 执行时间在重排重绘之后,执行时间太晚(此时页面已经更新,出让控制权已没啥意义)
Reconciler调和
双/多缓存Filber树
React16之前,更新是递归不可中断的,当组件嵌套层级过深,会阻塞主线程导致页面卡顿- 双/Filber树
- 处理
WorkInProgress FIlber Tree时,根据浏览器空闲时间分片执行任务,实现中断、恢复、抛弃,且WorkInProgress FIlber Tree是在内存中构建的,整个current树保持稳定,不会出现中间状态导致页面闪烁 - 双 Filber 树的结构通过
alternate(交替的) 属性在两棵Fiber树之间建立联系,在更新时还可以复用Filber节点(如果key和type相同),减少内存分配和GC压力。在频繁更新的场景下,这种优势更加明显
- 处理
挂载阶段Mount:
- 执行
ReactDOM.render创建filberRootNode和rootFilber - 根据组件
jsx返回的内容在内存中依次创建filber节点并拼接构成Filber树,即workInProgress Tree(构建过程中会尽量复用已有的current Tree的节点,不过初次挂载,没有可复用的节点) - 构建完毕进入
commit阶段,filberRootNode的current指针指向workInProgress Tree
更新阶段Update:
当触发更新时,会触发新的render阶段,重新执行JSX,并构建一棵新的workInProgress Fiber树,这个过程中复用currentFiber树对应的节点数据,这个决定是否复用的过程就是Diff算法
diff 算法的本质:比较 currentFilber 和 JSX,生成 workInProgressFilber
调和是一个可中断的深度优先遍历,整体可分为“递”和“归”两大阶段,完成组件树的更新,为commit阶段做准备
beginWork递阶段——思考需要做什么- 首先从
rootFilber开始向下遍历 - 通过
key+type判断节点是否可以复用- 不可复用,删除老节点、生成新节点,分别打上
Deletion、Plaction标记 - 可复用,沿用老节点,进入下一步
- 不可复用,删除老节点、生成新节点,分别打上
- 比较新旧
props和state判断是否需要更新- 不需要,跳过该节点和子树
Bailout - 需要,执行更新逻辑(如重新执行组件函数),执行
diff算法,打上Update标记
- 不需要,跳过该节点和子树
- 通过
child继续下探- 存在子节点,则重复子节点的调和过程
- 不存在子节点,返回
null,触发归阶段,进入completeWork
- 首先从
completeWork归阶段——总结每个节点做了什么- 比较新旧属性,生成需要更新的属性列表
- 收集副作用,根据
beginWork阶段给Filber标记的副作用类型,将有副作用的Filber连接成effectList,供commit阶段遍历使用placement:插入新节点update:更新属性或内容deletion:删除节点
- 当某个节点完成
complateWork后,通过slibing指针指向兄弟节点- 若存在兄弟节点,重复兄弟节点的调和工作
- 若不存在,向上回溯,直至回到根节点,则算是完成整个
workInProgress Filber Tree的构建
关于调和中断:
beginWork的执行过程中:React 会通过Scheduler的shouldYield()检查是否有更高优先级的任务需要插入,如果有,则中断当前工作,让出主线程- 中断时通过一个全局变量保存当前处理到的节点的指针(内存地址),中断后通过调度器重新调度任务,直接从该地址恢复,无需重新遍历整棵树;另外,即使发生中断,也能通过多缓存树机制,确保用户不会看到不完整的
UI更新结果
Renderer(commit提交)
把变更应用到DOM上: 遍历 effectList;执行 DOM 操作;调用生命周期钩子和 hooks;同步不可中断
Diff
- 分为单节点 diff 和多节点 diff。根据
babel转换之后的结果,而非靠视觉层级进行判断 - 新树对比老树,操作的对象是老树,参照物是新树
newChild类型 | DIff 模式 | 示例 |
|---|---|---|
对象(ReactElement) | 单节点 diff | <div />或<Component /> |
| 字符串或数字 | 单节点 diff | "Hello"或{123} |
数组(Array) | 多节点 diff | [<div key="1" />, <div key="2"/>] |
其他(如 null、false) | 跳过 | {isShow && <div />}可能返回 false |
React 的 diff 算法遵循三个原则:
- 同级比较,跨层级的 DOM 不进行复用,意味着 diff 的过程中是在比较兄弟节点(
sibling)的过程 - 不同类型的节点生成的 DOM 不同,此时会直接销毁老节点及子孙节点,并新建节点(类型指 html 标签类型)
- 可以通过
key来对元素diff的过程提供复用的线索
单节点 diff:
React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用
- 当
child !== null且ke相同且type不同 时执行deleteRemainingChildren将child及其兄弟 fiber 都标记删除 - 当
child !== null且key不同时仅将child标记删除
多节点 diff:
多节点 diff,无外乎三种基本情况:
- 节点更新:
// 旧
<ul>
<li key="0" className="old">0<li>
<li key="1">1<li>
</ul>
// 新 情况1 节点属性变化
<ul>
<li key="0" className="after">0<li>
<li key="1">1<li>
</ul>
//新 情况2 节点类型更新
<ul>
<div key="0">0</div>
<li key="1">1<li>
</ul>
- 节点新增或删除
// 旧
<ul>
<li key="0">0<li>
<li key="1">1<li>
<li key="2">2<li>
</ul>
// 新 情况1 —— 新增节点
<ul>
<li key="0">0<li>
<li key="1">1<li>
<li key="2">2<li>
<li key="3">3<li>
</ul>
// 新 情况2 —— 删除节点
<ul>
<li key="1">1<li>
<li key="3">3<li>
</ul>
- 节点位置移动
// 旧
<ul>
<li key="0">0<li>
<li key="1">1<li>
</ul>
// 新
<ul>
<li key="1">1<li>
<li key="0">0<li>
</ul>
多节点 diff 为啥不用双指针?
虽然多节点 diff 的newChildren类型为Array,当我们遇到数组时,为了提升算法效率,常常会使用双指针算法。但是此处与newChildren比较的是currentFilber节点,同级Filber节点是通过sibling指针连接起来的单链表,不支持双指针遍历
即 newChildren[0] 与 filber 比较、newChildren[1] 与 filber.sibling 比较
基于上述原因,react 多节点 diff 会经历两次循环:
- 第一轮:处理更新的节点
- 第二轮:处理不属于更新的节点
第一轮遍历:
let i = 0,遍历newChildren,将newChildren[0]与oldFiber比较,判断DOM节点是否可复用。- 如果可复用,
i++,继续比较newChildren[1]与oldFiber.sibling,可以复用则继续遍历。 - 如果不可复用,分两种情况:
key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历
- 如果
newChildren遍历完(即i === newChildren.length - 1)或者oldFibers遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束
第二轮遍历:
分三种情况:
- newChildren 没遍历完,oldFibers 遍历完:意味着本次更新有新节点插入,只需遍历剩下的
newChildren生成的workInProgress fiber依次标记Placement - newChildren 遍历完,oldFibers 没遍历完:只需遍历剩下的
oldFibers,依次标记Deletion - newChildren 和 oldFilbers 都没有遍历完:即节点可能新增、可能删除、可能移动了位置👇
- 收集剩余的
oldFilbers,建立key -> oldFiber的映射表(existingChildren)
此处可回顾
Vue的操作,思想一致
- 遍历剩余的
newChildren,尝试匹配或新增节点:- 如果
newChild有key,则在existingChildren中查找对应的oldFiber:- 找到:复用该
oldFiber(可能移动位置) - 没找到:创建新节点(新增)
- 找到:复用该
- 如果
newChild没有key,则按顺序尝试匹配(效率较低)
- 如果
- 遍历完成后,
existingChildren中剩余的oldFiber会被删除(因为它们没有对应的newChild)👇
处理节点移动:
我们的参照物是:最后一个可复用的节点在
oldFiber中的位置索引,用变量lastPlacedIndex表示
由于本次更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个节点一定在lastPlacedIndex对应的可复用的节点的后面
理解这句话,比如:
假设newChildren[0]、newChildren[1] 在 oldFilbers 中都存在
newChildren[0] 在 oldFIlbers 对应的节点是甲、位置是 x;那么 newChildren[1] 在 oldFilbers 对应的节点乙一定在甲后面、位置一定>x;如果不是,说明乙需要移动
- 那么我们只需要比较
newChildren当前的节点在oldFibers中的位置是否在lastPlacedIndex对应的fiber后面,就能知道newChildren中两个相邻节点的相对位置是否发生改变 - 我们用变量
oldIndex表示newChildren当前的节点在oldFibers中的位置索引。如果oldIndex<lastPlacedIndex,代表本次更新该节点需要向右移动。 lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex>=lastPlacedIndex,则lastPlacedIndex=oldIndex
同样是回顾
Vue对于是否进行LIS的判断,会发现这两个框架的思想也是存在相同之处的
举个🌰
以多节点更新为例;原图链接
- 第一轮遍历开始: a vs a,key不变,可复用 此时 a 对应的节点在之前的数组(abcd)中索引为0,所以 lastPlacedIndex = 0
- 继续第一轮遍历:c(新)vs b(旧),key改变,不能复用,跳出第一轮遍历;此时 lastPlacedIndex = 0 第一轮遍历结束
- c 在 oldFiber中存在 (映射表查找), 此时 oldIndex = 2(c在旧数组中的索引值) 比较 oldIndex 与 lastPlacedIndex;在例子中,oldIndex 2 > lastPlacedIndex 0,则 lastPlacedIndex = 2; c节点位置不变
- 继续遍历剩余newChildren d 在 oldFiber中存在,oldIndex 3 > lastPlacedIndex 2,则 lastPlacedIndex = 3; d节点位置不变
- 继续遍历剩余newChildren b 在 oldFiber中存在,oldIndex 1 < lastPlacedIndex 3,则 b节点需要向右移动;第二轮遍历结束
Vue VS React:
核心思想不同导致 diff 方案不同:
React 的 diff 算法采用了贪心算法,而 Vue3 采用的双端对比 + 最长递增子序列算法,根本原因是两个框架的核心理念不同:
- React 更看重更新的延迟(如输入框响应速度),而非最少的移动次数,所以采用贪心这种线性的算法,因为 LIS 需要花费额外的时间 O(k log k);另外 Filber 架构要求可中断/恢复,线性遍历更容易被拆分成小任务
- Vue3 更看重交互的卡顿(如排序动画的流畅度),响应式系统对 DOM 的操作次数极度敏感,所以采用 LIS 尽可能少的减少 DOM 的移动次数
| 场景 | React的选择(贪心) | Vue3的选择(双端对比 + LIS) |
|---|---|---|
[A,B,C] → [A,C,B] | 移动 1 次(B) | 移动 1 次(C) |
[A,B,C] → [C,A,B] | 移动 2 次(A,B) | 移动 1 次(C) |
| 时间复杂度 | O(n) | O(n + k log k)k 指的是剩余的乱序节点数 |
数据驱动进一步佐证diff的不同:
对比Vue/React的开发流程,最主要的区别就是:React 心智负担重、而 Vue 更省心
- 在使用 React 的过程中,常常需要考虑是否需要使用
useCallback、React.memo;依赖项填写是否正确;以及依赖性有时候带有传染性,很容易绕晕开发者 - 但我们从不需要考虑
defineEmit声明的自定义事件是否会被重新声明、响应式数据一的改变会不会引起只使用响应式数据二的组件的重新执行...
| Vue | React |
|---|---|
Vue 的响应式系统,通过Proxy/Object.defineProperty自动追踪依赖,当数据变化时,通知依赖它的组件更新 | React 的状态管理,基于不可变数据和显式状态更新,每次状态改变会触发组件重新渲染,默认比较整棵树的 VDOM |
| 组件级的精准更新,未使用到对应的响应式数据的组件不会更新 | 默认保守更新,更新时会递归渲染所有子组件,这也意味着需要开发者手动管理和优化 |
| 开发者更加专注于业务逻辑 | 开发者可以精准控制更新逻辑(比如跳过某些子组件的更新) |
| “省心”来源于 Vue 自动追踪,无需思考依赖关系 | “费心”来自于 Hooks 的依赖数组需要手动声明,容易遗漏或过度填充;不可变性要求必须返回新的对象/数组,增加了代码复杂度 |
| 让开发者写更少的代码,做更多的事 | 给开发者足够的权力,即使这意味着更复杂 |
也正是因为数据驱动原理的不同:
- Vue 会自动追踪,只要发生变化就触发组件更新,就要求尽可能减少dom的移动
- React 是保守更新,默认递归渲染所有子组件,所以为了快速响应就要求开发者做出一些手段
进而导致了二者diff算法的不同
Vue 的响应式绑定需要 LIS
- 自动依赖追踪 已经帮 Vue 缩小了更新范围,因此 Diff 可以“奢侈”地追求最少移动(LIS)
- 如果 Vue 用贪心:
在复杂顺序变化(如sort)时,DOM 移动次数会变多,违背其“最小化操作”的目标
React 的不可变性需要贪心
- 递归渲染 意味着每次更新都可能涉及大量组件,Diff 必须足够快,否则会成为性能瓶颈。
- 如果 React 用 LIS:
计算O(k log k)的 LIS 会拖慢高频更新(如输入框连续输入),违背其“快速响应”的目标
后记:
心智负担重是Vue转React过程中一个绕不开的问题,进入新的公司实习前期,本人也困惑过一段时间;究竟什么使用添加依赖、究竟怎么防止闭包陷阱、为什么要我自己来管这些东西......所以本文相当于是个人使用下来的一份框架对比总结;如果你的经历和我类似,希望本文对你有帮助。若有误的地方,欢迎交流指正~
后续会在本文中同步更新React的双/多Filber树等内容...
顺便吐槽一句,掘金现在的图片上传为啥如此龟速,下次能不能来个“保存”按钮让用户点击一下,上午写得文章以为自动更新完了,结果一刷新整篇文章就剩Vue的部分了...