虚拟dom是通过状态生成一个虚拟节点树,然后使用虚拟节点数进行渲染,在渲染之前,会使用新生成的虚拟节点数和上一个上传的虚拟节点数进行对比,只渲染不同的部分。
之前说过vue的变化侦测,当属性变化时vue会通知到对应的组件,然后组件内部通过虚拟dom进行对比渲染。
编写vue文件模板-->render渲染函数-->生成vnode-->渲染视图
VNode是一个类,可以生成不同的vnode实例,vonde只是一个对象,不要想象的太神秘。
新旧两个vnode对比的过程就是diff算法,内部函数是patch(补丁)
源码来源 https://www.cnblogs.com/wind-lanyan/p/9061684.html 写的很不错这篇文章
function patch (oldVnode, vnode) {
// some code
if (sameVnode(oldVnode, vnode)) {
//如果一样进入下一层对比
patchVnode(oldVnode, vnode)
} else {
//如果不一样 就全部替换了不会管原本下面的子级
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl) // 父元素
createEle(vnode) // 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
oldVnode = null
}
}
// some code
return vnode
}
sameVnode就是对比新旧两个vnode是否相同 如果相同进行下一次对比,否则直接替换oldVnode,
diff是逐层对比,也是同层对比,判断两个vnode是否相同主要是看他们的key,tag,isComment,vode上的data。
如果父级不相同也就不会再进入子集对比了哦!
insertBefore 传入三个值,父节点,移动的节点(真正的内容),要移动到的地方(会被替换掉)
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
patchVnode
判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return;
如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
如果oldVnode有子节点而Vnode没有,则删除el的子节点
如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
如果两者都有子节点,则执行updateChildren函数比较子节点
上面这几条看代码基本能读懂意思
updateChildren 就是具体的规则来对比新老节点的复用问题,复用就是指原本有的子节点被他找到了,他发现新老对比只是移动了位置就只直接移动内容位置,或是位置都没有变(位置没变还是会调用回patchVnode)。
updateChildren内部的具体代码还是要调用回来 patchVnode
patchVnode 中的createEle、removeChildren、setTextContent 就已经是具体操作dom的地方了,仔细思考层层递归,到了最底层多半就是这些情况了,要不新增、要不删除、要不替换文字。
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
下方updateChildren是复制过来的,又臭又长的源码。
updateChildren 就是对比子级用的。
oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。 原博客的介绍,感觉有些太概括了。
再仔细的说一下
updateChildren 对比子层级的时候是从两边向中间循环。
所以开头的这些变量oldStartIdx、newStartIdx、oldEndIdx、oldStartVnode、newEndIdx、newStartVnode....是用来记录位置的,
新vnode左侧第一个id 右侧第一个id 老vnode右侧第一个...
四种对比方式是 新前-旧前 新后-旧后 新后-旧前 新前-旧后
是这么对比的,如果判断为相等就去更改内容或是移动位置。
如果四种方式都没比较成功就用key循环对比。
最后根据前面的id 判断哪个先循环完毕了,
如果旧vnode循环完毕了新的vnode还要节点就说明剩下的都是新增的
如果新vnode循环完毕就vnode还要节点就说明旧的上剩余的节点都是需要删除的。
updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, 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
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
核心源码解读就是上面这些。
再说下自己的理解。其实对比新旧vnode是否相同就是直接通过sameVnode传递进新老节点对比即可,但是为什么updateChildren内要写这么多代码,四种对比方式
新前-旧前 新后-旧后 新后-旧前 新前-旧后,其实用最笨的方法直接循环对比就行,但是这样对比要双重循环,新老双向循环这样就很耗费性能,这四种方式就可以看成在碰运气,往四个方向比较一下,比较上了就找到了,比较不上再去循环用key比较。但是你不要感觉这样没有道理,真实场景思考一下,实际的界面上位置基本不会大变这四种情况碰上的概率还是很高的。
在思考一个问题。为什么vue推荐我们循环时要加上key,可以优化性能复用节点。
当 Vue 更新已使用 v-for 渲染的元素列表时,默认会采用“就地填充”策略。如果数据项的顺序发生了变化,不是移动 DOM 元素来匹配列表项的顺序,Vue 会将每个元素填充到恰当的位置,并且确保最终反映为,在该特定索引处放置应该呈现的内容
官网上是这么写的
再来解读下 updateChildren 关于key的部分
diff算法基本就是这样子。