一、虚拟DOM和Diff 算法的核心原理
diff
算法可以进行精细化比对,实现最小量更新。虚拟DOM
:用JavaScript
对象 描述DOM
的层次结构。DOM
中的一切属性都在虚拟DOM
中有对应的属性。 diff
是发生在虚拟DOM
上的。新虚拟DOM
和老虚拟DOM
进行diff
(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM
上。snabbdom
是著名的虚拟DOM
库,是diff
算法的鼻祖,Vue
源码借鉴了snabbdom
。它的git
地址是 https://github.com/snabbdom/snabbdom
。snabbdom
渲染函数,h
函数,如下所示:
h
函数用来产生虚拟节点(vnode)
h
函数可以嵌套使用,从而得到虚拟DOM
树h
函数用法很活
diff
算法,如下所示:
最小量更新的关键是 key
。key
是这个节点的 唯一标识,告诉diff
算法,在更改前后它们是同一个DOM
节点。 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的、插入新的。对于同一个虚拟节点,选择器相同且key
相同。 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,精细化比较不diff
你,而是暴力删除旧的、然后插入新的。
同一个节点,旧节点的key
要和新节点的key
相同,并且旧节点的选择器要和新节点的选择器相同。 diff
处理新旧节点不是同一个节点时,如下所示:
patch
函数被调用,判断 oldVnode
是虚拟节点还是DOM
节点。如果是虚拟节点,判断 oldVnode
和 newVnode
是不是同一个节点。如果是DOM
节点,将 oldVnode
包装为虚拟节点,然后判断oldVnode
和 newVnode
是不是同一个节点。 如果是同一个节点,精细化比较。如果不是同一个节点,暴力删除旧的、插入新的。
创建节点时,所有子节点需要递归创建的。 diff
处理新旧节点是同一个节点时,如下所示:
判断oldVnode
和newVnode
是不是内存中的同一个对象。如果是就什么都不做。如果不是,判断 newVnode
有没有 text
属性。 如果有 text
属性,判断newVnode
的text
和oldVnode
是否相同。如果没有 text
属性,意味着 newVnode
有 children
,判断 oldVnode
有没有 Children
。 如果 oldVnode
有 Children
,最复杂的情况,就是新老vnode
都有 children
,此时就进行最优雅的 diff
算法。如果 oldVnode
没有 Children
,清空 oldVnode
中的 text
,并且把 newVnode
中的 children
添加到 DOM
中。 如果newVnode
的text
和oldVnode
相同,什么都不做。如果不同,把 elm
中的 innerText
改变为 newVnode
的 text
,即使oldVnode
有 children
属性而没有text
属性,那么也没事儿,innertText
一旦改变为新的 text
,老children
就没了。
对于新旧子节点的新增情况,新创建的节点(newVnode.children[i].elm)
插入到所有未处理的节点(oldVnode.children[um].elm)
之前,而不是所有已处理节点之后。 对于经典的diff
算法优化策略,有四种命中查找,命中一种就不再进行命中判断了。如果都没有命中,就需要用循环来寻找了。移动到oldStartIdx
之前。策略如下所示:
新前与旧前 新后与旧后 新后与旧前,此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后 新前与旧后,此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前
对于新增情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }
。如果是旧节点先循环完毕,说明新节点中有要插入的节点。 对于删除情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }
。如果是新节点先循环完毕,如果老节点中 还有剩余节点,说明他们是要被删除的节点。 对于多删除的情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }
。如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点),说明他们是要被删除的节点。 对于复杂的情况,旧子节点和新子节点,当新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面。当新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面。
二、虚拟DOM和Diff 算法的实现
vnode
,函数的功能非常简单,就是把传入的5
个参数组合成对象返回,代码如下所示:
export default function ( sel, data, children, text, elm ) {
const key = data. key;
return {
sel, data, children, text, elm, key
} ;
}
createElement
,真正创建节点。将vnode
创建为DOM
,是孤儿节点,不进行插入,如下所示:
创建一个DOM
节点,这个节点现在还是孤儿节点,判断有子节点还是有文本。 它内部是文字就赋值。它内部是子节点,就要递归创建节点,得到当前这个children
。 创建出它的DOM
,一旦调用createElement
意味着:创建出DOM
了,并且它的elm
属性指向了创建出的DOM
,但是还没有上树,是一个孤儿节点,上树。 补充elm
属性,返回elm
,elm
属性是一个纯DOM
对象。
createElement
,代码如下所示:
export default function createElement ( vnode ) {
let domNode = document. createElement ( vnode. sel) ;
if ( vnode. text != '' && ( vnode. children == undefined || vnode. children. length == 0 ) ) {
domNode. innerText = vnode. text;
} else if ( Array. isArray ( vnode. children) && vnode. children. length > 0 ) {
for ( let i = 0 ; i < vnode. children. length; i++ ) {
let ch = vnode. children[ i] ;
let chDOM = createElement ( ch) ;
domNode. appendChild ( chDOM) ;
}
}
vnode. elm = domNode;
return vnode. elm;
} ;
h
函数,这个函数必须接受3
个参数,缺一不可,相当于它的重载功能较弱,如下所示:
调用的时候形态必须是下面的三种之一,形态① h('div', {}, '文字')
。形态② h('div', {}, [])
。形态③ h('div', {}, h())
。 检查参数的个数,检查参数c
的类型,说明现在调用h
函数是形态①。 说明现在调用h
函数是形态②,遍历c
,收集children
,检查c[i]
必须是一个对象,如果不满足。这里不用执行c[i]
,因为你的测试语句中已经有了执行。此时只需要收集好就可以了。循环结束了,就说明children
收集完毕了,此时可以返回虚拟节点了,它有children
属性的。 说明现在调用h
函数是形态③,传入的c
是唯一的children
。不用执行c
,因为测试语句中已经执行了c
。
h
函数,代码如下所示:
import vnode from './vnode.js' ;
export default function ( sel, data, c ) {
if ( arguments. length != 3 )
throw new Error ( '对不起,h函数必须传入3个参数,我们是低配版h函数' ) ;
if ( typeof c == 'string' || typeof c == 'number' ) {
return vnode ( sel, data, undefined , c, undefined ) ;
} else if ( Array. isArray ( c) ) {
let children = [ ] ;
for ( let i = 0 ; i < c. length; i++ ) {
if ( ! ( typeof c[ i] == 'object' && c[ i] . hasOwnProperty ( 'sel' ) ) )
throw new Error ( '传入的数组参数中有项不是h函数' ) ;
children. push ( c[ i] ) ;
}
return vnode ( sel, data, children, undefined , undefined ) ;
} else if ( typeof c == 'object' && c. hasOwnProperty ( 'sel' ) ) {
let children = [ c] ;
return vnode ( sel, data, children, undefined , undefined ) ;
} else {
throw new Error ( '传入的第三个参数类型不对' ) ;
}
} ;
patchVnode
,函数的功能是对比同一个虚拟节点,如下所示:
判断新旧vnode
是否是同一个对象,判断新vnode
有没有text
属性。 新vnode
有text
属性,如果新虚拟节点中的text
和老的虚拟节点的text
不同,那么直接让新的text
写入老的elm
中即可。如果老的elm
中是children
,那么也会立即消失掉。 新vnode
没有text
属性,有children
,判断老的有没有children
。老的有children
,新的也有children
,此时就是最复杂的情况。 老的没有children
,新的有children
。清空老的节点的内容,遍历新的vnode
的子节点,创建DOM
,上树。
patchVnode
,代码如下所示:
import createElement from "./createElement" ;
import updateChildren from './updateChildren.js' ;
export default function patchVnode ( oldVnode, newVnode ) {
if ( oldVnode === newVnode) return ;
if ( newVnode. text != undefined && ( newVnode. children == undefined || newVnode. children. length == 0 ) ) {
console. log ( '新vnode有text属性' ) ;
if ( newVnode. text != oldVnode. text) {
oldVnode. elm. innerText = newVnode. text;
}
} else {
console. log ( '新vnode没有text属性' ) ;
if ( oldVnode. children != undefined && oldVnode. children. length > 0 ) {
updateChildren ( oldVnode. elm, oldVnode. children, newVnode. children) ;
} else {
oldVnode. elm. innerHTML = '' ;
for ( let i = 0 ; i < newVnode. children. length; i++ ) {
let dom = createElement ( newVnode. children[ i] ) ;
oldVnode. elm. appendChild ( dom) ;
}
}
}
}
patch
,对比,如下所示:
判断传入的第一个参数,是DOM
节点还是虚拟节点。传入的第一个参数是DOM
节点,此时要包装为虚拟节点。 判断oldVnode
和newVnode
是不是同一个节点。插入到老节点之前,删除老节点。
patch
,代码如下所示:
import vnode from './vnode.js' ;
import createElement from './createElement.js' ;
import patchVnode from './patchVnode.js'
export default function patch ( oldVnode, newVnode ) {
if ( oldVnode. sel == '' || oldVnode. sel == undefined ) {
oldVnode = vnode ( oldVnode. tagName. toLowerCase ( ) , { } , [ ] , undefined , oldVnode) ;
}
if ( oldVnode. key == newVnode. key && oldVnode. sel == newVnode. sel) {
console. log ( '是同一个节点' ) ;
patchVnode ( oldVnode, newVnode) ;
} else {
console. log ( '不是同一个节点,暴力插入新的,删除旧的' ) ;
let newVnodeElm = createElement ( newVnode) ;
if ( oldVnode. elm. parentNode && newVnodeElm) {
oldVnode. elm. parentNode. insertBefore ( newVnodeElm, oldVnode. elm) ;
}
oldVnode. elm. parentNode. removeChild ( oldVnode. elm) ;
}
} ;
updateChildren
,判断是否是同一个虚拟节点,并且实现更新,如下所示:
旧前、新前、旧后、新后、旧前节点、旧后节点、新前节点、新后节点。 开始大while
了,首先不是判断①②③④命中,而是要略过已经加undefined
标记的东西。新前和旧前,新后和旧后。 新后和旧前,当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面。移动节点,只要你插入一个已经在DOM
树上的节点,它就会被移动。 新前和旧后,当④新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面。移动节点,要你插入一个已经在DOM
树上的节点,它就会被移动。 四种命中都没有命中,制作keyMap
一个映射对象,这样就不用每次都遍历老对象了。从oldStartIdx
开始,到oldEndIdx
结束,创建keyMap
映射对象。 寻找当前这项(newStartIdx)
这项在keyMap
中的映射的位置序号。判断,如果idxInOld
是undefined
表示它是全新的项。被加入的项(就是newStartVnode
这项)现不是真正的DOM
节点。 如果不是undefined
,不是全新的项,而是要移动。把这项设置为undefined
,表示我已经处理完这项了。移动,调用insertBefore
也可以实现移动。指针下移,只移动新的头。 继续看看有没有剩余的。循环结束了start
还是比old
小。遍历新的newCh
,添加到老的没有处理的之前。 insertBefore
方法可以自动识别null
,如果是null
就会自动排到队尾去。和appendChild
是一致了。newCh[i]
现在还没有真正的DOM
,所以要调用createElement()
函数变为DOM
。批量删除oldStart
和oldEnd
指针之间的项。
updateChildren
,代码如下所示:
import patchVnode from './patchVnode.js' ;
import createElement from './createElement.js' ;
function checkSameVnode ( a, b ) {
return a. sel == b. sel && a. key == b. key;
} ;
export default function updateChildren ( parentElm, oldCh, newCh ) {
console. log ( '我是updateChildren' ) ;
console. log ( oldCh, newCh) ;
let oldStartIdx = 0 ;
let newStartIdx = 0 ;
let oldEndIdx = oldCh. length - 1 ;
let newEndIdx = newCh. length - 1 ;
let oldStartVnode = oldCh[ 0 ] ;
let oldEndVnode = oldCh[ oldEndIdx] ;
let newStartVnode = newCh[ 0 ] ;
let newEndVnode = newCh[ newEndIdx] ;
let keyMap = null ;
while ( oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
console. log ( '★' ) ;
if ( oldStartVnode == null || oldCh[ oldStartIdx] == undefined ) {
oldStartVnode = oldCh[ ++ oldStartIdx] ;
} else if ( oldEndVnode == null || oldCh[ oldEndIdx] == undefined ) {
oldEndVnode = oldCh[ -- oldEndIdx] ;
} else if ( newStartVnode == null || newCh[ newStartIdx] == undefined ) {
newStartVnode = newCh[ ++ newStartIdx] ;
} else if ( newEndVnode == null || newCh[ newEndIdx] == undefined ) {
newEndVnode = newCh[ -- newEndIdx] ;
} else if ( checkSameVnode ( oldStartVnode, newStartVnode) ) {
console. log ( '①新前和旧前命中' ) ;
patchVnode ( oldStartVnode, newStartVnode) ;
oldStartVnode = oldCh[ ++ oldStartIdx] ;
newStartVnode = newCh[ ++ newStartIdx] ;
} else if ( checkSameVnode ( oldEndVnode, newEndVnode) ) {
console. log ( '②新后和旧后命中' ) ;
patchVnode ( oldEndVnode, newEndVnode) ;
oldEndVnode = oldCh[ -- oldEndIdx] ;
newEndVnode = newCh[ -- newEndIdx] ;
} else if ( checkSameVnode ( oldStartVnode, newEndVnode) ) {
console. log ( '③新后和旧前命中' ) ;
patchVnode ( oldStartVnode, newEndVnode) ;
parentElm. insertBefore ( oldStartVnode. elm, oldEndVnode. elm. nextSibling) ;
oldStartVnode = oldCh[ ++ oldStartIdx] ;
newEndVnode = newCh[ -- newEndIdx] ;
} else if ( checkSameVnode ( oldEndVnode, newStartVnode) ) {
console. log ( '④新前和旧后命中' ) ;
patchVnode ( oldEndVnode, newStartVnode) ;
parentElm. insertBefore ( oldEndVnode. elm, oldStartVnode. elm) ;
oldEndVnode = oldCh[ -- oldEndIdx] ;
newStartVnode = newCh[ ++ newStartIdx] ;
} else {
if ( ! keyMap) {
keyMap = { } ;
for ( let i = oldStartIdx; i <= oldEndIdx; i++ ) {
const key = oldCh[ i] . key;
if ( key != undefined ) {
keyMap[ key] = i;
}
}
}
console. log ( keyMap) ;
const idxInOld = keyMap[ newStartVnode. key] ;
console. log ( idxInOld) ;
if ( idxInOld == undefined ) {
parentElm. insertBefore ( createElement ( newStartVnode) , oldStartVnode. elm) ;
} else {
const elmToMove = oldCh[ idxInOld] ;
patchVnode ( elmToMove, newStartVnode) ;
oldCh[ idxInOld] = undefined ;
parentElm. insertBefore ( elmToMove. elm, oldStartVnode. elm) ;
}
newStartVnode = newCh[ ++ newStartIdx] ;
}
}
if ( newStartIdx <= newEndIdx) {
console. log ( 'new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStartIdx之前' ) ;
for ( let i = newStartIdx; i <= newEndIdx; i++ ) {
parentElm. insertBefore ( createElement ( newCh[ i] ) , oldCh[ oldStartIdx] . elm) ;
}
} else if ( oldStartIdx <= oldEndIdx) {
console. log ( 'old还有剩余节点没有处理,要删除项' ) ;
for ( let i = oldStartIdx; i <= oldEndIdx; i++ ) {
if ( oldCh[ i] ) {
parentElm. removeChild ( oldCh[ i] . elm) ;
}
}
}
} ;