上一篇:Vue原理解析(七):全面深刻理解响应式原理(下)-数组进阶篇html
以前章节介绍了VNode
如何生成真实Dom
,这只是patch
内首次渲染作的事,完成了一小部分功能而已,而它作的最重要的事情是当响应式触发时,让页面的从新渲染这一过程能高效完成。其实页面的从新渲染彻底可使用新生成的Dom
去整个替换掉旧的Dom
,然而这么作比较低效,因此就借助接下来将介绍的diff
比较算法来完成。vue
diff
算法作的事情是比较VNode
和oldVNode
,再以VNode
为标准的状况下在oldVNode
上作小的改动,完成VNode
对应的Dom
渲染。node
回到以前_update
方法的实现,这个时候就会走到else
的逻辑了:面试
Vue.prototype._update = function(vnode) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode // 缓存为以前vnode
if(!prevVnode) { // 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode)
} else { // 从新渲染
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
复制代码
既然是在现有的VNode
上修修补补来达到从新渲染的目的,因此无非是作三件事情:算法
建立新增节点数组
删除废弃节点缓存
更新已有节点bash
接下来咱们将介绍以上三种状况分别什么状况下会遇到。dom
新增节点两种状况下会遇到:异步
VNode
中有的节点而oldVNode
没有
VNode
中有的节点而oldVNode
中没有,最明显的场景就是首次渲染了,这个时候是没有oldVNode
的,因此将整个VNode
渲染为真实Dom
插入到根节点以内便可,这一详细过程以前章节有详细说明。
VNode
和oldVNode
彻底不一样
VNode
和oldVNode
不是同一个节点时,直接会将VNode
建立为真实Dom
,插入到旧节点的后面,这个时候旧节点就变成了废弃节点,移除以完成替换过程。判断两个节点是否为同一个节点,内部是这样定义的:
function sameVnode (a, b) { // 是不是相同的VNode节点
return (
a.key === b.key && ( // 如平时v-for内写的key
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 注释节点
isDef(a.data) === isDef(b.data) && // 都有data属性
sameInputType(a, b) // 相同的input类型
) || (
isTrue(a.isAsyncPlaceholder) && // 是异步占位符节点
a.asyncFactory === b.asyncFactory && // 异步工厂方法
isUndef(b.asyncFactory.error)
)
)
)
}
复制代码
上面建立新增节点的第二种状况以略有说起,比较vnode
和oldVnode
,若是根节点不相同就将Vnode
整颗渲染为真实Dom
,插入到旧节点的后面,最后删除掉已经废弃的旧节点便可:
patch
方法内将建立好的
Dom
插入到废弃节点后面以后:
if (isDef(parentElm)) { // 在它们的父节点内删除旧节点
removeVnodes(parentElm, [oldVnode], 0, 0)
}
-------------------------------------------------------------
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
} // 移除从startIdx到endIdx之间的内容
------------------------------------------------------------
function removeNode(el) { // 单个节点移除
const parent = nodeOps.parentNode(el)
if(isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
复制代码
这个才是diff
算法的重点,当两个节点是相同的节点时,这个时候就须要找出它们的不一样之处,比较它们主要是使用patchVnode
方法,这个方法里面主要也是处理几种分支状况:
都是静态节点
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) { // 彻底同样
return
}
const elm = vnode.elm = oldVnode.elm
if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {
vnode.componentInstance = oldVnode.componentInstance
return // 都是静态节点,跳过
}
...
}
复制代码
什么是静态节点了?这是编译阶段作的事情,它会找出模板中的静态节点并作上标记(isStatic
为true
),例如:
<template>
<div>
<h2>{{title}}</h2>
<p>新鲜食材</p>
</div>
</template>
复制代码
这里的h2
标签就不是静态节点,由于是根据插值变化的,而p
标签就是静态节点,由于不会改变。若是都是静态节点就跳过此次比较,这也是编译阶段为diff
比对作的优化。
vnode
节点没有文本属性
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
if (isDef(oldCh) && isDef(ch)) { // // 都有children
if (oldCh !== ch) { // 且children不一样
updateChildren(elm, oldCh, ch) // 更新子节点
}
}
else if (isDef(ch)) { // 只有vnode有children
if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置oldVnode文本为空
}
addVnodes(elm, null, ch, 0, ch.length - 1)
// 往oldVnode空的标签内插入vnode的children的真实dom
}
else if (isDef(oldCh)) { // 只有oldVnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 所有移除
}
else if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置为空
}
}
else { vnode有text属性
...
}
...
复制代码
若是vnode
没有文本节点,又会有接下来的四个分支:
1. 都有children
且不相同
updateChildren
方法更详细的比对它们的children
,若是说更新已有节点是patch
的核心,那这里的更新children
就是核心中的核心,这个以后使用流程图的方式仔仔细细说明。2. 只有vnode
有children
oldVnode
要么是一个空标签或者是文本节点,若是是文本节点就清空文本节点,而后将vnode
的children
建立为真实Dom
后插入到空标签内。3. 只有oldVnode
有children
vnode
为标准的,因此vnode
没有的东西,oldVnode
内就是废弃节点,须要删除掉。4. 只有oldVnode
有文本
oldVnode
有而vnode
没有的,清空或移除便可。
vnode
节点有文本属性
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
...
} else if(oldVnode.text !== vnode.text) { // vnode有text属性且不一样
nodeOps.setTextContent(elm, vnode.text) // 设置文本
}
...
复制代码
仍是那句话,以vnode
为标准,因此vnode
有文本节点的话,不管oldVnode
是什么类型节点,直接设置为vnode
内的文本便可。至此,整个diff
比对的大体过程就算是说明完毕了,咱们仍是以一张流程图来理清思路:
更新子节点示例:
<template>
<ul>
<li v-for='item in list' :key='item.id'>{{item.name}}</li>
</ul>
</template>
export default {
data() {
return {
list: [{
id: 'a1',name: 'A'}, {
id: 'b2',name: 'B'}, {
id: 'c3',name: 'C'}, {
id: 'd4',name: 'D'}
]
}
},
mounted() {
setTimeout(() => {
this.list.sort(() => Math.random() - .5)
.unshift({id: 'e5', name: 'E'})
}, 1000)
}
}
复制代码
上述代码中首先渲染一个列表,而后将其随机打乱顺序后并添加一项到列表最前面,这个时候就会触发该组件更新子节点的逻辑,以前也会有一些其余的逻辑,这里只用关注更新子节点相关,来看下它怎么更新Dom
的:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧第一个下标
let oldStartVnode = oldCh[0] // 旧第一个节点
let oldEndIdx = oldCh.length - 1 // 旧最后下标
let oldEndVnode = oldCh[oldEndIdx] // 旧最后节点
let newStartIdx = 0 // 新第一个下标
let newStartVnode = newCh[0] // 新第一个节点
let newEndIdx = newCh.length - 1 // 新最后下标
let newEndVnode = newCh[newEndIdx] // 新最后节点
let oldKeyToIdx // 旧节点key和下标的对象集合
let idxInOld // 新节点key在旧节点key集合里的下标
let vnodeToMove // idxInOld对应的旧节点
let refElm // 参考节点
checkDuplicateKeys(newCh) // 检测newVnode的key是否有重复
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 开始遍历children
if (isUndef(oldStartVnode)) { // 跳过因位移留下的undefined
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) { // 跳过因位移留下的undefine
oldEndVnode = oldCh[--oldEndIdx]
}
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) // 递归调用
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 将旧第一节点右移到最后,视图马上呈现
oldStartVnode = oldCh[++oldStartIdx] // 旧开始节点被处理,旧开始节点为第二个
newEndVnode = newCh[--newEndIdx] // 新最后节点被处理,新最后节点为倒数第二个
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // 比对旧最后和新第一节点
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 递归调用
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 将旧最后节点左移到最前面,视图马上呈现
oldEndVnode = oldCh[--oldEndIdx] // 旧最后节点被处理,旧最后节点为倒数第二个
newStartVnode = newCh[++newStartIdx] // 新第一节点被处理,新第一节点为第二个
}
else { // 不包括以上四种快捷比对方式
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 获取旧开始到结束节点的key和下表集合
}
idxInOld = isDef(newStartVnode.key) // 获取新节点key在旧节点key集合里的下标
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // 找不到对应的下标,表示新节点是新增的,须要建立新dom
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
}
else { // 能找到对应的下标,表示是已有的节点,移动位置便可
vnodeToMove = oldCh[idxInOld] // 获取对应已有的旧节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx] // 新开始下标和节点更新为第二个节点
}
}
...
}
复制代码
函数内首先会定义一堆let
定义的变量,这些变量是随着while
循环体而改变当前值的,循环的退出条件为只要新旧节点列表有一个处理完就退出,看着循环体代码挺复杂,其实它只是作了三件事,明白了哪三件事再看循环体,会发现其实并不复杂:
1. 跳过undefined
为何会有undefined
,以后的流程图会说明清楚。这里只要记住,若是旧开始节点为undefined
,就后移一位;若是旧结束节点为undefined
,就前移一位。
2. 快捷查找
首先会尝试四种快速查找的方式,若是不匹配,再作进一步处理:
若是匹配,表示它们位置都是对的,Dom
不用改,就将新旧节点开始的下标日后移一位便可。
若是匹配,也表示它们位置是对的,Dom
不用改,就将新旧节点结束的下标前移一位便可。
若是匹配,位置不对须要更新Dom
视图,将旧开始节点对应的真实Dom
插入到最后一位,旧开始节点下标后移一位,新结束节点下标前移一位。
若是匹配,位置不对须要更新Dom
视图,将旧结束节点对应的真实Dom
插入到旧开始节点对应真实Dom
的前面,旧结束节点下标前移一位,新开始节点下标后移一位。
3. key值查找
那就说明是已有的节点,只是位置不对,那就移动节点位置便可。
再已有的key
值集合内找不到,那就说明是新的节点,那就建立一个对应的真实Dom
节点,插入到旧开始节点对应的真实Dom
前面便可。
这么说并不太好理解,结合以前的示例,根据如下的流程图将会明白不少:
start
和
end
标记。
key
值列表查找,并无找到说明
E
是新增的节点,建立对应的真实
Dom
,插入到旧节点里
start
对应真实
Dom
的前面,也就是
A
的前面,已经处理完了一个,新
start
位置后移一位。
key
值列表查找。发现是已有的节点,只是位置不对,那么进行插入操做,参考节点仍是
A
节点,将原来旧节点
C
设置为
undefined
,这里以后会跳过它。又处理完了一个节点,新
start
后移一位。
Dom
位置是对的,新
start
和旧
start
都后移一位。
Dom
位置是不对的,插入节点到最后位置,最后将新
end
前移一位,旧
start
后移一位。
undefined
的逻辑,而后再开始快捷比对,匹配到的是新开始节点和旧开始节点,它们各自
start
后移一位,这个时候就会跳出循环了。接着看下最后的收尾代码:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
...
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
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(parentElm, oldCh, oldStartIdx, oldEndIdx) // 删除废弃节点
}
}
复制代码
咱们以前的示例恰好是新旧节点列表同时处理完退出的循环,这里是退出循环后为还有没有处理完的节点,作不一样的处理:
Dom
并插入到视图便可。这就是整个
diff
算法过程了,你们能够对比以前的递归流程图再看一遍,相信思路会清晰不少。
最后按照惯例咱们仍是以一道vue
可能会被问到的面试题做为本章的结束~
面试官微笑而又不失礼貌的问道:
v-for
里建议为每一项绑定key
,并且最好具备惟一性,而不建议使用index
?怼回去:
diff
比对内部作更新子节点时,会根据oldVnode
内没有处理的节点获得一个key
值和下标对应的对象集合,为的就是当处理vnode
每个节点时,能快速查找该节点是不是已有的节点,从而提升整个diff
比对的性能。若是是一个动态列表,key
值最好能保持惟一性,但像轮播图那种不会变动的列表,使用index
也是没问题的。顺手点个赞或关注呗,找起来也方便~