snabbdom的diff算法

246 阅读5分钟

snabbdom的diff算法

1、首先阐述个人对diff算法的理解:

diff算法是==比较两个vnode(虚拟dom)的不同,将其中旧vnode所描述真实dom更新成新vnode所描述的样子==,目的是减少创建dom所进行的开销。

==为什么这么做?==

1、 首先知道 在一个组件里如果涉及到更新页面数据的时候, 我们最简单的方法当然是直接替换dom元素。在写jQuery年代大家都是如此做的。

2、 其次,明白vue是响应式,我们写vue的数据一旦做出修改,会触发页面修改。我们能想到最理想的方式当然是哪里修改了,可以精确定位到改哪里。但是我们无法做到精确定位到你想修改的地方。

==如果是我的话,我会怎么做?== 也可以理解为没有vnode,我们会怎么做。

我能想到笨方法当然是 根据template重新创建新dom 进行整体替换。 这样当然可以实现效果,但是当组件足够大,而你改动内容却很少,那么进行的创建开销就很大了。 如果你改动内容多,那你的创建内容可能确实需要那么大开销,但是大多数时候,我们修改的内容可能只是一个span内容,一个变量。

==3、 引入diff后呢,我们得到了什么。
== diff 实际上比较新旧vnode,将旧vnode所描述的dom树更新成新vnode所描述的样子,当改动的内容足够少,那么对dom操作肯定也足够小。当然这其中也涉及到了如果改动特别那么大,我们可能白白浪费时间diff两个vnode那么久,还不如创建新dom来的划算。这里既然引入了这个算法,那么官方肯定是认定我们平常写代码,对dom操作更新的内容没有那么多。

结论: 也就是当修改dom内容的时候引入diff可以减少创建dom的时间开销

2、diff过程理解
  1. 我们更新dom会更新什么,web平台上如果标签(div、span等)相同我们需要比较属性、class、style、监听事件,如果标签不一样,那么就是需要重新创建dom。
  2. 根据内容 diff 会走三步 patch => patchVnode => updateChildren,我梳理了一下每步做的事情

3、以下是对查看下面代码的建议:

1、一般diff算法过程是 patch => patchVnode => updateChildren,无论是snabbdom还是vue的源码都是这样的。所以学习的的时候可以先参考snabbdom做学习,直接看vue可能看不懂,毕竟vue涉及的很多钩子的执行,可能让你看不懂

2、建议理解代码的时候把代码沾到vscode在看,在markdown下看其实不是很美观

patch方法:

==patch (oldVnode, vnode)== 很容易看出是需要传入旧vnode和新vnode

该方法为起始方法,也为diff方法,怎么理解,初始化的时候我们只有一个vnode,那么只需要根据vnode直接创建dom树然后进行挂载就行了,此时oldvnode传进去的可能只是div标签,然后再这个标签内容上进行插入操作

那如果是diff 的时候 那就是需要比较了,如何判断是创建dom还是更新dom只需要比较 它们的标签是否相同

相同会走到下一步patchVnode


function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
	let i: number, elm: Node, parent: Node

	const insertedVnodeQueue: VNodeQueue = [] // 其实储存起来有从来执行mounted钩子的,这样才能保证子组件的挂载先执行
	
	for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() // 钩子执行忽略

	if (!isVnode(oldVnode)) { // 初始化时是dom元素而不是vnode
		oldVnode = emptyNodeAt(oldVnode) // 旧节点非vnode,创建
	}

	if (sameVnode(oldVnode, vnode)) {  // key值和ele值相同可以直接patchVnode
		patchVnode(oldVnode, vnode, insertedVnodeQueue)
	} else {
		elm = oldVnode.elm! // ! typescript的断言 表示必存在
		parent = api.parentNode(elm) as Node // 获取父的dom

		createElm(vnode, insertedVnodeQueue) // 根据vnode创建dom

		if (parent !== null) {
			api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) // 插入
			removeVnodes(parent, [oldVnode], 0, 0) // 移除旧元素
		}
	}

	for (i = 0; i < insertedVnodeQueue.length; ++i) { // 执行钩子 忽视别看
		insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
	}
	for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
	return vnode
}

// 比较key值 和 标签
// 如果没有key值 undefined === undefined 为true
// 一般只有for循环才会指定key值
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

  1. 当前标签进行属性更新、事件更新等
  2. 如果双方有children则进行 updateChildren 方法。(毕竟子dom一多,比较其他麻烦)
  3. 否则一方没有孩子,只是进行直接创建,或者删除dom的操作,又或者是文本更新
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {

  const hook = vnode.data?.hook
	hook?.prepatch?.(oldVnode, vnode)
	
  const elm = vnode.elm = oldVnode.elm!
  const oldCh = oldVnode.children as VNode[] // 旧孩子
	const ch = vnode.children as VNode[] // 新孩子
	
	if (oldVnode === vnode) return // 相同返回
	
  if (vnode.data !== undefined) { // 更新属性
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    vnode.data.hook?.update?.(oldVnode, vnode)
	}
	
	if (isUndef(vnode.text)) { // vnode非文本元素

    if (isDef(oldCh) && isDef(ch)) { // 都有children,只有非文本元素才有
			if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) // 更新children
			
    } else if (isDef(ch)) {  // 只有新vnode有children
      if (isDef(oldVnode.text)) api.setTextContent(elm, '')
			addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)  
			
    } else if (isDef(oldCh)) { // 只有旧vnode有children
			removeVnodes(elm, oldCh, 0, oldCh.length - 1)
			
    } else if (isDef(oldVnode.text)) { // 都没有children,设置dom内容为空文本
      api.setTextContent(elm, '')
		}
		
  } else if (oldVnode.text !== vnode.text) { // vnode是文本元素
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 删除dom上的children
    }
    api.setTextContent(elm, vnode.text!) // 设置文本内容
  }
  hook?.postpatch?.(oldVnode, vnode)
}


updatechildren

1、updatechildren 理解比较吃力,我建议去看个up主视频, 写起来麻烦 bilibili: (完整版)快速掌握虚拟DOM和diff算法【Vue】

2、但总体理解上就是将dom树更新成新vnode的样子,因为可能是一样的内容不做处理,也可能是位置变更,我们做移动操作,新增元素做创建,不存在元素做删除。

3、vue2在diff上原理上是一样,vue3在children上的diff比较有大的差别,但这里据说vue2是参考snabbdom写的,实际上确实很相似,但经常听同事说vue2引入snabbdom去做的diff,那就是错误的了。

function updateChildren (parentElm: Node,
  oldCh: VNode[],
  newCh: VNode[],
  insertedVnodeQueue: VNodeQueue) {
  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: KeyToIndexMap | undefined
  let idxInOld: number
  let elmToMove: VNode
  let before: any

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {

    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx]

      // 如果具有相同的tag和key 开始和开始
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      //递归调整之下的节点
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
      //结尾和结尾
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
      // 开始和结尾
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]

    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]

    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      idxInOld = oldKeyToIdx[newStartVnode.key as string]
      if (isUndef(idxInOld)) { // New element // 创建新的节点插到老节点起始的前面
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
      } else {
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          //如果tab不一样 一样插到老节点起始的前面
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
          // 递归将节点调整成一样
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined as any // 将原来的置为 undefined
          // 将节点插入
          // 将该节点节点插到老节点起始的前面
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
        }
      }
      // 只移动新结点的开始
      newStartVnode = newCh[++newStartIdx]
    }
  }
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // 旧的节点遍历完了加入新vode数组剩余的
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else {
      // 新节点遍历完了移除旧的
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
}