前言
本人在一家中小公司工作了几年,一开始只负责前端,到后来用nodejs写服务以及负责一些团队基础设施等运维工作。由于做的事情太杂,最近想重新系统地整理一下前端的知识,因此写了这个专栏。我会尽量写一些业务相关的小技巧和前端知识中的重点内容,核心思想。
什么是虚拟dom?
虚拟dom(virtual DOM)是一个原生js对象,用来描述dom元素。
我们知道平常dom元素下会有许多属性,如(style,attributes,offsetHeight等)。浏览器用这些属性来渲染页面。那么如果我们自己建立属性来描述dom元素,是否可以用js原生对象的方式来构建出一棵存在缺又不必渲染在浏览器上的dom树?
下面是一个例子:
{
"tag":"div",
"className":"xxx",
"on":[
{
"click":function(){}
}
],
"children":[...]
}
复制代码
当然虚拟dom的组成是一种思想,他的结构不是固定的。不同团队,不同工具会根据自己的业务,制定的虚拟dom对象结构。
为什么要用虚拟dom?
可以肯定的是,无论用什么实现方式。使用虚拟dom的最终目的都只有一个。就是减少dom元素的操作,降低渲染成本。我们都知道操作dom的渲染成本是很高的,但出于一些交互,又不得不这么做。jquery在dom的操作上已经把写法优化得很好了。往后的前端开发者在思考的是,如何以最低的成本来操作dom?
虚拟dom就是这个解决方案,我们在内存中维护一棵dom树,这棵树的信息量足够让浏览器把页面完整渲染。每当我们需要修改页面时,先在虚拟dom上修改信息,再去对比修改前后的2棵dom树,找到不同的地方,在操作dom时,只修改那个地方即可。
diff算法?
从上述内容可以看出,要实现这套流程。最核心的地方是如何实现虚拟dom找出修改的部分,即2棵虚拟dom树的对比。
diff 算法是vue选择的解决方案。diff 算法本身是一种通过同层的树节点进行比较的高效算法,需要注意的是它不是vue特有或者原创的。
diff 算法有两个特点:
- 比较只会在同层级进行, 不会跨层级比较
- 在diff比较的过程中,循环从两边向中间比较
path函数
path函数主要是同级判断新旧2个节点,主要操作有以下:
- 如果有新节点,没有旧节点。直接创建新节点内容。
- 如果没有新节点,有旧节点,删除旧节点内容。
- 如果2个节点都有,判断是否一致(调用sameVnode方法)。如果不一致就把旧的删调,创建新的。如果一致就用patchVnode方法处理。
function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) { // 没有新节点,销毁旧节点,触发destory钩子
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {// 没有旧节点,直接用新节点生成dom元素
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else { // 新旧节点都存在
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 判断旧节点和新节点自身一样,一致执行patchVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 否则直接销毁及旧节点,根据新节点生成dom元素
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
}
}
oldVnode = emptyNodeAt(oldVnode)
}
return vnode.elm
}
}
}
复制代码
sameVnode函数
sameVnode方法的作用是判断2个节点是否一致,从源码可以看出他主要是根据节点的key值和tag等具体组件信息判断的。
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)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
复制代码
patchVnode函数
patchVnode是在sameVnode方法判断2个节点一致时,执行的处理2个节点的方法。
- 如果2个节点完全一致,不作处理
- 如果2个节点不完全一致,用updateChildren处理。
- 如果老节点有子节点,新节点没有,则删除新节点。
- 如果新节点有内容,老节点没有,则把新节点内容增加到老节点下。
- 新vnode和老vnode是文本节点或注释节点,但内容不一样,直接替换。
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// 如果新旧节点一致,什么都不做
if (oldVnode === vnode) {
return
}
// 让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化
const elm = vnode.elm = oldVnode.elm
// 异步占位符
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// 如果新旧都是静态节点,并且具有相同的key
// 当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上
// 也不用再有其他操作
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 如果vnode不是文本节点或者注释节点
if (isUndef(vnode.text)) {
// 并且都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 并且子节点不完全一致,则调用updateChildren
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
// 如果只有新的vnode有子节点
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
// elm已经引用了老的dom节点,在老的dom节点上添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
// 如果新vnode没有子节点,而vnode有子节点,直接删除老的oldCh
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
// 如果老节点是文本节点
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
// 如果新vnode和老vnode是文本节点或注释节点
// 但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
复制代码
updateChildren函数
updateChildren处理的是某节点下的children,他们是一组同级的扁平数组。
用双指针的方法,定义新头newStartIdx,新尾newEndIdx,旧头oldStartIdx,旧尾oldEndIdx,2组4个的索引。循环遍历处理以下几种情况:
-
新头与旧头一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newStartIdx和oldStartIdx往右移动。
-
新头与旧尾一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newStartIdx往右移动和newEndIdx往左移动。
-
新尾与旧头一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newEndIdx往左移动和oldStartIdx往右移动。
-
新尾与旧尾一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newEndIdx往左移动和oldEndIdx往左移动。
-
如果以上情况都不符合,则暴力遍历处理。在旧节点组里找有没有当前新节点组里newStartIdx指向的内容。如果没有就直接新建,如果有就patchVnode处理。
-
最后当新旧节点组2组其中之一遍历结束后,循环停止。这时如果旧节点组还有内容,就把旧的删除,如果新节点组还有内容就把新的添加。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 //定义旧头
let newStartIdx = 0 //定义新头
let oldEndIdx = oldCh.length - 1 //定义旧尾
let oldStartVnode = oldCh[0] //定义新尾
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
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)
}
// 当新旧节点组2组其中之一遍历结束后,循环停止
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果oldStartVnode不存在,对应的oldStartIdx往右移动
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// oldEndVnode,对应的oldEndIdx往右移动
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// oldStartVnode与newStartVnode相同,用patchVnode处理,并移动指针。
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// oldEndVnode与newEndVnode相同,用patchVnode处理,并移动指针。
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// oldStartVnode与newEndVnode相同,用patchVnode处理,并移动指针。
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// oldEndVnode与newStartVnode相同,用patchVnode处理,并移动指针。
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 如果上面的情况都不符合,则暴力遍历
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 {
// 如果新的节点在旧的组里有就判断是不是一致,一致就继续patchVnode。如果不是就把新的新建
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
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]
}
}
// 到这里while循环已经结束了,这时如果旧节点组还有内容,就把旧的删除,如果新节点组还有内容就把新的添加。
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)
}
}
复制代码
图例详解
假设下面的要对比的2组节点组,分别定义新头newStartIdx,新尾newEndIdx,旧头oldStartIdx,旧尾oldEndIdx。4个索引
第一次遍历发现newStartIdx与oldStartIdx相同,用 patchVnode处理。并让2个索引向前移动。
第二次遍历发现oldEndIdx与oldStartIdx相同,用 patchVnode处理。并让oldEndIdx索引向后移动看,让oldStartId向前。
第三次遍历发现oldStartIdx与newEndIdx相同,用 patchVnode处理。并让oldStartIdx索引向前移动看,让newEndIdx向后。
第四次遍历发现4种情况都不符合,开始暴力遍历,在旧节点组中仍找不到节点,直接新建节点。并让newStartIdx向前。
第五次遍历情况同上。
五次遍历结束后,新节点组已经空了,旧节点组还有内容。直接删除。最后diff结束。
总结
diff算法是vue的核心内容之一,在借鉴时,要留意:
- dom的渲染应该是最少幅度的,先用虚拟dom,挑选出需要渲染dom的部分再渲染。
- 在diff时一切新的内容应该以新的node为主。
- 在算法中应该注意特殊情况的处理。把各种情况考虑清楚。