【Vue转React】更新机制对比,React开发中的心智负担从何而来?

前言

作为VueReact的选手,相信不少人会和我有一样的困惑:

  • 为什么React的心智负担这么重,明明我在Vue中直接修改响应式数据就可以了
  • 作为开发者,有时候还得停顿下来思考:这个组件是否需要memo避免不必要的更新;这个Hook的依赖项是否传多/传少了;有时候依赖项的传染性 很容易绕进去...可我在Vue中响应式数据似乎更加"自动"一点
  • 刚开始从Vue转型而来,应该从什么角度理解更加自由的React

本文将从Vue的更新机制讲起,并对比与React的差别,以个人见解分享一下理解的角度

Vue响应式系统 & 更新策略:

  • Vue2基于Object.defineProperty()、Vue3基于Proxy建立的响应式系统,会将对应的watchers收集起来,后续响应式数据一发生变化,就通知对应的watchers重新执行;换句话说:当响应式数据变化,通知对应用到当前响应式数据的render()重新执行,生成新的VDom
  • reactivity(传送门)与runtime-core模块的结合也不会是此次的讨论重点;此处我们主要讨论Vue的更新策略(此外,此处也不会讨论props的更新,而是聚焦在children的更新逻辑上)
  • v-node指的虚拟节点;v-dom指的虚拟节点构成的虚拟树

diff算法:

  • 同级比较,通过type``key判断是否属于相同
    • 不相同,直接return
    • 相同,下探递归进行对比
  • 双端对比,缩小乱序范围
  • 中间乱序部分采用最长递增子序列,尽可能减少移动的次数
  • 这里有个容易绕进去的点,很多时候我们都会想:对于更新过程中同时存在的n1 n2,到底最终要使用哪个?是不是在比较的过程中把所有更新的点移植到某一方身上,再以某一方的最终值为参照物实现更新❌
  • 更新过程中是不断利用n1 n2的过程,不存在一方附加到另一方的情况,二者存在的意义就是比较

一图胜千言

原图链接

有关LIS是否执行的优化:

这部分建议看图

尽管使用了LIS算法减少了移动的次数,但是LIS自身的执行就是耗时的,对于乱序部分变化前后位置均保持不变的情况下,其实无需执行LIS

思路:

  1. 在遍历过程中,维护两个变量:
    • maxNewIndexSoFar记录目前遍历过程中最大的newIndex即记录上一个VNodenewIndex
    • moved标记newChildren乱序部分是否真存在"乱序"的情况(即当前的newIndex<maxNewIndexSoFar)
  2. 如果当前newIndex>maxNewIndexSoFar说明这个节点相对位置没有发生变化,更新maxNewIndexSoFar
  3. 如果任何一个当前newIndex<maxNewIndexSoFar说明出现了乱序,moved标记为true,表明后续需要执行LIS

React:

  1. 分为单节点 diff 和多节点 diff。根据babel转换之后的结果,而非靠视觉层级进行判断

  2. 新树对比老树,操作的对象是老树,参照物是新树

newChild类型 DIff 模式 示例
对象(ReactElement) 单节点 diff <div /><Component />
字符串或数字 单节点 diff "Hello"{123}
数组(Array) 多节点 diff [<div key="1" />, <div key="2"/>]
其他(如 nullfalse) 跳过 {isShow && <div />}可能返回 false

React 的 diff 算法遵循三个原则:

  1. 同级比较,跨层级的 DOM 不进行复用,意味着 diff 的过程中是在比较兄弟节点(sibling)的过程
  2. 不同类型的节点生成的 DOM 不同,此时会直接销毁老节点及子孙节点,并新建节点(类型指 html 标签类型)
  3. 可以通过key来对元素diff的过程提供复用的线索

单节点 diff:

React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用

  • child !== nullke相同且 type不同 时执行deleteRemainingChildrenchild及其兄弟 fiber 都标记删除
  • child !== nullkey不同 时仅将child标记删除

