什么是 DOM ?
DOM 全称是 Document Object Model,作用是将 html/xml 文档组织成对象模型。
DOM 不是一种编程语言,但是它提供了编程接口可由 JS 等编程语言操控。
以浏览器的角度来看 DOM
浏览器渲染中一个很重要的工作就是将文档形成页面,大概分为下面三个流程:
浏览器接收到响应文档后,会进行文档解析,并构建DOM:
最后解析 DOM,形成页面:
举个例子,假设现有以下 HTML
<html>
<head>
<title>dom</title
</head>
<body>
<p id="id">react dom</p>
</body>
</html>
解析成 DOM 后结构如下:
什么是 JSX ?
JSX 是 React 提出的一个概念,它本质是一种 JS 的语法糖。长得像 HTML,但本质上还是 JS。
经过 Babel 编译后会转换成 React.createElement
React 官方对 Element 是这样介绍的:
Elements are the smallest building blocks og React apps
可以理解为 Element 就是 React 中元素的最小单位。如果递归调用 React.createElement 就可以生成 Elements Tree。
举个例子, JSX ===〉 Elements Tree :
// jsx
return (
<div className='demo-div'>
{/* 组件 */}
<Header>hello</Header>
<p>world</p >
footer
</div>
)
// Babel 编译后
return (
React.createElement(
// type
'div',
// props
{
className: 'demo-div',
},
// children
React.createElement(
Header,
null,
'hello'
),
React.createElement(
'p',
null,
'world',
),
'footer',
)
)
// Elements Tree
{
type: 'div',
props: {
className: 'demo-div',
children: [
{
type: function Header,
props: {
children: 'hello',
},
},
{
type: 'p',
props: {
children: 'world',
},
},
'footer',
],
},
}
什么是 Virtual DOM ?
由 elements 组成的树就是 Virtual DOM。
它的本质就是用 JS 对象来模拟 DOM 树,在渲染更新时,先对 JS 对象进行操作,再将操作后的 JS 对象(Virtual DOM)渲染成真实 DOM。减少对 DOM 的操作,提升性能。
什么是 DOM Diff ?
DOM Diff 其实就是比较你本次 UI 更新前后两棵 Elements Tree 的差异。
在 React 中,DOM Diff 是基于两个假设实现的:
- 两个不同类型的元素会产生不同的树
- 对于同一层级的一组子元素,它们之间可以通过唯一的 key 进行区分
React 的 Reconcilation 机制是这样描述的:
在 UI 更新时:第一次调用 render() 方法时会生成第一颗 elements 树,
在下一次 state 或 props 变化时,相同的 render() 方法会生成一棵不同的 elements 树。
再通过 diff 算法对比前后两个 elements 树的差异,从而实现真实 DOM 的增量更新渲染
由此可知,当对比两棵树时,React 会从两棵树的根节点进行比较,比较的过程可以分为下面几种类型:
对比不同类型的元素节点
对于不同类型的节点,React 会基于第一条假设,直接删除旧节点,然后创建新节点。
举个例子,从 before 更新到 after
// before
<root>
<A>
<B />
<C />
</A>
<D />
</root>
// after
<root>
<D>
<A>
<B />
<C />
</A>
</D>
</root>
抽象成更好理解的 DOM Tree 模型:
在上述例子中,React 会执行下列操作:
- 移除 root 节点的左子树,即 A 节点及其子节点 B 和 C
- 重新创建 A 节点以及其子节点 B 和 C
- 插入到 root 节点右子树 D 节点的子节点中
对比相同类型的元素节点
对比相同类型的元素节点时,React 会保留 DOM 节点,仅对比及更新有变更的属性。
【同为 DOM 元素类型】
// before
<div className="before" title="demo" />
//after
<div className="after" title="demo" />
上面这个例子,由于 type 都是 div,属于相同类型的节点,React 只会修改 DOM 元素上的 classname 属性
【同为自定义组件类型】
此时 React 并不知道如何去更新 DOM,因为这些逻辑是在组件里去实现的。
当组件更新时,其实例是保持不变的,所以此时 React 做的就是根据新节点的 props 来更新旧节点的组件实例,从而触发一个更新过程,最后再对所有的 child 节点进行 diff 递归比较更新。
用生命周期来表示的话就是下面这样的:
递归对比子节点
默认情况下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素列表,按顺序比对每个子节点。
【场景一:插入节点】
// before
<root>
<A />
<B />
<C />
</root>
// after
<root>
<A />
<B />
<D />
<C />
</root>
上述例子,我们在 B 和 C 节点中间,插入了 D 节点。
当比较到第三个节点时,发现 C 和 D 不相同,React 会删除 C,然后创建 D 放在 C 的位置;
最后再创建一个 C 放到尾部。会有两次插入 DOM 操作。
那么有没有办法只操作一次 DOM 呢?
如果要做到只进行一次操作,则需要保持 DOM 结构的稳定性,比如再插入一个 null 节点,这样就能避免 C 被卸载后又重建,减少一次 DOM 操作。
// before
<root>
<A />
<B />
{nul}
<C />
</root>
但是每次都要提前插入一个 null 节点,对开发而言绝不是一个好办法。
因此,React 提出了 Key 这个概念。
当元素拥有 key 属性时,React 会通过 key 来匹配新旧两棵树上的子元素。
key 在列表中需要保持唯一,不建议使用数组下标来做 key,因为当重新排序的操作时,key就会变化,从而造成预期之外的变化。
// before
<root>
<A key="A" />
<B key="B" />
<C key="C" />
</root>
// after
<root>
<A key="A" />
<B key="B" />
<D key="D" />
<C key="C" />
</root>
添加了唯一的 key 后,React 就可以定位到要插入的位置,只需要进行一次插入 DOM 操作。
【场景二:删除节点】
// before
<root>
<A key="A" />
<B key="B" />
<D key="D" />
<C key="C" />
</root>
// after
<root>
<A key="A" />
<B key="B" />
<C key="C" />
</root>
删除节点与添加节点类似: 如果没有 key,那么当比较到第三个节点时,D 和 C 不同,React 会删除 D,然后创建一个 C 并放到 D 的位置;最后再删除末尾的 C 节点。
如果有 key:React 就可以确定要移除的位置,只需要进行一次删除DOM操作。
【场景三:交换节点位置】
// before
<root>
<A key="A" />
<B key="B" />
<C key="C" />
</root>
// after
<root>
<A key="A" />
<C key="C" />
<B key="B" />
</root>
如果没有 key,React 会认为 B 更新成了 C,C 更新成了 B,因此会执行两次卸载更新的操作。
如果有 key:React 就知道 key B 和 key C 在更新后都还存在,DOM 节点可以复用,只需要简单交换一下位置即可。
React DOM Diff 算法详解
前面介绍完 DOM Diff 是什么之后,接下来我们来仔细研究下 React 中的 DOM Diff 算法是如何实现的。
Fiber 与 双缓存机制
Fiber:React 中用 Fiber 表示 Virtual DOM
Fiber 包含了 DOM 结构相关的 type、key 等静态属性,同时还包含了 return、child、sibling 等连接形成树的属性,以及 alternate 属性用于指向下一次更新对应的 Fiber。
双缓存机制:在内存中构建并直接替换
在 React 中最多会同时存在两棵 Fiber 树,一个是当前 DOM 节点对应的 current Fiber,一个是内存中构建的 workInProgress Fiber,它们之间通过 alternate 属性连接。
当 workInProgress Fiber 构建完成交给 Renderer 去渲染后,应用根节点的 current 就会从指向 current Fiber 改为指向 workInProgress Fiber,从而完成 DOM 更新。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
DOM Diff 流程概览
假设一个函数组件发生了更新,那么 React 会执行以下流程:
-
performSyncWorkOnRoot:入口,同步更新,将 workInProgress 传入 performUnitOfWork 并遍历
-
performUnitOfWork:创建下一个 Fiber 节点,赋值给 workInProgress,并将 workInProgress 和已创建的 Fiber 节点连接构成 Fiber 树
-
beginWork:从 rootFiber 开始深度优先遍历,为遍历到的每个节点执行 beginWork。该方法根据传入的 Fiber 节点创建 子 Fiber 节点,并将两个节点连接。当遍历到叶子结点时,就会进入 completeWork 阶段
-
completeWork:当某个节点执行完 completeWork,如果存在兄弟 Fiber 节点,那么会进入兄弟 Fiber 节点的 beginWork 阶段;否则进入 父节点 的 beginWork 阶段
-
直到回到 rootFiber,render 完成,提交 commitWork,执行 DOM 操作
下面来看下每个流程具体都做了什么
performSyncWorkOnRoot
DOM Diff 的入口方法,将 workInProgress 传入 performUnitOfWork 并遍历
// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
performUnitOfWork
该方法主要作用是创建更新后的 Fiber 节点,并将 workInProgress 树与新创建的 Fiber 节点连接构成新的 Fiber 树。
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
next = beginWork(current, unitOfWork, subtreeRenderLanes)
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
beginWork
beginWork 的作用是:根据传入的 Fiber 节点,创建其子 Fiber 节点。
该方法会接收 currentFiber 和 workInProgressFiber:
- 如果存在 current,表示存在上一次更新时的 Fiber 节点,走 update 逻辑
- 如果不存在 current,表示不存在对应的 Fiber 节点,走 mount 逻辑
function beginWork(
// 当前组件对应的 Fiber节点在上一次更新时的 Fiber节点,即 workInProgress.alternate
current: Fiber | null,
// 当前组件对应的Fiber节点
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// update
// 满足条件 didReceiveUpdate === false 时可以复用 current.child 作为 workInProgress.child
if (current !== null) {
if (current.props !== workInProgress.props) {
didReceiveUpdate = true;
}
// ...其他判断逻辑
} else {
didReceiveUpdate = false;
}
// mount
// 根据 tag 创建不同类型的 Fiber 子节点
switch (workInProgress.tag) {
case FunctionComponent: {
return updateFunctionComponent(
current,
workInProgress,
renderLanes,
...,
);
}
}
}
如果是走 mount 逻辑,那么会根据 tag 创建不同类型的 Fiber 子节点。
举个例子,tag 类型是函数组件,那么会执行 updateFunctionComponent 方法,如果需要来创建 Fiber 子节点,那么将会走到 reconcileChildren 逻辑。
function updateFunctionComponent(
current,
workInProgress,
renderLanes,
) {
if (current !== null && !didReceiveUpdate) {
// 复用 current 子节点
return ...;
}
// 创建新的子节点 or diff 生成新的子节点
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
reconcileChildren
对于 mount 的节点,该方法会创建新的子节点;对于 update 的节点,它会进行 DOM Diff,再将 diff 的结果生成新的子节点。
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
// current 为 null,mount
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 存在 current,dom diff
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
function ChildReconciler(shouldTrackSideEffects) {...}
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
其中,mountChildFibers 和 reconcileChildFibers 逻辑几乎一致,区别在于reconcileChildFibers 会为生成的 Fiber 节点带上 flags 属性,而 mount 不会。
flags 的作用是什么呢?
前面提到,render 阶段是在内存中进行的,当完成后会通知 Renderer 我们需要进行的 DOM 操作类型,而这个操作类型就保存在 flags 中,通过二进制表示。
如果 fiber.flags &= Placement !== 0,则表示该 fiber 节点被标记为 Placement,需要插入。
如果是 mount,那么只会在 rootFiber 赋值一次 flags 为 Placement。
reconcileChildFibers
该方法会根据 newChild 的类型选择不同的 diff 函数进行处理:
- newChild 类型为 object / number / string,表示同级只有一个节点
- newChild 类型为 Array,表示同级有多个节点
- 如果都不是,则表示要删除该节点
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,比如 REACT_ELEMENT_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// ...
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...
}
// 一些其他情况调用处理函数
// ...
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
从这里开始就是 DOM Diff 算法的核心逻辑了,请牢记前面提到的两个假设:
- 两个不同类型的元素会产生不同的树
- 对于同一层级的一组子元素,它们之间可以通过唯一的 key 进行区分
同级只有一个节点 —— reconcileSingleElement
以 Object 类型为例,同级只有一个节点时会执行 reconcileSingleElement。
该方法大致流程如下:
首先会判断当前 Fiber 节点是否存在对应的 DOM 节点:
-
如果不存在,则直接创建一个新的 Fiber节点;
-
如果存在,则进行以下判断:
- key相同,type不同:执行 deleteRemainingChildren,会删除 child
- key相同,type相同:删除其兄弟节点
- key不相同:执行 deleteChild ,删除 child
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement, // newChild
lanes: Lanes,
): Fiber {
let child = currentFirstChild;
const key = element.key;
// 是否存在对应的 DOM 节点
while (child !== null) {
// key 相同
if (child.key === key) {
// type 相同,表示当前节点需要保留
if (child.type === element.type) {
// 父级下应该只有这一个子节点,将该子节点的兄弟节点删除
deleteRemainingChildren(returnFiber, child.sibling);
// 复用 Fiber 节点
const existing = useFiber(child, element.props);
existing.return = returnFiber;
return existing;
} else {
// type 不同,需要删除
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// key 不同,直接删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新的节点
const created = createFiberFromElement(element);
return created;
}
其中 deleteRemainingChildren 和 deleteChild 逻辑如下:
function deleteRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
): null {
if (!shouldTrackSideEffects) {
// Noop.
return null;
}
let childToDelete = currentFirstChild;
while (childToDelete !== null) {
deleteChild(returnFiber, childToDelete);
// 删除兄弟节点
childToDelete = childToDelete.sibling;
}
return null;
}
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) {
// Noop.
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
// 标记为 Deletion
returnFiber.flags |= ChildDeletion;
} else {
deletions.push(childToDelete);
}
}
同级存在多个节点 —— reconcileChildrenArray
这种场景下,React 会进行两轮遍历
【第一轮遍历】
第一轮遍历的目标是处理需要更新的节点(即可复用的节点):
- 遍历
newChildren,将newChildren[i]与oldFiber(currentFirstChild)比较,判断DOM节点是否可复用。 - 如果可复用,继续比较
newChildren[i]与oldFiber.sibling,可以复用则继续遍历。 - 如果不可复用,立即跳出整个遍历,第一轮遍历结束。
- 如果 newChildren 遍历完(即
i === newChildren.length - 1)或者 oldFiber 遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。
(oldFiber 是单向链表,因此 react 官方不建议使用双指针进行遍历)
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
// ========== 第一轮遍历开始 =============
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
// fiber.index 是固定的,如果出现 fiber.index > newIndex
// 表示此时 newIndex 对比的 oldFiber 其实是 null
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 尝试复用节点
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// newFiber.alternate 为 null 表示 newFiber 是新创建的
if (oldFiber && newFiber.alternate === null) {
// 删除 oldFiber
deleteChild(returnFiber, oldFiber);
}
}
// 更新 lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (!previousNewFiber) {
// 这是第一个插入的新fiber
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// ========== 第一轮遍历结束 =============
// ======= 第二轮遍历 ========
// ...
}
【第二轮遍历】
第二轮遍历的目标是处理非更新的节点,根据第一轮遍历的不同结果有不同的处理。
【结果1】newChildren 没遍历结束, oldFiber 遍历结束:
表示已存在的 DOM 节点都已复用,剩下的 newChildren 节点为需要插入的节点,则只需要遍历剩下的 newChildren 节点,依次生成 workInProgress fiber 节点
// 结果1: newChildren 遍历结束
if (newIdx === newChildren.length) {
// 表示第一轮所有的节点都可复用,剩下的 oldFiber 节点删除即可
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
【结果2】newChildren 遍历结束, oldFiber 没遍历结束
表示剩下的节点需要删除,则只需要遍历剩下的 oldFiber, 执行 deleteRemainingChildren 操作
// 结果2: oldFiber 遍历结束
if (oldFiber === null) {
// oldFiber 均已被复用,剩下的 newChildren 遍历后插入
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
【结果3】newChildren 和 oldFiber 都没遍历结束
表示有节点位置发生了变更,需要定位并处理移动的节点
React 是用 key 来匹配前后两次更新的相同节点。
为了方便找到 key 对应的 oldFiber,首先会先生成一个 Map,其中 key 为 oldFiber 的 key,value 为 oldFiber
function mapRemainingChildren(returnFiber, currentFirstChild) {
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
接着需要判断节点是否需要移动,以及怎么移动:
- 找到节点是否移动的参照物:最后一个可复用节点在 oldFiber 中的位置,记为 lastPlacedIndex
- 用 oldIndex 表示遍历到的可复用节点在 oldFiber 中的位置
- oldIndex < lastPlacedIndex,则表示节点需要向右移动
- oldIndex >= lastPlacedIndex,则不需要移动节点
function placeChild(
newFiber: Fiber, // 更新的 fiber
lastPlacedIndex: number, // 最后一个可复用节点的 index
newIndex: number, // newFiber 在数组中的 index
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// Noop.
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
// 存在 current
// 类似插入排序:将要排序的部分分为已排序区和未排序区,每次遍历都将未排序区的一个元素与已排序区元素比较
// 正常来讲,lastPlacedIndex 对应的 newFiber 需要在本次 newFiber 的左边
// oldIndex < lastPlacedIndex:表示本次 newFiber 在 lastPlacedIndex 的左边,需要移动
// 否则不需要移动
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 标记未需要移动
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 不需要移动
return oldIndex;
}
} else {
// 不存在 current
// 标记为需要插入
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
结果 3 的完整代码如下:
// 生成 oldFiber Map
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 存在与之对应的 current,可以复用 current
// 则需要从 existingChildren 中移除对应的节点避免在后续操作中被删除
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) {
// 剩余的 oldFiber 表示未被复用,需要删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
举个例子
光看前面的文字和代码可能有点不太好理解,我们来看个具体的例子:
// before
<root>
<A key="A" />
<B key="B" />
<C key="C" />
</root>
// after
<root>
<A key="A" />
<C key="C" />
<B key="B" />
</root>
在这个例子中,我们把 B 和 C 的节点位置做了交换,那么这个 diff 的流程是什么样的呢?
before: ABC ===> after: ACB
// ==== 第一轮遍历 start ====
// i = 0
A(after)对比 A(before):key不变,可复用
此时 A(before) 在 oldFiber 中索引为,所以 lastPlacedIndex = 0;
// i = 1
C(after)对比 B(before):key 改变,不可复用,跳出第一轮遍历
// ==== 第一轮遍历 end ====
// ==== 第二轮遍历 start ====
此时 newChildren = CB,oldFiber = BC;
创建 existingChildren,然后遍历 newChildren
// i = 0
key C 在 oldFiber 中存在,且 index 为 2,则 oldIndex = 2
oldIndex > lastPlacedIndex (2 > 0): 则 lastPlacedIndex = 2;
因此 C 的位置不需要改变
// i = 1
key B 在 oldFiber 中存在,index 为 1,则 oldIndex = 1
此时 oldIndex < lastPlacedIndex (1 < 2)
因此 B 需要向右移动
// ==== 第二轮遍历 end ====
以上便是 React DOM diff 算法的核心算法详解。