虚拟DOM
最核心的部分是patch
,它可以将vnode
渲染成真实DOM
。
patch
也可以叫做patching算法
,通过它渲染真实DOM
时,并不是暴力覆盖原有的DOM
,而是比对新旧两个vnode
之间有哪些不同,然后根据对比结果找出现有DOM
中需要更新的节点进行修改,来实现更新视图的目的。
之所以要这么做,是因为DOM
操作的执行速度远不如JavaScript
运行速度快,因此把大量的DOM
操作搬运到JavaScript
中。使用patching
算法计算出真正需要更新的节点,最大限度的减少DOM
操作,从而显著提升性能。
1.patch
patch
的过程就是对比两个vnode
之间的差异,然后修改现有DOM节点
,也可以理解为渲染视图。既然是修改DOM
那应该会有三种操作:
-
创建新增的节点
-
删除已废弃的节点
-
修改需要更新的节点
patch
的过程其实就是创建节点,删除节点,修改节点。如果oldVnode
和vnode
不同时,以vnode
为标准来渲染视图。
1.1 新增节点
有两种情况需要新增节点:
oldVnode
不存在,而vnode
中存在的节点,通常发生在首次渲染oldVnode
和vnode
完全不是同一个节点时,以vnode
为标准来创建元素。
// 第一种情况
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
}
// 第二种情况
if (!sameVnode(oldVnode, vnode)) {
createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
}
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
)
)
}
第二种情况下要做的事情就是使用vnode
创建一个新的DOM节点
,去替换oldVnode
对应的真实DOM
。
1.2 删除节点
也存在两种情况需要删除节点:
- 节点在
vnode
中不存在,而在oldVnode
中存在的节点,说明这是一个被废弃的节点,所以需要删除。 oldVnode
和vnode
完全不是同一个节点时,将新创建的DOM
节点插入旧DOM
的旁边,然后再将旧DOM
删除。
// 第一种情况
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// 第二种情况
if (!sameVnode(oldVnode, vnode)) {
createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
}
1.3 更新节点
当新旧两个vnode
是相同节点时,需要对这两个节点进行更细致的对比,然后对oldVnode
所在的视图中对应的真实节点进行更新。
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
2.创建节点
创建节点的过程就是根据vnode
的类型来创建出相同类型的DOM
元素,然后将DOM
元素插入到视图中。
事实上,只有三种类型的节点会被插入到DOM中:元素节点、注释节点和文本节点
2.1 元素节点
要判断vnode
是否是元素节点,只需要判断它是否具有tag
属性即可。如果一个vnode
具有tag
属性,就认为它是元素节点。接着调用当前环境下的createElment
方法(在浏览器中是document.createElement
)来创建真实的元素节点。
// function createElm
if (isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode) // 设置css scoped
}
// node-ops.js
export function createElement (tagName: string, vnode: VNode): Element {
const elm = document.createElement(tagName)
}
当一个元素被创建后,接下来要做的就是将它插入到指定的父节点中。
将元素渲染到视图的过程非常简单。只需要调用当前环境下的appendChild
方法(在浏览器环境下是调用parentNode.appendChild
),将一个元素插入到指定的父节点中,如果父节点已经渲染到视图,那么插入的子节点也会立即渲染。
// function createElm
if (isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode) // 设置css scoped
insert(parentElm, vnode.elm, refElm)
}
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
// node-ops.js
export function appendChild (node: Node, child: Node) {
node.appendChild(child)
}
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
parentNode.insertBefore(newNode, referenceNode)
}
创建元素节点时,通常会有子节点(children)。所以当一个元素节点被创建后,我们需要将它的子节点也创建出来并插入到这个刚创建的节点下面。
// function createElm
if (isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode) // 设置css scoped
// 新增
createChildren(vnode, children, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
}
function createChildren (vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(children) // 检查key是否重复
}
for (let i = 0; i < children.length; ++i) {
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
}
}
创建子节点是一个递归的过程。vnode
中的children
属性保存了当前节点的所有子虚拟节点。只需要将vnode
中的children
属性循环一遍,将每个子虚拟节点都执行一遍创建元素的逻辑,就可以实现创建子节点。
创建子节点时,子节点的父节点就是当前刚创建出来的这个节点,所以子节点刚创建出来会被插入到当前节点下面。
当所有子节点都被创建并插入到它的父节点后,如果当前节点(就是最上面的那个节点)的父节点已经被渲染到视图,那么把当前节点会立即被渲染到视图。
2.2 注释节点
如果vnode
不存在tag
属性,那么它可能会是注释节点或文本节点。
如果是注释节点那么vnode
一定会有一个isComment
属性且为true
。调用当前环境下的createComment
方法(浏览器环境下是document.createComment
)来创建真实的注释节点并将其插入到指定的父节点中。
// function createElm
if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
// node-ops.js
export function createComment (text: string): Comment {
return document.createComment(text)
}
2.3 文本节点
如果vnode
没有tag
属性和isComment
属性为true
,那么就是文本节点。
创建文本节点,只要调用当前环境下的createTextNode
方法(浏览器中为document.createTextNode
)创建真实的文本节点并将其插入到指定的父节点中。
// function createElm
if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
// node-ops.js
export function createTextNode (text: string): Text {
return document.createTextNode(text)
}
3.删除节点
删除节点是将元素从视图中删除,Vue中的源码并不多。
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
}
上面代码的功能是删除vnodes
数组从startIdx
到endIdx
的内容。
removeNode
用于删除视图中的单个节点,而removeVnodes
用于删除一组指定的节点。
// patch.js
function removeNode (el) {
const parent = nodeOps.parentNode(el)
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
// node-ops.js
function removeChild(parent, child) {
parent.removeChild(child)
}
4.更新节点
两个节点是同一节点时会进行更细致的对比。找出两个节点不一样的地方,然后进行更新。
4.1 静态节点
首先判断两个节点是否是静态节点,如果是,就不需要进行更新操作,可以直接跳过更新节点的过程。
静态节点指的是那些一旦渲染到界面后,无论以后状态如何变化,都不会发生任何变化的节点。
// function patchVnode
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
4.2 虚拟节点有文本属性
根据vnode
是否有text
属性,更新节点可以分为两种情况。
如果新生成的vnode
有text
属性,那么不论之前旧节点的子节点是什么,直接调用setTextContent
方法(在浏览器环境下是node.textContent
)方法来将视图中的DOM
节点的内容改为vnode
的text
属性所保存的文字。
如果oldVnode
也是文本且和vnode
的文本相同,那么就不需要执行setTextContent
方法来重复设置相同的文本。
// function patchVnode
if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
// node-ops.js
export function setTextContent (node: Node, text: string) {
node.textContent = text
}
4.3 虚拟节点无文本属性
如果虚拟节点(vnode)没有text
属性,那么它就是一个元素节点。元素节点通常会有一个子节点,那就是children
属性。但是也可能没有子节点,所以存在两种情况。
// function patchVnode
if (isUndef(vnode.text)) {
...
}
1. vnode有children的情况
当创建的vnode
有children
属性时,其实还会有两种情况,那就要看oldVnode
是否有children
属性。
oldVnode有children的情况
这种情况下需要对新旧两个虚拟节点的children
进行一个更详细的对比并更新。更新children
可能会移动某个子节点的位置,也有可能会删除或新增某个子节点。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
}
oldVnode没有children的情况
oldVnode
没有children
属性,说明oldVnode
要么是一个空标签,要么是有文本的节点。如果是文本节点,那么先把文本清空让它变成空标签,然后将vnode
中的children
挨个创建成真实DOM
元素节点并将其插入到视图中的DOM
节点下面。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) { // isUndef(oldCh) === true
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
}
2. vnode没有children的情况
当创建的vnode
既没有text
属性也没有children
属性时,说明这个新创建的节点是一个空节点。这时如果oldVnode
有子节点就删除子节点,有文本就删除文本。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
}
5.更新子节点
更新节点的时候,如果新节点的子节点和旧节点的子节点都存在且不相同时,会进行子节点的更新操作。
更新操作可以分为4种:更新节点、删除节点、新增节点、移动节点。
首先循环newChildren
(新子节点列表),每循环到一个子节点,就去oldChildren
(旧子节点列表)中找到和当前节点相同的那个子节点。如果在oldChildren
中找不到,则创建节点并插入视图;如果找到了,则做更新操作;如果找到了,但是位置不同,则需要移动节点。
5.1 更新策略
创建子节点
新旧两个子节点的列表是通过循环进行对比的,如果没有在oldChildren
列表里找到本次循环所指向的新节点,那么说明这是一个新增的节点。创建这个节点,并将其插入到oldChildren
中所有未处理节点的前面。
更新子节点
如果newChildren
和oldChildren
中同时存在一个节点并且它们的位置都相同。那么就进行更新操作。
移动子节点
移动子节点通常发生在newChildren
中某个节点和oldChildren
中某个节点是同一个节点,但是他们的位置不同。所以要在真实DOM中将这个节点的位置以新虚拟节点的位置为基准进行移动。
通过Node.insertBefore()
方法,可以成功的将一个已有的节点移动到一个指定的位置。
这个位置就是所有未处理节点的最前面。
删除子节点
本质上是删除那些,newChildren
上不存在而oldChildren
中存在的节点。
当newChildren
循环了一遍之后,如果oldChildren
中还有未被处理的节点,那么这些节点将被删除。
5.2 优化策略
通常情况下,并不是所有的子节点的位置都会发生移动。针对这些位置不会变的或者说位置可以预测的节点,我们不需要循环来查找,因为有一个更快捷的查找方式。
假设我们只是修改了列表中某个数据的内容,而没有新增数据或者删除数据等,这种情况下newChildren
和oldChildren
中所有节点的位置都是相同的。只要尝试使用相同位置的两个节点来比对是否同一节点:如果恰巧是同一节点,直接就可以进入更新节点操作。如果尝试失败了,再用循环方式来查找节点。这样做可以很大程度的提升执行速度。
快捷查找一共有4种方式:
-
新前和旧前
-
新后和旧后
-
新前和旧后
-
新后和旧前
新前:newChildren
中所有未处理节点的第一个节点
新后:newChildren
中所有未处理节点的最后一个节点
旧前:oldChildren
中所有未处理节点的第一个节点
旧后:oldChildren
中所有未处理节点的第一个节点
1. 新前和旧前
"新前"节点和"旧前"节点进行对比,如果是相同节点,则使用更新操作进行更新。
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
2. 新后与旧后
"新后"节点和"旧后"节点进行对比,如果是相同节点,则使用更新操作进行更新。
if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
3. 新后与旧前
"新后"节点和"旧前"节点进行对比,如果是相同节点,由于位置不同,需要将oldChildren
中对应的"新后"的那个节点,移动到所有未处理节点的后面。
if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
4. 新前与旧后
"新前"节点和"旧后"节点进行对比,如果是相同节点,由于位置不同,需要将oldChildren
中对应的"信前"的那个节点,移动到所有未处理节点的前面。
if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
当这4种方式都没有找到相同的节点的时候,会再通过循环的方式去oldChildren
中再找一圈。
5.3 哪些节点是未处理的
由于优化策略,循环是两边向中间循环的。
需要4个变量: newStartIdx、newEndIdx、oldStartIdx、oldEndIdx。分别表示newChildren
的开始和结束位置,oldChildren
的开始和结束位置。
在循环体内,每处理一个节点,就将新旧两个节点的下标进行移动。开始位置被处理后,就向后移动一个位置。结束位置被处理后,就向前移动一个位置。
也就是说newStartIdx
和oldStartIdx
只能向后移动,而newEndIdx
和oldEndIdx
只能向前移动。
当开始位置大于结束位置时,就会退出循环。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
当newChildren
和oldChildren
中有一个循环结束,就会退出。那么,当新子节点和旧子节点数量不一致时,会导致循环结束后仍有未处理的节点。如果是oldChildren
先循环完,那么newChildren
有很多未处理的节点,那么这些节点都是要新增的节点。如果是newChildren
先循环完,这时如果oldChildren
中还有未处理的节点,那么这些就是需要删除的节点。
要找出newChildren
和oldChildren
未循环完的节点很简单,它们就是在startIdx
和endIdx
之间的节点。
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
5.4 key值的作用
在Vue.js
中,渲染列表时可以为节点设置一个属性key
,这个属性作为一个节点的唯一ID
。
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 创建一个对象,保存了key值对应的下标
function createKeyToOldIdx(children, beginIdx, endIdx) {
var i, key;
var map = {};
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key;
if (isDef(key)) {
map[key] = i;
}
}
return map;
}
以上代码表示,如果oldKeyToId
不存在:也就是key
对应下标的map
不存在。那么就用createKeyToOldIdx
创建了一个key
对应下标的对象。
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 新增
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
function findIdxInOld(node, oldCh, start, end) {
for (var i = start; i < end; i++) {
var c = oldCh[i];
if (isDef(c) && sameVnode(node, c)) {
return i;
}
}
}
如果newStartVnode
里的key
值存在,那么就去map
里去找对应的下标;如果不存在那么就去oldChildren
里循环查找,是否有节点和这个newStartVnode
相同,有就返回下标。最后把下标赋值给idxInOld
。
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 新增
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
以上代码:如果idxInOld
不存在,说明在map
里没找到且在oldChildren
里也没找到,那么这个newStartVnode
就是一个新节点,将其创建。
再来看如果idxInOld
存在,说明这是一个oldChildren
已经存在的节点。
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 新增
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
将oldChildren
中和newStartVnode
对应key
下标的节点赋值给vnodeToMove
。
如果它们拥有相同的key
,但是不是相同节点,则创建新节点。
如果它们是相同节点,那么就进行更详细的节点对比。之后将oldChildren
中对应下标的节点置为undefined
,用来标记节点已被处理并且移动到其他位置,防止后续重复处理。