前言
本文章不讲解 vDom 实现,mount 挂载,以及 render 函数。只讨论三种 diff 算法。VNode 类型不考虑 component、functional-component、Fragment、Teleport。只考虑 Element 和 Text。此文章全部代码可参考该项目。
下面的 diff 算法中会出现几个方法,在这里进行罗列,并说明其功能
-
mount(vnode, parent, [refNode])
: 通过vnode
生成真实的DOM
节点。parent
为其父级的真实 DOM 节点,refNode
为真实的DOM
节点,其父级节点为parent
。如果refNode
不为空,vnode
生成的DOM
节点就会插入到refNode
之前;如果refNode
为空,那么vnode
生成的DOM
节点就作为最后一个子节点插入到parent
中 -
patch(prevNode, nextNode, parent)
: 可以简单的理解为给当前DOM
节点进行更新,并且调用diff
算法对比自身的子节点;
一、React-Diff
React 的思路是递增法。通过对比新的列表中的节点,在原本的列表中的位置是否是递增,来判断当前节点是否需要移动。
1. 实现原理
来看这样一个例子。
nextList
为新的列表,prevList
为旧列表。这个例子我们一眼能看出来,新列表是不需要进行移动的。下面我用react
的递增思想,解释一下为什么新列表中的节点不需要移动。
我们首先遍历nextList
,并且找到每一个节点,在prevList
中的位置。
function foo(prevList, nextList) {
for (let i = 0; i < nextList.length; i++) {
let nextItem = nextList[i];
for (let j = 0; j < prevList.length; j++) {
let prevItem = prevList[j]
if (nextItem === prevItem) {
}
}
}
}
找到位置以后,与上一个节点的位置进行对比,如果当前的位置大于上一个位置,说明当前节点不需要移动。因此我们要定义一个lastIndex
来记录上一个节点的位置。
function foo(prevList, nextList) {
let lastIndex = 0
for (let i = 0; i < nextList.length; i++) {
let nextItem = nextList[i];
for (let j = 0; j < prevList.length; j++) {
let prevItem = prevList[j]
if (nextItem === prevItem) {
if (j < lastIndex) {
// 需要移动节点
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
}
}
}
}
在上面的例子中,nextList
每个节点在prevList
的位置为0 1 2 3
。每一项都要比前一项要大,所以不需要移动,这就是react
的diff
算法的原理。
2. 找到需要移动的节点
在上一小节中,我们是通过对比值是否相等,查找的对应位置。但是在 vdom 中,每一个节点都是一个 vNode,我们应该如何进行判断呢?
答案就是key
,我们通过对每个节点的key
进行赋值,并且让处于同一children
数组下的vnode
的key
都不相同,以此来确定每个节点的唯一性,并进行新旧列表的对比。
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i];
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 需要移动节点
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
}
}
}
}
3. 移动节点
首先我们先明确一点,移动节点所指的节点是DOM
节点。vnode.el
指向该节点对应的真实DOM
节点。patch
方法会将更新过后的DOM
节点,赋值给新的vnode
的el
属性。
为了画图方便,我们用
key
的值来表示vnode
节点。为了行文方便,我们把key
值为a
的vnode
简写为vnode-a
,vnode-a
对应的真实 DOM 节点为DOM-A
我们来将上图的例子代入reactDiff
中执行。我们遍历新列表,并查找vnode
在旧列表中的位置。当遍历到vnode-d
时,之前遍历在旧列表的位置为0 < 2 < 3
,说明A C D
这三个节点都是不需要移动的。此时lastIndex = 3
, 并进入下一次循环,发现vnode-b
在旧列表的index
为1
,1 < 3
,说明DOM-B
要移动。
通过观察我们能发现,只需要把DOM-B
移动到DOM-D
之后就可以了。也就是找到需要移动的 VNode,我们称该 VNode 为 α,将 α 对应的真实的 DOM 节点移动到,α 在新列表
中的前一个 VNode 对应的真实 DOM 的后面。
在上述的例子中,就是将vnode-b
对应的真实 DOM 节点DOM-B
, 移动到vnode-b
在新列表中的前一个VNode
——vnode-d
对应的真实 DOM 节点DOM-D
的后面。
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i];
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling;
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
}
}
}
}
为什么是这样移动的呢?首先我们列表是从头到尾
遍历的。这就意味着对于当前VNode
节点来说,该节点之前的所有节点都是排好序的,如果该节点需要移动,那么只需要将 DOM 节点移动到前一个vnode
节点之后就可以,因为在新列表中vnode
的顺序就是这样的。
4. 添加节点
上一小节我们只讲了如何移动节点,但是忽略了另外一种情况,就是在新列表中有全新的VNode
节点,在旧列表中找不到。遇到这种情况,我们需要根据新的VNode
节点生成DOM
节点,并插入DOM
树中。
至此,我们面临两个问题:1.如何发现全新的节点、2. 生成的DOM
节点插入到哪里
我们先来解决第一个问题,找节点还是比较简单的,我们定义一个find
变量值为false
。如果在旧列表找到了key
相同的vnode
,就将find
的值改为true
。当遍历结束后判断find
值,如果为false
,说明当前节点为新节点。
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i],
find = false
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
find = true
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
break
}
}
if (!find) {
// 插入新节点
}
}
}
找到新节点后,下一步就是插入到哪里了,这里的逻辑其实是和移动节点的逻辑是一样的。我们观察上图可以发现,新的vnode-c
是紧跟在vnode-b
后面的,并且vnode-b
的 DOM 节点——DOM-B
是已经排好序的,所以我们只需要将vnode-c
生成的 DOM 节点插入到DOM-B
之后就可以了。
但是这里有一种特殊情况需要注意,就是新的节点位于新列表的第一个,这时候我们需要找到旧列表第一个节点,将新节点插入到原来第一个节点之前就可以了。
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i],
find = false
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
find = true
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
break
}
}
if (!find) {
// 插入新节点
let refNode = i <= 0 ? prevChildren[0].el : nextChildren[i - 1].el.nextSibling
mount(nextChild, parent, refNode)
}
}
}
5. 移除节点
有增就有减,当旧的节点不在新列表中时,我们就将其对应的 DOM 节点移除。
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i],
find = false
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
find = true
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
break
}
}
if (!find) {
// 插入新节点
let refNode = i <= 0 ? prevChildren[0].el : nextChildren[i - 1].el.nextSibling
mount(nextChild, parent, refNode)
}
}
for (let i = 0; i < prevChildren.length; i++) {
let prevChild = prevChildren[i],
key = prevChild.key,
has = nextChildren.find((item) => item.key === key)
if (!has) parent.removeChild(prevChild.el)
}
}
6.优化与不足
以上就是 React 的 diff 算法的思路。
目前的reactDiff
的时间复杂度为O(m*n)
,我们可以用空间换时间,把key
与index
的关系维护成一个Map
,从而将时间复杂度降低为O(n)
,具体的代码可以查看此项目。
我们接下来看这样一个例子
根据reactDiff
的思路,我们需要先将DOM-A
移动到DOM-C
之后,然后再将DOM-B
移动到DOM-A
之后,完成Diff
。但是我们通过观察可以发现,只要将DOM-C
移动到DOM-A
之前就可以完成Diff
。
这里是有可优化的空间的,接下来我们介绍vue2.x
中的diff
算法——双端比较
,该算法解决了上述的问题
二、Vue2.X Diff —— 双端比较
所谓双端比较
就是新列表和旧列表两个列表的头与尾互相对比,,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。
1. 实现原理
我们先用四个指针指向两个列表的头尾
function vue2Diff(prevChildren, nextChildren, parent) {
let oldStartIndex = 0,
oldEndIndex = prevChildren.length - 1
;(newStartIndex = 0), (newEndIndex = nextChildren.length - 1)
let oldStartNode = prevChildren[oldStartIndex],
oldEndNode = prevChildren[oldEndIndex],
newStartNode = nextChildren[nextStartIndex],
newEndNode = nextChildren[nextEndIndex]
}
我们根据四个指针找到四个节点,然后进行对比,那么如何对比呢?我们按照以下四个步骤进行对比
- 使用旧列表的头一个节点
oldStartNode
与新列表的头一个节点newStartNode
对比 - 使用旧列表的最后一个节点
oldEndNode
与新列表的最后一个节点newEndNode
对比 - 使用旧列表的头一个节点
oldStartNode
与新列表的最后一个节点newEndNode
对比 - 使用旧列表的最后一个节点
oldEndNode
与新列表的头一个节点newStartNode
对比
使用以上四步进行对比,去寻找key
相同的可复用的节点,当在某一步中找到了则停止后面的寻找。具体对比顺序如下图
对比顺序代码结构如下:
function vue2Diff(prevChildren, nextChildren, parent) {
let oldStartIndex = 0,
oldEndIndex = prevChildren.length - 1
;(newStartIndex = 0), (newEndIndex = nextChildren.length - 1)
let oldStartNode = prevChildren[oldStartIndex],
oldEndNode = prevChildren[oldEndIndex],
newStartNode = nextChildren[newStartIndex],
newEndNode = nextChildren[newEndIndex]
if (oldStartNode.key === newStartNode.key) {
} else if (oldEndNode.key === newEndNode.key) {
} else if (oldStartNode.key &#