vue源码之虚拟DOM
虚拟DOM简介
首先,什么是虚拟DOM?虚拟DOM就是一个JS对象来描述一个DOM节点,像如下示例:
<div class="a" id="b">我是内容</div>
{
tag:'div', // 元素标签
attrs:{ // 属性
class:'a',
id:'b'
},
text:'我是内容', // 文本内容
children:[] // 子元素
}
那么,为什么要有虚拟DOM呢?我们知道,vue是靠数据驱动视图的,数据发送变化视图就要发生变化,在更新视图时就要操作DOM,而真实的DOM元素的设计非常复杂,所以操作真实DOM元素非常消耗性能。所以我们可以用JS的计算性能来换取操作DOM所消耗的性能。
也就是说,在更新视图之前,先去对比数据变化前后的不同,只更新不同的地方,其他地方不需要关系,这样就能尽可能少的操作DOM了。
Vue中的虚拟DOM
1.Vnode类
虚拟DOM就是用JS来描述一个真实DOM节点,而在vue中存在一个Vnode类,就是用来实例化不同的DOM节点,源码:
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag /*当前节点的标签名*/
this.data = data /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.children = children /*当前节点的子节点,是一个数组*/
this.text = text /*当前节点的文本*/
this.elm = elm /*当前虚拟节点对应的真实dom节点*/
this.ns = undefined /*当前节点的名字空间*/
this.context = context /*当前组件节点对应的Vue实例*/
this.fnContext = undefined /*函数式组件对应的Vue实例*/
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key /*节点的key属性,被当作节点的标志,用以优化*/
this.componentOptions = componentOptions /*组件的option选项*/
this.componentInstance = undefined /*当前节点对应的组件的实例*/
this.parent = undefined /*当前节点的父节点*/
this.raw = false /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.isStatic = false /*静态节点标志*/
this.isRootInsert = true /*是否作为跟节点插入*/
this.isComment = false /*是否为注释节点*/
this.isCloned = false /*是否为克隆节点*/
this.isOnce = false /*是否有v-once指令*/
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
get child (): Component | void {
return this.componentInstance
}
}
Vnode的作用:
在视图渲染之前,把写好的template模板先编译成Vnode并缓下来,等数据发生变化需要重写渲染时,把数据发生变化后生成的Vnode和前一次缓存下来的Vnode进行对比,然后根据有差异的Vnode创建出真实DOM节点再插入视图中,完成一次视图更新。
Diff算法
Diff算法就是对比新旧两份Vnode并找出差异。
在vue中,把DOM-diff的过程叫做patch过程。在patch过程中,要以生成的新Vnode为基准,对比旧的Vnode,大概有这几种情况:
- 创建节点:新节点有,旧节点没有
- 删除节点:新节点没有,节点有
- 更新节点:新旧节点都有
创建节点
vue有三种类型的节点能够被创建并插入到DOM中,分别是:元素节点、文本节点、注释节点。所以我们只需要在创建节点的时候判断在新的Vnode中而旧的oldVnode中没有的这个节点是什么类型的节点,从而创建并插入到DOM中,源码如下:
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else {
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
流程图:
删除节点
如果某些节点在新的Vnode中没有而在旧的oldVnode中有,那么就需要把这些节点从oldVnode中删除,源码如下:
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}
更新节点
在更新节点之前先明白一个概念,什么是静态节点?例子:
<p>我是不会变化的文字</p>
应该明白了把,就是在这个节点中没有任何变量,只有纯文字,也就是说它以后永远都不会发生变化了,所以数据发生任何变化都与他无关,这种节点称之为静态节点。
那么我们对更新情况进行判断并分别处理;
- 如果Vnode和oldVnode都是静态节点
静态节点无论数据发生任何变化都与他无关,所以都是静态节点的话直接跳过,无需处理 - 如果Vnode是文本节点
首先查看oldVnode是否也是文本节点,如果是,就比较两个文本是否不同,如果不同就把oldVnode中的文本改成跟Vnode的文本一样;如果oldVnode不是文本节点,那么不论它是什么,直接调用setTextNode方法把它改成文本节点,并且文本内容跟Vnode相同。 - 如果Vnode是元素节点
如果是Vnode是元素节点,又细分以下两种情况:- 该节点包含子节点
如果新的节点内包含了子节点,那么此时要看旧的节点是否包含子节点,如果旧节点里也包含了子节点,那就需要递归对比更新子节点;如果旧的节点里不包含子节点,那么这个旧节点有可能是空节点或者是文本节点,如果是空节点就把新的节点里的子节点创建一份然后插入到oldVnode中,如果是文本节点就清空文本,然后把新的节点里的子节点创建一份插入到oldVnode中。 - 该节点不包含子节点
如果该节点不包含子节点,同时又不是文本节点,那就说明该节点是个空节点,那么直接把之前的旧节点全部清空就可以了。
理清楚了逻辑后,那么我们来看看源码中具体是怎么实现的:
- 该节点包含子节点
// 更新节点
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// vnode与oldVnode是否完全一样?若是,退出程序
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
// vnode与oldVnode是否都是静态节点?若是,退出程序
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// vnode有text属性?若没有:
if (isUndef(vnode.text)) {
// vnode的子节点与oldVnode的子节点是否都存在?
if (isDef(oldCh) && isDef(ch)) {
// 若都存在,判断子节点是否相同,不同则更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 若只有vnode的子节点存在
else if (isDef(ch)) {
/**
* 判断oldVnode是否有文本?
* 若没有,则把vnode的子节点添加到真实DOM中
* 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 若只有oldnode的子节点存在
else if (isDef(oldCh)) {
// 清空DOM中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 若vnode和oldnode都没有子节点,但是oldnode中有文本
else if (isDef(oldVnode.text)) {
// 清空oldnode文本
nodeOps.setTextContent(elm, '')
}
// 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
}
// 若有,vnode的text属性与oldVnode的text属性是否相同?
else if (oldVnode.text !== vnode.text) {
// 若不相同:则用vnode的text替换真实DOM的文本
nodeOps.setTextContent(elm, vnode.text)
}
}
官方流程图:
更新子节点
当新的Vnode和旧的Vnode都是元素节点并且都包含子节点时,我们把新的Vnode上的子节点数组记为newChildren,旧的oldVnode上的子节点数组记为oldChildren。我们把newchildren和oldchildren中的元素都要进行一一对比,这时又有两种方案,一种是拿一个新的然后旧的里面一个一个的找,伪代码如下:
for (let i = 0; i < newChildren.length; i++) {
const newChild = newChildren[i];
for (let j = 0; j < oldChildren.length; j++) {
const oldChild = oldChildren[j];
if (newChild === oldChild) {
// ...
}
}
}
这样的话当有很多子节点的时候,就会很消耗性能。所以选择第二种。
首先,我们有如下定义:
newChildren数组里的所有未处理子节点的第一个子节点称为:新前;
newChildren数组里的所有未处理子节点的最后一个子节点称为:新后;
oldChildren数组里的所有未处理子节点的第一个子节点称为:旧前;
oldChildren数组里的所有未处理子节点的最后一个子节点称为:旧后;
然后我们定义一个更新规则:
- 旧前和新前进行对比,如果相同,进入更新节点操作后无需进行节点移动操作,只需要旧前和新前后移继续对比。如果不同,进行下面的规则;
- 旧后和新后进行对比,如果相同,进入更新节点操作后无需进行节点移动操作,只需要旧后和新后前移然后又第一规则进行对比。如果不同,进行下面的规则;
- 旧前和新后进行对比,如果相同,进入更新节点操作后再将oldchildren数组里的该节点移动到oldchildren所有未更新节点之后,旧前后移,新后前移。如果不同,进行下面规则;
- 旧后和新前进行对比,如果相同,进入更新节点操作后再将oldchildren数组里的该节点移动到所有未处理节点之前,旧后前移,新前后移。如果不同,进行下面规则;
- 上面四种规则都不能找到,那么就使用最费劲的循环,如果在oldchildren所有未处理的数组中找到和新前相同的,就将该节点移动到所有未处理节点之前,然后新前后移;如果没有找到,就创建一个节点然后移动到数组中所有未处理节点之前,新前后移。
思路理清,分析源码:
// 循环更新子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // oldChildren开始索引
let oldEndIdx = oldCh.length - 1 // oldChildren结束索引
let oldStartVnode = oldCh[0] // oldChildren中所有未处理节点中的第一个
let oldEndVnode = oldCh[oldEndIdx] // oldChildren中所有未处理节点中的最后一个
let newStartIdx = 0 // newChildren开始索引
let newEndIdx = newCh.length - 1 // newChildren结束索引
let newStartVnode = newCh[0] // newChildren中所有未处理节点中的第一个
let newEndVnode = newCh[newEndIdx] // newChildren中所有未处理节点中的最后一个
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // 如果oldStartVnode不存在,则直接跳过,比对下一个
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 如果新前与旧前节点相同,就把两个节点进行patch更新
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 如果新后与旧后节点相同,就把两个节点进行patch更新
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 如果不属于以上四种情况,就进行常规的循环比对patch
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果在oldChildren里找不到当前循环的newChildren里的子节点
if (isUndef(idxInOld)) { // New element
// 新增节点并插入到合适位置
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 如果在oldChildren里找到了当前循环的newChildren里的子节点
vnodeToMove = oldCh[idxInOld]
// 如果两个节点相同
if (sameVnode(vnodeToMove, newStartVnode)) {
// 调用patchVnode更新节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
// canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
canMove && 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)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
/**
* 如果oldChildren比newChildren先循环完毕,
* 那么newChildren里面剩余的节点都是需要新增的节点,
* 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/**
* 如果newChildren比oldChildren先循环完毕,
* 那么oldChildren里面剩余的节点都是需要删除的节点,
* 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
*/
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
行百里者半九十