简单Diff算法
减少DOM操作的性能开销
在之前我们对两组子节点的更新的时候,采用了一种直接的手段,将全部旧子节点卸载,再将全部新子节点挂载上去
这样确实能够完成更新,但是没有复用任何DOM元素,会产生极大的性能开销
const oldValue = {
type: 'div',
children: [
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'}
]
}
const newValue = {
type: 'div',
children: [
{type: 'p', children: '4'},
{type: 'p', children: '5'},
{type: 'p', children: '6'}
]
}
对于上述代码,我们如果按照之前的做法,必须执行6次DOM操作(3次卸载+3次挂载)
但是上述的代码中,我们可以发现改变的只是他的p标签的值,所以我们可以直接修改旧子节点的文本,这样就只需要3次DOM操作了,性能比第一种方法提高了一倍
实现思路:将每一个子节点的新旧节点一一比较更新即可
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
//判断新子节点是否是一组子节点
//判断旧子节点是否也是一组子节点
if(Array.isArray(n1.children)){
//这里说明新旧子节点都是一组子节点
//获取新旧节点的子节点
const oldChildren = n1.children
const newChildren = n2.children
//遍历两者,一一对应的更新节点
for(let i = 0; i < oldChildren.length; i++){
//调用patch逐个更新子节点
patch(oldChildren[i], newChildren[i])
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
上述代码也存在很多不足,我们是通过遍历旧子节点,并假设新子节点的数量与之相同的情况下,这段代码才能正常运行,一旦数量不一致,比如新节点比较少的时候,有些节点就必须卸载
所以现在,我们应该考虑一下两组子节点的长短,遍历长度短的那一组,这样才能尽可能多的调用patch函数进行更新,然后,对比新旧两组子节点的长度,如果新的一组子节点更长,那就说明有子节点需要挂载,否则需要卸载
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
//判断新子节点是否是一组子节点
//判断旧子节点是否也是一组子节点
if(Array.isArray(n1.children)){
//这里说明新旧子节点都是一组子节点
//获取新旧节点的子节点
const oldChildren = n1.children
const newChildren = n2.children
//获取新旧子节点的长度
const oldLen = oldChildren.length
const newLen = newChildren.length
//两组子节点的公共长度,即两者中比较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)
//遍历commonLength次
for(let i = 0; i < commonLength; i++){
//调用patch逐个更新子节点
patch(oldChildren[i], newChildren[i], container)
}
//如果newLen > oldLen,说明有新子节点需要挂载
if(newLen > oldLen){
for(let i = commonLength; i < newLen; i++){
patch(null, newChildren[i], container)
}
}else if(newLen < oldLen){
//如果newLen < oldLen,说明有旧子节点需要卸载
for(let i = commonLength; i < oldLen; i++){
unmount(oldChildren[i])
}
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
DOM复用与key的作用
上述方式已经可以减少DOM操作次数,但是还有优化空间
//旧子节点
[
{type: 'p'},
{type: 'div'},
{type: 'span'}
]
//新子节点
[
{type: 'span'},
{type: 'p'},
{type: 'div'}
]
如果按照上一节的方式去更新DOM元素,那么需要6次DOM操作
实际上,这些DOM元素只是顺序不同,所以应该通过DOM的移动来完成子节点的更新,这比不断执行子节点的卸载和挂载性能更好
但是,这个方法有一个前提:新旧子节点中要存在可复用的节点
所以现在的问题就变成:应该如何确定新的子节点是否出现在旧的子结点中
解决方式一:通过vnode.type来判断,只要相同就认为是相同的节点,但是实际上,这种方式并不可靠
//旧子节点
[
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'}
]
//新子节点
[
{type: 'p', children: '3'},
{type: 'p', children: '1'},
{type: 'p', children: '2'}
]
如果是这段代码的话,我们就无法确定新旧子节点中节点的对应关系,所以也无法得知怎么进行DOM移动才能完成更新
解决方式二:引入一个额外的key来作为vnode的标识
key就像虚拟节点的身份证,只要两个虚拟子节点的type属性值和key属性值都相同,那么就认为他们是相同的,可以进行DOM的复用
//旧子节点
[
{type: 'p', children: '1', key: 1},
{type: 'p', children: '2', key: 2},
{type: 'p', children: '3', key: 3}
]
//新子节点
[
{type: 'p', children: '3', key: 3},
{type: 'p', children: '1', key: 1},
{type: 'p', children: '2', key: 2}
]
注意,DOM可复用并不是代表着不需要更新,可能需要更新文本节点等
现在,要完成新旧元素的更新一共有两步:打补丁操作和移动元素操作
首先,我们要对虚拟节点进行打补丁,因为新的虚拟节点的文本子节点可能已经改变
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
//这里说明新旧子节点都是一组子节点
//获取新旧节点的子节点
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.length; i++){
//拿要将要更新的新子节点
const newVNode = newChildren[i]
//遍历旧的children
for(let j = 0; j < oldChildren.length; j++){
//获取旧的子节点
const oldVNode = oldChildren[j]
//如果找到了具有相同的key值的两个节点,则说明可以复用
if(newVNode.key === oldVNode.key){
//当仍然需要patch函数来更新
patch(oldVNode, newVNode, container)
break
}
}
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
现在,所有节点对应的真实DOM元素已经更新完毕了,但是依旧保持着旧子节点的顺序,因此我们需要通过移动节点来完成真实DOM顺序的更新
找到需要移动的元素
我们现在已经可以通过key值找到可复用的节点了,但是顺序还是原来的顺序,所以接下来我们需要考虑,判断一个节点是否需要移动,以及如何移动
现在我们先来思考一下如何判断一个节点需不需要移动,在这里,我们可以通过逆向思维的方式想想在啥情况下节点不需要移动,很明显,当新旧两组子节点顺序不变时,就不需要移动
这里考虑一种最理想的情况:
如图所示,新旧两组节点的顺序没有发生变化,如果我们采用上一节的更新算法,会发生什么:
- 第一步:取新数子节点的第一个节点
p-1,key为1,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-1可以复用,索引值为0 - 第二步:取新数子节点的第二个节点
p-2,key为2,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-2可以复用,索引值为1 - 第三步:取新数子节点的第三个节点
p-3,key为3,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-3可以复用,索引值为2
可以发现,我们按照这些旧子节点的位置索引值的先后顺序排序,可以得到一个索引递增的顺序:1,2,3,所以在这种情况下是不需要移动任何节点的
再来考虑一下另一种情况:
现在新旧两组节点的顺序已经发生变化了,我们再来采用原来的更新算法分析一下:
-
第一步:取新子节点的第一个节点
p-3,key为3,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-3可以复用,索引值为2 -
第二步:取新子节点的第一个节点
p-1,key为1,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-1可以复用,索引值为0在这里,我们可以发现,我们想要的递增序列已经被打破,这说明节点
p-1在旧children中排在节点p-3前面,但在新children中,他排在p-3后面,故节点p-1对应真实的DOM需要移动 -
第三步:取新子节点的第三个节点
p-2,key为2,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-2可以复用,索引值为1在这里,我们可以发现,节点
p-2在旧children中索引为1,小于节点三p-3在旧children上的索引2,说明在旧children中,p-2排在p-3前面,但是在新children中,排在节点p-3后面,因此需要移动p-2对应的真实DOM
所以,最后我们得出了p-1和p-2都要移动的结论
这是因为他们在旧children中索引小于p-3在旧children中的索引,按照先后顺序记录在寻找节点过程中所遇到位置的索引,可以得到:2, 0, 1,并不具有递增序列
综上,我们可以将节点p-3在旧节点中的索引定义为:在旧children中寻找具有相同key值节点过程中,遇到的最大索引值,如果在后续寻找过程中,存在索引值比当前最大索引值小的节点,则意味着该节点需要移动(即上面第二三步)
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
//这里说明新旧子节点都是一组子节点
//获取新旧节点的子节点
const oldChildren = n1.children
const newChildren = n2.children
//存储寻找过程中遇到的最大索引值
let lastIndex = 0
//遍历新的children
for(let i = 0; i < newChildren.length; i++){
//拿要将要更新的新子节点
const newVNode = newChildren[i]
//遍历旧的children
for(let j = 0; j < oldChildren.length; j++){
//获取旧的子节点
const oldVNode = oldChildren[j]
//如果找到了具有相同的key值的两个节点,则说明可以复用
if(newVNode.key === oldVNode.key){
//当仍然需要patch函数来更新
patch(oldVNode, newVNode, container)
if(j < lastIndex){
//如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
//说明该节点对应真实的DOM需要移动
}else{
//如果当前找到的节点在旧children中的索引不小于最大索引值,则更新lastIndex
lastIndex = j
}
//此处需要break
break
}
}
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
如何移动元素
在上一节中我们已经可以找到需要移动的节点了,这一节中,我们需要实现移动节点
移动节点指的是,移动一个虚拟节点所对应的真实DOM节点,而不是移动虚拟节点本身,所以我们要取得虚拟节点的引用
在此之前,我们已经将每一个虚拟节点对应的真实DOM元素存储在他的vnode.el属性中了,所以我们可以通过旧子节点的vnode.el属性取得它对应的真实DOM节点
当更新操作发生时,渲染器会调用patchElement函数在新旧虚拟节点之间进行打补丁,以下是patchElement函数:
function patchElement(n1, n2){
const el = n2.el = n1.el
//省略部分代码
}
可以看到在我们之前的代码中,我们将旧节点的n1.el属性赋值给新节点的n2.el属性,这个赋值语句的真正含义其实就是DOM元素的复用,让新节点也持有对真实DOM的引用
更新节点步骤(采用上一节第二个例子):
-
第一步:取新一组子节点中的第一个节点
p-3,通过key在旧的一组子节点中找到具有相同key值得可复用节点,该可复用节点在旧的一组子节点中的索引为2,此时变量lastIndex的值为0,由于2不小于0,所以只要更新lastIndex的值即可,不用移动DOM元素 -
第二步:取新的一组子节点中的第二个节点
p-1,通过key在旧的一组子节点中找到具有相同key值的可复用节点,该可复用节点在旧的一组子节点中的索引为0,此时变量lastIndex的值为2,由于0小于2,所以p-1对应的真实DOM元素需要移动到这里,我们发现
p-1对应的真实DOM需要移动,移动到节点p-3所对应的真实DOM后面因为新
children的顺序其实就是更新后真实DOM节点应有的顺序,所以节点p-1在新children中的位置就代表了真实DOM更新后的位置,即要将其放在p-3对应的真实DOM后面现在的真实DOM顺序为
p-2、p-3、p-1 -
第三步:取新的一组子节点中的第三个节点
p-2,通过key在旧的一组子节点中找到具有相同key值的可复用节点,该可复用节点在旧的一组子节点中的索引为1,此时变量lastIndex的值为2,由于1小于2,所以p-2对应的真实DOM元素需要移动移动的位置分析与
p-1类似,此时p-2就需要移动到节点p-1对应的真实DOM的后面了
上述分析过程的图例:
实现思路:
- 先获取当前元素在新子节点中的前一个元素
prevVNode - 如果不存在前一个元素的话则不需要移动
- 如果存在的话就要找到
prevVNode的实际DOM元素的下一个兄弟节点 - 用
insert方法将新节点对应的真实DOM插入到上面找到的兄弟节点前面,也就是prevVNode对应的真实DOM后面
代码实现其移动过程:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for(let i = 0; i < newChildren.length; i++){
const newVNode = newChildren[i]
for(let j = 0; j < oldChildren.length; j++){
const oldVNode = oldChildren[j]
if(newVNode.key === oldVNode.key){
patch(oldVNode, newVNode, container)
if(j < lastIndex){
//如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
//说明该节点对应真实的DOM需要移动
//需要先获取newVNode的前一个vnode
const prevVNode = newChildren[i - 1]
//如果prevVNode不存在,则说明newVNode是第一个节点,不需要移动
if(prevVNode){
//由于我们要将newVNode对应的真实DOM移动到prevVNode所对应的真实DOM后面
//所以需要获取prevVNode所对应的真实DOM的下一个兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling
//调用insert方法将newVNode对应的真实DOM插入到锚点元素之前
// 也就是prevVNode对应真实DOM后面
insert(newVNode.el, container, anchor)
}
}else{
//如果当前找到的节点在旧children中的索引不小于最大索引值,则更新lastIndex
lastIndex = j
}
//此处需要break
break
}
}
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
其中使用了insert函数,我们之前已经定义过了
insert(el, parent, anchor = null) {
//其中要传入锚点元素anchor,代表在这个元素之前插入
parent.insertBefore(el, anchor)
}
添加新元素
在之前的例子中,我们的新节点都能找到对于的旧节点,而现在我们考虑一种新的情况:
新的子节点不能在旧子节点中找到对应的节点,也就是添加新的节点到原本的DOM树上,所以我们在更新的时候应该正确的将他挂载,主要分为两步:
- 想办法找到新增节点
- 将新增节点挂载到正确位置
同样的,我们采用一个例子来深入理解一下:
现在我们来模拟一下简单Diff算法的更新逻辑:
- 第一步:取新子节点的第一个节点
p-3,它的key值为3,尝试在旧的一组子节点中寻找可复用的节点,能够找到对应的节点,该结点在旧的一组子节点中的索引值为2,而此时变量lastIndex的值为0,索引值2不小于0,所以不需要移动p-3的真实DOM元素,只需要将变量lastIndex更新为2即可 - 第二步:取新的一组子节点中的第二个节点
p-1,它的key值为1,在旧的一组子结点中寻找可复用的节点,可以找到对应的旧子节点,索引值为0,因为比当前的lastIndex的值小,所以需要移动该结点的位置,移动到节点p-3对应的真实DOM后面,经过这一步移动操作后,真实DOM的状态为p-2、p-3、p-1 - 第三步:取新的一组子节点中的第三个节点
p-4,他的key值为4,在旧的一组子结点中找不到可以复用的节点,所以就应该把节点p-4作为新增节点并挂载它,而挂载位置就应该挂载在目前节点p-1的后面,也就是挂载在p-1对应的真实DOM后面,经过这一步移动操作,真实DOM的状态为p-2、p-3、p-1、p-4 - 第四步:取新的一组子节点中的第四个节点
p-2,它的key值为2,在旧的一组子结点中寻找可复用的节点,可以找到对应的旧子节点,索引值为1,因为比当前的lastIndex的值小,所以需要移动该结点的位置,移动到节点p-4对应的真实DOM后面,经过这一步移动操作后,真实DOM的状态为p-2、p-3、p-1、p-4、p-2
目前的状态:
代码实现:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}
setElementText(container, n2.children)
}else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for(let i = 0; i < newChildren.length; i++){
const newVNode = newChildren[i]
//在第一层循环中定义变量find,代表是否在旧的一组子节点中找到可复用的节点
let find = false
for(let j = 0; j < oldChildren.length; j++){
const oldVNode = oldChildren[j]
if(newVNode.key === oldVNode.key){
//找到可复用的节点就将变量改为true
find = true
patch(oldVNode, newVNode, container)
if(j < lastIndex){
const prevVNode = newChildren[i - 1]
if(prevVNode){
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
}else{
lastIndex = j
}
break
}
}
//代码运行到这里则find仍然为false,则说明newVNode没有在旧的一组子节点中找到可复用的节点
//也就是说newVNode是新增节点,需要挂载
if(!find){
//为了将节点挂载到正确位置,我们需要先获取锚点元素
//首先获取当前newVNode的前一个vnode节点
const prevVNode = newChildren[i - 1]
let anchor = null
if(prevVNode){
//如果存在前一个vnode节点,则说明他的下一个兄弟元素作为锚点元素
anchor = prevVNode.el.nextSibling
}else{
//如果不存在前一个vnode节点,则说明即将挂载的新节点是第一个子节点
anchor = container.firstChild
}
//挂载newVNode
patch(null, newVNode, container, anchor)
}
}
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}else{
if(Array.isArray(n1.children)){
n1.children.forEach(c => unmount(c))
}else if(typeof n1.children === 'string'){
setElementText(container, '')
}
}
}
在上述代码中,我们使用了patch函数来挂载新节点,而现在的patch函数不支持传递第四个参数,所以需要调整一下patch代码
function patch(n1, n2, container, anchor){
//省略部分代码
if(typeof type === 'string'){
//首次挂载,直接递归调用mountElement函数
if (!n1) {
//挂载时将锚点元素作为第三个参数传递给mountElement函数
mountElement(n2, container, anchor)
} else {
//更新
patchElement(n1, n2)
}
}
//省略部分代码
}
//此处由于给挂载函数也传递了第三个参数,我们也要对其进行相应的调整
function mountElement(vnode, container, anchor) {
//省略部分代码
//再插入节点时,将锚点元素传给insert
insert(el, container, anchor)
}
移除不存在的元素
上一节中,我们探讨了遇到新增元素的情况,不仅如此,我们还会遇到元素被删除的情况
同样,我们来分析一个例子:
-
第一步:取新的子结点中的第一个节点
p-3,key值为3,能够在旧子节点中找到对应的节点,在旧子节点中的索引为2,此时变量lastIndex的值为0,由于2不小于0,所以不需要移动,只需要更新lastIndex变量 -
第二步:取新的子结点中的第二个节点
p-1,key值为1,能够在旧子节点中找到对应的节点,在旧子节点中的索引为0,此时变量lastIndex的值为2,由于0小于2,所以需要移动,将节点p-1对应的真实DOM移动到节点p-3对应的真实DOM后面 -
至此,更新已经结束,但是我们发现,节点
p-2对应真实DOM还存在,所以需要增加额外的逻辑来删除遗留节点思路:当更新结束的时候,需要遍历旧的一组子节点,然后取新的一组子结点中寻找相同
key值得节点,如果找不到则需要删除该节点:function patchChildren(n1, n2, container){ if(typeof n2.children === 'string'){ if(Array.isArray(n1.children)){ n1.children.forEach(c => unmount(c)) } setElementText(container, n2.children) }else if(Array.isArray(n2.children)){ const oldChildren = n1.children const newChildren = n2.children let lastIndex = 0 for(let i = 0; i < newChildren.length; i++){ const newVNode = newChildren[i] let find = false for(let j = 0; j < oldChildren.length; j++){ const oldVNode = oldChildren[j] if(newVNode.key === oldVNode.key){ find = true patch(oldVNode, newVNode, container) if(j < lastIndex){ const prevVNode = newChildren[i - 1] if(prevVNode){ const anchor = prevVNode.el.nextSibling insert(newVNode.el, container, anchor) } }else{ lastIndex = j } break } } if(!find){ const prevVNode = newChildren[i - 1] let anchor = null if(prevVNode){ anchor = prevVNode.el.nextSibling }else{ anchor = container.firstChild } patch(null, newVNode, container, anchor) } } //上一步更新操作完成后,需要遍历一次旧的子节点 for(let i = 0; i < oldChildren.length; i++){ const oldVNode = oldChildren[i] //拿旧子节点oldVNode去新的一组子结点中寻找具有相同key值得节点 const has = newChildren.find( vnode => vnode.key === oldVNode.key ) //如果没有找到则说明需要删除该节点,调用unmount将其卸载 if(!has) unmount(oldVNode) } }else{ setElementText(container, '') n2.children.forEach(c => patch(null, c, container)) } }else{ if(Array.isArray(n1.children)){ n1.children.forEach(c => unmount(c)) }else if(typeof n1.children === 'string'){ setElementText(container, '') } } }