直接操作 DOM 带来的问题
首先我们先来看一段代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="list"></ul>
<button id="add-item">Add Item</button>
<script>
const list = document.getElementById('list');
let itemCount = 0;
document.getElementById('add-item').addEventListener('click', () => {
itemCount++;
// 每次都重新渲染整个列表
list.innerHTML = ''; // 清空列表
for (let i = 0; i < itemCount; i++) {
const li = document.createElement('li');
li.textContent = 'Item ' + (i + 1);
list.appendChild(li);
}
});
</script>
</body>
</html>
假设我们有一个列表,每次向列表中添加一个新项时,都会更新整个列表。
每次点击按钮,整个列表都会被清空并重新渲染,即使只需要添加一个新的列表项。这样频繁的 DOM 操作会导致性能问题,尤其是在列表项非常多的情况下。除了这些之外,当我们每次操作 DOM 时,浏览器会重新计算布局、重新渲染页面。这些操作会阻塞主线程,导致页面卡顿,如果我们在一个很大的工程中重复的做类似的操作,就会使我们的项目运行的很卡。所以就此引出了虚拟 DOM 的概念,我们只关心改变的那个 DOM 其他的不用去管。
我们再来看一个创建 div 元素的操作:
const div = document.createElement('div');
const items = [];
for (let item in div) {
items.push(item);
}
// 以逗号分隔并输出所有属性
console.log(items.join(' | '));
我们可以看到,创建一个简单的 div 元素,它就需要添加了这么多的属性和方法:
从上图中可以看出,我们每一次创建 DOM 都会创建出这么多属性来,如果多次创建,可以想象浏览器的性能。 基于以上,我们总结出下面几点:
-
DOM 操作是“昂贵”的,js 运行的效率高
-
尽量减少 DOM 操作,而不是“推到重来”
-
项目越复杂,影响就越严重
-
vdom 即可解决这个问题
-
将 DOM 对比操作放在 js 层,提高效率
虚拟 DOM 真的就天下无敌吗?
虚拟 DOM 并不是总是比直接操作 DOM 快,但它在大多数情况下能提高性能和开发效率。虚拟 DOM 的设计初衷是为了简化开发者对复杂 UI 更新的管理,而不是单纯追求操作速度的提高。
虚拟 DOM 和直接操作 DOM 在开发上的区别主要体现在代码复杂度、性能优化、开发效率和维护性等方面。以下是两者的关键区别:
从编程范式上讲,直接操作 DOM 属于命令式编程,开发者需要明确地描述如何一步步更新 DOM。例如,需要手动查找元素、修改属性、插入或删除节点。而虚拟 DOM 属于声明式编程,开发者只需要描述 UI 在不同状态下应该是什么样的,而不必关心如何一步步实现这些变化。框架负责处理底层的 DOM 操作。
// 直接操作 DOM
const list = document.getElementById('myList');
const newItem = document.createElement('li');
newItem.textContent = 'New Item';
list.appendChild(newItem);
// 虚拟 DOM
function MyComponent() {
const [items, setItems] = useState([]);
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
<button onClick={() => setItems([...items, 'New Item'])}>Add Item</button>
</ul>
);
}
从性能方面来讲,直接操作 DOM 需要开发者手动优化性能,避免不必要的 DOM 操作,如频繁的重排和重绘。对于复杂的 UI 操作,容易产生冗余的 DOM 更新,导致性能瓶颈。而虚拟 DOM 通过在内存中创建一个虚拟的 DOM 树,比较新旧虚拟 DOM 树的差异,最后只更新实际需要变化的部分。这样可以减少对真实 DOM 的操作次数,它还可以自动处理批量更新,将多次状态变化合并成一次实际的 DOM 更新,减少重排和重绘。
从开发效率上讲,直接操作 DOM,它的代码更接近底层实现,开发者需要了解和处理 DOM 的细节,可能导致更多的样板代码和错误。 在复杂应用中,手动管理状态和 UI 更新容易引入 bug 和性能问题,开发难度增加。而虚拟 DOM 提供了更高的抽象层次,开发者专注于业务逻辑和 UI 状态的描述,而不必关心底层 DOM 的具体操作。更简洁的代码和更少的样板代码,更容易实现复杂的 UI 交互和状态管理。 框架提供的生命周期方法和状态管理工具进一步简化了开发过程。
在状态管理方面,直接操作 DOM 的状态和 UI 更新逻辑紧密耦合,状态管理复杂且容易出错而虚拟 DOM 实现了状态和 UI 更新逻辑通常分离,使用框架自带的状态管理工具,如 React 的 useState、context、redux 等状态关联工具更容易管理复杂的应用状态。
总的来说,直接操作 DOM 适用于简单的、少量 DOM 操作的场景,能够更直接地控制页面,但开发复杂应用时容易产生维护和性能问题。虚拟 DOM 通过抽象 DOM 操作和状态管理,简化了开发过程,尤其适合开发复杂的、需要频繁更新的 UI 应用,并且在性能优化和维护性上具有明显优势。
什么是虚拟 DOM
虚拟 DOM 是一种在内存中对真实 DOM 的轻量级复制,它通过描述 UI 的当前状态来减少直接操作真实 DOM 的次数。每当状态发生变化时,虚拟 DOM 会计算出变化前后的差异(diff),然后只将必要的更新应用到真实 DOM 上。这样可以提高性能,减少浏览器的重排和重绘,同时简化开发者对 UI 更新的管理。
在 React 中,整体的渲染流程可以分为两个阶段:
-
render 阶段:从虚拟 DOM 转换成 Fiber,并且对需要 DOM 操作的阶段打上 effectTag 的标记。
-
commit 阶段:对有 effectTag 标记的 Fiber 节点进行 dom 操作,并执行所有的 effect 副作用。
React 的 commit 阶段是不可中断的。在这个阶段,React 会将计算好的变化应用到真实 DOM 上。如果这个阶段被中断,可能会导致 DOM 的不一致,从而使用户看到不完整的 UI 更新,这会造成闪烁或不稳定的界面。
reconciliation 是 React 将虚拟 DOM 转换为 Fiber 树的过程,它的目标是找出虚拟 DOM 树中的变化。这个过程是可以被打断的,因为它是由 React 的调度器(scheduler)来管理的,调度器会根据任务的优先级来决定何时执行或暂停调和过程,以确保 React 在浏览器空闲时执行渲染操作,从而提高性能和响应性。
从虚拟 DOM 到 Fiber 的主要步骤有以下几个方面:
-
虚拟 DOM 构建:React 通过 JSX 编译生成虚拟 DOM 树,这是一棵轻量级的 JavaScript 对象树,描述了组件的 UI 结构。
-
协调(Reconciliation):在这个阶段,React 会将新构建的虚拟 DOM 树与前一次渲染的虚拟 DOM 树进行比较。React 使用 diff 算法来找出两个树之间的差异,从而确定哪些部分需要更新。这个过程主要是为了最大限度地减少不必要的 DOM 操作。
-
生成 Fiber 树:根据协调阶段确定的需要更新的部分,React 会生成一棵 Fiber 树。Fiber 是 React 用来表示组件的状态和结构的单元,每个 Fiber 节点都包含了组件的类型、状态、子节点和兄弟节点等信息。这棵树不仅仅是一个 UI 描述,还包含了与渲染相关的调度信息,便于 React 更精细地管理更新过程。
-
构建工作单元:React 将确定的更新操作分解成多个称为工作单元的任务,这些任务被组织成 effect list(副作用列表)。每个工作单元都与一个 Fiber 节点关联,包括组件的添加、更新、删除操作,以及可能的副作用(如生命周期方法的调用)。
-
执行工作单元:React 使用调度器来管理这些工作单元的执行顺序。调度器会根据任务的优先级决定执行顺序,以确保高优先级任务(如用户交互)优先执行。React 会依次执行这些工作单元,更新组件状态,执行必要的副作用。
-
更新 Fiber 树:在执行工作单元时,React 会根据任务的结果更新 Fiber 树。这可能包括改变 Fiber 节点的状态、创建新的 Fiber 节点、或者标记某些节点为不需要渲染。在所有工作单元执行完毕后,Fiber 树的最新状态将被用于最终的 DOM 更新。
diff 算法作用在 reconcile 阶段,也就是构建 JSX 编译转换之后,FIber 树建立之前。
第一次渲染不需要 diff,直接虚拟 DOM 转 FIber。而再次渲染的时候,会产生新的虚拟 DOM,这时候要和之前的 fiber 做下对比,决定怎么产生新的 fiber,对可复用的节点打上修改的标记,剩余的旧节点打上删除标记,新节点打上新增标记。
在第一次渲染时,React 会直接将虚拟 DOM 转换为 Fiber 树,不需要进行 diff 操作。每个虚拟 DOM 节点都对应一个新的 Fiber 节点。在后续渲染中,React 会生成一个新的虚拟 DOM 树,并将其与现有的 Fiber 树进行对比(diff)。这个过程决定了如何更新 Fiber 树:
-
对于可以复用的节点,React 会在相应的 Fiber 节点上打上更新的标记。
-
旧的、无法复用的节点将被打上删除标记。
-
新增的节点则会被打上新增标记。
React 的 diff 算法实现原理
我们都知道,在 React 中,有两棵维护的 fiber 树:一棵是当前正在展示的 UI 对应的树,称为 current 树;另一棵是在更新过程中构建的新树。在更新时,React 通过对比新旧节点来决定是复用现有节点还是创建新节点。
reconcileChildren
reconcileChildren 函数是 DOM diff 的入口函数,它的源码实现如下图所示:
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
/**
* 第一次渲染不存在 current.child,都是父亲节点先渲染,子节点后渲染
* 如果此新 fiber 没有旧 fiber,说明此新 fiber 是新创建的
*/
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
} else {
// 如果有老 fiber的话,做 DOM-DIFF 拿老的fiber 和新的 fiber 进行 DOM 比较
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
reconcileChildren 的源码并不长,主要做了两件事:
-
如果是首次渲染,则会把已经处理好的 fiber 树进行挂载。
-
如果不是首次渲染则调用 reconcileChildFibers 进行下一步处理。
我们关注一下 mountChildFibers 和 reconcileChildFibers,我们发现这两个函数分别指向 ChildReconciler,只是 mountChildFibers 的参数为 false,reconcileChildFibers 的参数为 true。
export const reconcileChildFibers = ChildReconciler(true); // 需要收集副作用
export const mountChildFibers = ChildReconciler(false); // 不用追踪副作用
从上面代码可以看出, mount 时执行的 reconcileChildFibers 和 update 时执行的 mountChildFibers 方式,实际上都是由 ChildReconciler 这个方法封装出来的,差别只在于传参不同。
ChildReconciler
ChildReconciler
是一个工厂函数,它根据传入的 shouldTrackSideEffects 参数生成不同的协调函数(reconcileChildFibers)。这些函数负责对比当前渲染的子组件(称为 current 树)和更新后的子组件(称为 workInProgress 树),从而决定如何复用已有的组件、删除不再需要的组件或添加新的组件。
参数 shouldTrackSideEffects 接收的是一个布尔值,它决定了 React 是否需要跟踪副作用(side effects),如组件的插入、移动或删除。在初次渲染时,这些操作不需要跟踪,因为不会进行 DOM 操作;在更新时则需要,以便最小化 DOM 操作的次数。
该工厂函数包含了多个不同的方法,其中:
-
deleteChild:在需要跟踪副作用的情况下,将要删除的子组件(Fiber 节点)标记为待删除。
-
deleteRemainingChildren:删除所有尚未被处理的旧子组件。
-
mapRemainingChildren:将未处理的旧子组件存储在一个 Map 中,以便在新子组件中快速查找并复用它们。
-
useFiber:用于复用一个现有的 Fiber 节点,并为其设置新的属性。
-
placeChild 和 placeSingleChild:决定子组件在 Fiber 树中的位置,并根据是否需要移动或插入新的组件来设置相应的副作用标记。
-
updateTextNode, updateElement, updatePortal, updateFragment 等:分别处理文本节点、普通元素、Portal 和 Fragment 的更新和插入逻辑。
-
createChild:为新的 React 元素创建对应的 Fiber 节点。
-
updateSlot 和 updateFromMap:用于在新的子组件中查找与旧子组件匹配的节点,如果找到则更新它,否则创建新的 Fiber 节点。
-
reconcileChildrenArray, reconcileChildrenIterator, reconcileSingleTextNode, reconcileSingleElement, reconcileSinglePortal:这些函数分别处理不同类型的新子组件(如数组、迭代器、文本节点、普通元素和 Portal)的协调过程。
删除单个节点 deleteChild
删除单一某个 fiber 节点,这里会将该节点,存储到其父级 fiber 节点的 deletions 中。
/**
* 将returnFiber子元素中,需要删除的fiber节点放到deletions的副作用数组中
* 该方法只删除一个节点
* 当前diff时不会立即删除,而是在更新时,才会将该数组中的fiber节点进行删除
* @param returnFiber
* @param childToDelete
*/
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) {
// 不需要收集副作用时,直接返回,不进行任何操作
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
// 若副作用数组为空,则创建一个
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion;
} else {
// 否则直接推入
deletions.push(childToDelete);
}
}
在这里我们提供了一个 demo:
import React, { useState } from 'react';
function ExampleComponent() {
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const deleteItem = (index) => {
const newItems = items.filter((item, i) => i !== index);
setItems(newItems);
};
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>
{item}
<button onClick={() => deleteItem(index)}>Delete</button>
</li>
))}
</ul>
<button
onClick={() => {
setItems([...items, `Item ${items.length + 1}`]);
}}
>
Add Item
</button>
</div>
);
}
export default ExampleComponent;
当我们点击删除一个子节点的时候,它是会之后 if 分支里面的,而 childToDelete 正是我们要删除的节点。
批量删除多个节点 deleteRemainingChildren
deleteRemainingChildren 函数在 React 的协调阶段用于删除某个 Fiber 节点的所有子节点,如果需要跟踪副作用则逐个标记为删除,通常在组件更新时使用,而在初次渲染时则不会执行删除操作。
function deleteRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber | null
): null {
if (!shouldTrackSideEffects) {
// 不需要收集副作用时,直接返回,不进行任何操作
return null;
}
/**
* 从 currentFirstChild 节点开始,把当前及后续所有的节点,通过 deleteChild() 方法标记为删除状态
* @type {Fiber}
*/
let childToDelete = currentFirstChild;
while (childToDelete !== null) {
deleteChild(returnFiber, childToDelete);
childToDelete = childToDelete.sibling;
}
return null;
}
复用 fiber 节点 useFiber
在 React 中,当没有更新时,当前的两棵 Fiber 树是完全对应的。但在更新发生后,两棵 Fiber 树就会出现差异。此时,如果需要为某个元素生成一个 Fiber 节点,存在两种情况:
-
如果 current 节点不存在或 current.alternate 不存在,表示该元素是新增的,需要直接创建新的 Fiber 节点;
-
如果 current.alternate 存在,则可以直接复用现有的 Fiber 节点。
在 useFiber 中,它会调用 createWorkInProgress 方法,基于旧基于旧 fiber 节点和新内容的 props,克隆生成一个新的 fiber 节点,从而实现旧节点的复用。
并在返回新的 fiber 节点前将其 index 属性重置为 0,sibling 属性重置为 null。将 index 属性重置为 0,使其作为子节点的第一个节点。将 sibling 属性设置为 null,这表示它在创建时没有兄弟节点。这是因为在复用节点的时候,暂时还不知道它将来会被如何使用的,有可能只是作为单个 Fiber 节点使用,因此把 index 和 sibling 进行了重置。
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
if (enableProfilerTimer) {
workInProgress.actualDuration = 0;
workInProgress.actualStartTime = -1;
}
}
workInProgress.flags = current.flags & StaticMask;
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
const currentDependencies = current.dependencies;
workInProgress.dependencies =
currentDependencies === null
? null
: {
lanes: currentDependencies.lanes,
firstContext: currentDependencies.firstContext,
};
workInProgress.sibling = current.sibling;
workInProgress.index = current.index;
workInProgress.ref = current.ref;
if (enableProfilerTimer) {
workInProgress.selfBaseDuration = current.selfBaseDuration;
workInProgress.treeBaseDuration = current.treeBaseDuration;
}
return workInProgress;
}
首先,createWorkInProgress 函数会尝试获取 current Fiber 树的副本作为 workInProgress Fiber 树。如果 workInProgress 已经存在,说明可以直接复用它,无需创建新的节点。
如果 workInProgress 为 null,则调用 createFiber() 函数,基于旧节点的 tag、key 和 mode 属性以及新内容的 props,构建一个新的 Fiber 节点,并复用旧节点的其他属性。接着,将 current Fiber 树和 workInProgress Fiber 树通过 alternate 属性相互连接。
如果 workInProgress 不为 null,则直接复用现有的 workInProgress 节点,并将 current 节点的属性同步到 workInProgress。
在 createFiber 函数中,会基于 tag、pendingProps、key 和 mode 构建一个新的 Fiber 节点,返回 new FiberNode(tag, pendingProps, key, mode) 作为结果。
createChild
createChild 函数是 React 内部用于根据传入的 newChild 创建对应的 Fiber 节点的工具函数。这个函数的主要作用是将 React 的虚拟 DOM(如文本节点、React 元素、数组或其他可迭代对象等)转换成 Fiber 节点,以便 React 在协调(reconciliation)阶段处理这些节点。
function createChild(
returnFiber: Fiber,
newChild: any,
lanes: Lanes
): Fiber | null {
if (
(typeof newChild === "string" && newChild !== "") ||
typeof newChild === "number"
) {
const created = createFiberFromText("" + newChild, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
}
if (typeof newChild === "object" && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const created = createFiberFromElement(
newChild,
returnFiber.mode,
lanes
);
created.ref = coerceRef(returnFiber, null, newChild);
created.return = returnFiber;
return created;
}
case REACT_PORTAL_TYPE: {
const created = createFiberFromPortal(
newChild,
returnFiber.mode,
lanes
);
created.return = returnFiber;
return created;
}
case REACT_LAZY_TYPE: {
const payload = newChild._payload;
const init = newChild._init;
return createChild(returnFiber, init(payload), lanes);
}
}
if (isArray(newChild) || getIteratorFn(newChild)) {
const created = createFiberFromFragment(
newChild,
returnFiber.mode,
lanes,
null
);
created.return = returnFiber;
return created;
}
throwOnInvalidObjectType(returnFiber, newChild);
}
return null;
}
在 createChild 中,它会根据不同的节点类型来调用不同的函数进行处理,如 createFiberFromElement、createFiberFromPortal 等方法。
updateSlot
updateSlot()和 createChild()两个方法很像,但两者最大的区别就在于:是否要复用 oldFiber 节点。
createChild() 用于为新增的子元素创建新的 Fiber 节点,主要在元素不存在于当前 Fiber 树中时调用。updateSlot() 则尝试复用现有的 Fiber 节点,主要在元素已存在但可能需要更新时调用。createChild() 直接创建新节点,而 updateSlot() 先检查旧节点是否可复用,能复用则更新,不能则返回 null。
假设有一个列表组件,其中的每个子元素都有唯一的 key。如果列表中的某个元素在更新时被添加了一个新项目,React 会调用 createChild() 为这个新项目创建一个新的 Fiber 节点。
而对于列表中未删除或未添加的元素,React 会调用 updateSlot() 来检查这些元素是否可以复用。如果它们的 key 和类型与之前的一致,则复用旧的 Fiber 节点并进行更新;否则,需要创建新的节点或进行适当的处理。
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
// Update the fiber if the keys match, otherwise return null.
const key = oldFiber !== null ? oldFiber.key : null;
if (
(typeof newChild === "string" && newChild !== "") ||
typeof newChild === "number"
) {
if (key !== null) {
return null;
}
return updateTextNode(returnFiber, oldFiber, "" + newChild, lanes);
}
if (typeof newChild === "object" && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) {
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
case REACT_PORTAL_TYPE: {
if (newChild.key === key) {
return updatePortal(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
case REACT_LAZY_TYPE: {
const payload = newChild._payload;
const init = newChild._init;
return updateSlot(returnFiber, oldFiber, init(payload), lanes);
}
}
if (isArray(newChild) || getIteratorFn(newChild)) {
if (key !== null) {
return null;
}
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
return null;
}
当 element 仅包含一个元素时(如普通的 React 函数组件、类组件或 HTML 标签等),React 会调用 reconcileSingleElement() 来处理。在更新过程中,如果找到可以复用的 Fiber 节点,那么会将该节点下的其他子节点标记为删除,以便在提交(commit)阶段移除不再需要的 DOM 元素。
复用节点的标准是:新旧节点的 key 和 type 都相同。如果任意一个不同,则无法复用,这时会删除旧节点,并创建一个新的 Fiber 节点来替代。
// 负责处理单个子元素的协调过程。这个函数的目的是根据新的React元素(element),
// 与现有的Fiber节点(currentFirstChild)进行比较,
// 并决定是更新现有的Fiber节点,还是创建一个新的Fiber节点。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
return existing;
}
} else {
if (
child.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false) ||
(typeof elementType === "object" &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
lanes,
element.key
);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
在上面的代码中,首先从新的 React 元素中提取 key,并将遍历指针初始化为当前 Fiber 树的第一个子节点。接着,循环遍历父 Fiber 节点下的所有子节点,对比每个子节点的 key 是否与新元素的 key 匹配。如果匹配,则进一步检查类型是否一致或符合复用条件;如果不匹配,则标记当前子节点为删除状态,继续检查下一个子节点。如果 key 和 type 均匹配,复用该节点并删除多余的兄弟节点,更新其属性和 ref 后返回复用的节点;如果 type 不匹配,则删除当前子节点及其兄弟节点,并跳出循环。
如果未找到可复用的节点,或者需要创建新的节点,则根据 element.type 创建一个新的 Fiber 节点。对于 Fragment 类型,创建一个 Fragment Fiber 节点;否则,创建一个普通的 Fiber 节点并处理 ref。最后,设置新节点的父关系,并返回新创建的 Fiber 节点。
处理并列多个元素 reconcileChildrenArray
这种情况要比之前处理单个节点复杂的多,因为可能会存在末尾新增、中间插入、删除、节点移动等情况,比如要考虑的情况有:
-
新列表和旧列表都是顺序排布的,但新列表更长,这里在新旧对比完成后,还得接着新建新增的节点;
-
新列表和旧列表都是顺序排布的,但新列表更短,这里在新旧对比完成后,还得删除旧列表中多余的节点;
-
新列表中节点的顺序发生了变化,那就不能按照顺序一一对比了;
-
节点更新:
-
旧: A - B - C
-
新: A - B - C
-
-
新增节点
-
旧: A - B - C
-
新: A - B - C - D - E
-
-
删除节点
1. 旧: A - B - C - D - E 2. 新: A - B - C
-
节点移动
1. 旧: A - B - C - D - E 2. 新: A - B - D - C - E
在 Fiber 结构中,并列的元素会形成单向链表,而且也没有双指针。在 Fiber 链表和 element 数组进行对比时,只能从头节点开始比较:
-
对于相同位置(索引相同)的节点,复用或保持不变的可能性较大;
-
如果 newChildren 数组遍历完后,oldFiber 链表仍有剩余节点,这些节点会被标记为删除;
-
如果 oldFiber 链表遍历完后,newChildren 数组还有剩余元素,则需要为这些新元素创建对应的 Fiber 节点;
-
当节点的顺序无法一一对应时,可能发生了节点移动,此时旧的 Fiber 节点会被存入一个 map 中,以便在后续处理中进行高效的查找和匹配。
这里有一种特殊情况:当 oldFiber.index > newIdx
时,说明旧的 Fiber 节点的索引比当前的新节点索引 newIdx 大,这通常表示在之前的 JSX 元素中,有一些无法转换为 Fiber 节点的元素。
在 Fiber 节点中,index 是由 JSX 数组中的下标决定的。如果某个下标对应的 JSX 元素无法转换为 Fiber 节点(例如,该元素为 null),那么会导致该下标在 Fiber 链表中缺失。例如,如果下标为 1 的 JSX 元素为 null,则转换后的 Fiber 链表索引可能会是:0 -> 2 -> 3
。当我们遍历新的 JSX 数组时,索引从 0 开始顺序遍历,旧的 Fiber 链表也会同步移动。当新的索引 newIdx 达到 1 时,oldFiber 移动到下一个节点的索引可能已经是 2,这时就会出现 oldFiber.index > newIdx
的情况。
在这种情况下,我们将 oldFiber 设置为 null,然后在执行 updateSlot() 时为当前的 newIdx 创建一个新的 Fiber 节点。当 newIdx 和 oldFiber.index 再次相等时,才能进行相同位置的比较和复用操作。
如下代码所示:
import React, { useState } from 'react';
export default function App() {
const [items, setItems] = useState([
<div key="0">Item 0</div>,
null, // 这个元素无法转换为 Fiber 节点
<div key="2">Item 2</div>,
<div key="3">Item 3</div>,
]);
const updateItems = () => {
setItems([
<div key="0">Item 0</div>,
<div key="1">New Item 1</div>, // 新增的节点,将导致 oldFiber.index > newIdx
<div key="2">Item 2</div>,
<div key="3">Item 3</div>,
]);
};
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
<button onClick={updateItems}>Update Items</button>
</div>
);
}
上面的这段代码就展示了当 JSX 数组中某些元素无法转换为 Fiber 节点时,React 如何通过 newIdx
和 oldFiber.index
的对比来处理这种情况。具体地说,当 oldFiber.index > newIdx
时,React 会创建一个新的 Fiber 节点来填补缺失的位置,从而确保 Fiber 树和新的 JSX 数组能够正确地同步和更新。
相同索引位置对比
同一个位置(索引相同),保持不变或复用的可能性比较大。不过也只能说可能性比较大,在实际开发中什么情况都会存在,我们先以最简单的方式来处理。
let resultingFirstChild: Fiber | null = null; // 用来存储新的Fiber树的第一个子节点。
let previousNewFiber: Fiber | null = null; // 用于追踪上一个处理过的新Fiber节点。
let oldFiber = currentFirstChild; // oldFiber 指向当前的第一个旧Fiber节点。
// lastPlacedIndex 和 newIdx 分别用来追踪上一个放置的位置和新子节点的索引。
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 对于数组中的每一个子组件,使用 updateSlot 函数尝试复用旧的Fiber节点,
// 如果无法复用,则创建新的Fiber节点。
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
在上面的这段代码中,它主要经历了如下步骤:
-
初始化变量:初始化变量,用于存储新构建的 Fiber 链表的头节点、最后一个处理的 Fiber 节点,以及当前的旧 Fiber 节点和遍历索引信息。这些变量帮助 React 构建新的 Fiber 链表并保持节点连接正确。
-
遍历新旧节点:React 遍历 newChildren 数组和 oldFiber 链表,逐个对比节点,决定是否复用旧 Fiber,创建新 Fiber,或删除旧节点。
-
处理索引不匹配:如果旧 Fiber 节点的索引大于当前新节点的索引,React 跳过该旧节点,直接为新节点创建新的 Fiber 节点。
-
处理索引匹配:如果旧 Fiber 节点的索引与新节点匹配,React 尝试复用旧节点,并获取下一个兄弟节点,继续处理。
-
复用或创建 Fiber 节点:React 调用 updateSlot 函数,若 key 和 type 匹配,复用旧节点,否则创建新的 Fiber 节点。
-
删除旧节点:如果启用了副作用追踪且未复用旧节点,React 将该旧节点标记为删除,以确保更新时移除无效的 DOM 元素。
-
处理节点位置:更新 lastPlacedIndex,判断新节点是否需要移动,还是可以复用其原位置。
-
构建新链表:将新处理的 Fiber 节点添加到链表中,更新头节点或末尾节点的指针。
-
继续处理下一个旧节点:更新 oldFiber 引用,指向下一个要处理的旧节点,继续循环。如果旧节点为空或创建新节点失败,跳过当前节点。
-
退出循环后的处理:如果 newChildren 数组未遍历完,创建剩余的新节点并添加到链表中。如果 oldFiber 链表未遍历完,标记剩余的旧节点为删除。
接下来我们来看一个例子:
旧: A - B - C - D - E
新: A - B - D - C
在本轮遍历中,会遍历 A - B - D - C
。A 和 B 都是 key 没变的节点,可以直接复用,但当遍历到 D 时,发现 key 变化了,跳出当前遍历。例子中 A 和 B 是自身发生更新的节点,后面的 D 和 C 我们看到它的位置相对于 oldFiber 链发生了变化,会往下走到处理移动节点的循环中。
新节点遍历完毕
若经过上面的循环后,新节点已全部创建完毕,这说明可能经过了删除操作,新节点的数量更少,这里我们直接把剩下的旧节点删除了就行。
if (newIdx === newChildren.length) {
// 如果新子组件数组已处理完毕但还有未处理的旧Fiber节点,
// 使用 deleteRemainingChildren 函数删除剩余的旧Fiber节点。
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
后续已不需要其他的操作了,直接返回新链表的头节点指针即可。
旧 fiber 节点遍历完毕
若经过上面的循环后,旧 fiber 节点已遍历完毕,但 newChildren 中可能还有剩余的元素没有转为 fiber 节点,但现在旧 fiber 节点已全部都复用完了,这里直接创建新的 fiber 节点即可。
if (oldFiber === null) {
// 这里已经没有旧的fiber节点可以复用了,然后我们就选择直接创建的方式
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 接着上面的链表往后拼接
if (previousNewFiber === null) {
// 记录起始的第1个节点
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
// 返回新链表的头节点指针
return resultingFirstChild;
}
到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢?
节点位置发生了移动
若节点的位置发生了变动,虽然在旧节点链表中也存在这个节点,但若按顺序对比时,确实不方便找到这个节点。因此可以把这些旧节点放到 Map 中,然后根据 key 或者 index 获取。
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
将剩余所有的子节点都存放到 map 中,方便可以通过 key 快速查找该 fiber 节点,若该 fiber 节点有 key,则使用该 key 作为 map 的 key;否则使用隐性的 index 作为 map 的 key:
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber
): Map<string | number, Fiber> {
const existingChildren: Map<string | number, Fiber> = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
把所有的旧 fiber 节点存储到 Map 中后,就接着循环新数组 newChildren,然后从 map 中获取到对应的旧 fiber 节点(也可能不存在),再创建出新的节点。
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
existingChildren.forEach((child) => deleteChild(returnFiber, child));
}
这段代码的主要作用是在 reconcileChildrenArray 的过程中处理新旧节点的匹配情况:
-
通过 updateFromMap 函数,React 尝试在 existingChildren(旧节点集合)中找到与当前新节点匹配的旧节点,如果匹配成功,则复用该旧节点,并从 Map 中删除。
-
复用或创建的新节点通过 placeChild 函数确定其在新的 Fiber 树中的位置,并将其链接到新生成的 Fiber 链表中。
-
最后,如果有未复用的旧节点(仍在 Map 中),这些节点会被标记为删除,以确保它们在 DOM 中被移除。
假设我们有如下代码:
import React, { useState } from 'react';
function App() {
const [items, setItems] = useState(['A', 'B', 'C', 'D', 'E']);
const shuffleItems = () => {
setItems(['A', 'B', 'D', 'C']);
};
return (
<div>
<ul>
{items.map((item, index) => (
<li key={item}>{item}</li>
))}
</ul>
<button onClick={shuffleItems}>Shuffle Items</button>
</div>
);
}
export default App;
它的初始状态是这样子的;
旧节点链表:A -> B -> C -> D -> E
新节点数组:A -> B -> D -> C
根据前面的内容我们可以知道,A 和 B 是已经被复用了的,当 React 遍历到新节点 D 时,它发现旧节点 C 的 key 不匹配。因此,React 会将剩余的旧节点放入 Map 中。Map 结构如下:
{
"C": <Fiber Node for C>, // 旧节点 C
"D": <Fiber Node for D>, // 旧节点 D
"E": <Fiber Node for E> // 旧节点 E
}
如下图所示:
最后经过一轮循环之后,就剩下旧节点 E 了:
在最后面,无法复用的节点最终也会被删除,至此,整个 DOM DIFF 结束。
总结
React 的 diff 算法可以分为两个关键阶段:
-
第一阶段:逐一对比 - 在这个阶段,React 会逐一对比新旧节点。如果节点可以复用,继续对比下一个节点;如果不匹配,立即结束这一阶段的对比。
-
第二阶段:基于 Map 的查找- 结束第一阶段后,React 会将剩余的旧节点放入一个
Map
中,并开始遍历剩余的新节点。在这个阶段,React 会通过键值对的方式查找Map
中是否有可复用的节点,如果找到则复用,最后将未复用的旧节点标记为删除。
例如:
- 旧节点链表: A - B - C - D - E - F
- 新节点数组: A - B - D - C - E
在第一阶段中,React 对比到 B
后,发现后续节点不再匹配,于是结束逐一对比。第二阶段中,React 将 C - D - E - F
存入 Map
,然后通过 Map
查找并复用新节点中的 D - C - E
,最终将 F
标记为删除。
虚拟 DOM 的优势在于,它使开发者看起来像是在重新渲染整个界面,但在背后,React 通过计算补丁操作来高效更新 DOM。虽然虚拟 DOM 的 diff 和补丁算法并非最优解,但它提供了一种简洁且强大的方式来表达应用状态。开发者只需声明所需的最终界面,React 会计算出最有效的 DOM 操作来实现这一目标。这样,我们不需要手动操作 DOM,也无需担心之前的 DOM 状态,更不必担心重新渲染整个界面带来的性能损耗。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你想参与或者交流学习,可以加我微信 yunmz777 如果你也喜欢,欢迎 star 🚗🚗🚗