多节点 diff:

多节点 diff,无外乎三种基本情况:

  1. 节点更新:
xml 复制代码
// 旧
<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>
  1. 节点新增或删除
xml 复制代码
// 旧
<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>
  1. 节点位置移动
xml 复制代码
// 旧
<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 会经历两次循环:

  1. 第一轮:处理更新的节点
  2. 第二轮:处理不属于更新的节点

第一轮遍历:

  1. let i = 0,遍历newChildren,将newChildren[0]oldFiber比较,判断DOM节点是否可复用。
  2. 如果可复用,i++,继续比较newChildren[1]oldFiber.sibling,可以复用则继续遍历。
  3. 如果不可复用,分两种情况:
  • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束
  • key相同 type不同 导致不可复用,会将oldFiber标记为DELETION,并继续遍历
  1. 如果newChildren遍历完(即i === newChildren.length - 1)或者oldFibers遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束

第二轮遍历:

分三种情况:

  • newChildren 没遍历完,oldFibers 遍历完 :意味着本次更新有新节点插入,只需遍历剩下的newChildren生成的workInProgress fiber依次标记Placement
  • newChildren 遍历完,oldFibers 没遍历完 :只需遍历剩下的oldFibers,依次标记Deletion
  • newChildren 和 oldFilbers 都没有遍历完 :即节点可能新增、可能删除、可能移动了位置👇

  1. 收集剩余的 oldFilbers ,建立 key -> oldFiber 的映射表existingChildren

此处可回顾Vue的操作,思想一致

  1. 遍历剩余的 newChildren ,尝试匹配或新增节点
    • 如果newChildkey,则在existingChildren中查找对应的oldFiber
      • 找到:复用该oldFiber(可能移动位置)
      • 没找到:创建新节点(新增
    • 如果newChild没有key,则按顺序尝试匹配(效率较低)
  2. 遍历完成后,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 的过程中,常常需要考虑是否需要使用useCallbackReact.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 会拖慢高频更新(如输入框连续输入),违背其"快速响应"的目标

后记:

心智负担重是VueReact过程中一个绕不开的问题,进入新的公司实习前期,本人也困惑过一段时间;究竟什么使用添加依赖、究竟怎么防止闭包陷阱、为什么要我自己来管这些东西......所以本文相当于是个人使用下来的一份框架对比总结;如果你的经历和我类似,希望本文对你有帮助。若有误的地方,欢迎交流指正~

  • 后续会在本文中同步更新React的双/多Filber树等内容...

  • 顺便吐槽一句,掘金现在的图片上传为啥如此龟速,下次能不能来个"保存"按钮让用户点击一下,上午写得文章以为自动更新完了,结果一刷新整篇文章就剩Vue的部分了...

相关推荐
我是若尘2 分钟前
利用资源提示关键词优化网页加载速度
前端
moyu845 分钟前
跨域问题解析(下):Nginx代理、domain修改与postMessage解决方案
前端
moyu8418 分钟前
跨域问题解析(上):JSONP、CORS与Node代理解决方案
前端
moyu8422 分钟前
深入理解TCP的三次握手与四次挥手
前端
不一样的少年_39 分钟前
头像组件崩溃、乱序、加载失败?一套队列机制+多级兜底全搞定
前端·vue.js
Code_XYZ1 小时前
uni-app x开发跨端应用,与web-view的双向通信解决方案
前端
wordbaby1 小时前
构建时规划,运行时执行:解构 React Router 的 prerender 与 loader
前端·react.js
用户5806139393001 小时前
【前端工程化】Eslint+Prettier vue项目实现文件保存时自动代码格式化
前端
麦当_1 小时前
基于 Shadcn 的可配置表单解决方案
前端·javascript·面试
MrSkye1 小时前
从零到一:我用AI对话写出了人生第一个弹幕游戏 | Prompt编程实战心得
前端·ai编程·trae