snabbdom Diff算法学习

记一波简单的 diff 算法学习,我是基于 https://github.com/snabbdom/snabbdom学习的,各个框架的比较算法不太一样,但是总体流程类似。
本篇参考了 https://segmentfault.com/a/1190000017494569 这个文章,我的代码是最新拉取的,和作者展示的略有不同。
(注:旧的入口 snabbdom.js 名字换成了init.ts)

问题引导: 为什么v-for 的时候要传key

  • 大部分的答案是直接告诉你可以增加效率,我稍微看了如下的两个核心函数才有点感觉。

其实主要是 vnode 对比,然后移动旧节点比创建一个新的节点效率高。然后就是 key 不能为每次都生成的 js 随机数和 index,随机数的话和没传一样,起不到对比作用,index 会动态改变会产生一些不可预料的结果(比如我们在 data 中删除节点3,界面上却把节点2删除了)。
比较绝的是 vue3 的 render 还给每个模板的数据绑定的位置加了 key,拥有了针对性之后跳过了很多 vnode 对比,所以性能提升很多。再加上 vite 的按需引入,开发效率 upup。

流程引导:官网给了一个示例,我们可以跟着示例的顺序去查看对应的内容
List item

提示: 把下面的代码复制到 js 中,最好安装一个叫Better Comments 的vscode插件,我的注释就可以高亮显示了
在这里插入图片描述

代码片段来源:src/h.ts

import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'

export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>

function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {
  data.ns = 'http://www.w3.org/2000/svg'
  if (sel !== 'foreignObject' && children !== undefined) {
    for (let i = 0; i < children.length; ++i) {
      const childData = children[i].data
      if (childData !== undefined) {
        addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
      }
    }
  }
}

/**
 *
 * @param sel 选择器,类似jquery
 * @param b    数据
 * @param c    子节点
 * @returns {{sel, data, children, text, elm, key}}
 */
// todo h函数的主要工1作就是把传入的参数封装为vnode
// todo 在调用之前会对数据进行一个处理:是否含有数据,是否含有子节点,子节点类型的判断等
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}
  var children: any
  var text: any
  var i: number
  // todo 如果childNode存在,则其为子节点
  if (c !== undefined) {
    if (b !== null) {
      // todo 则h的第二项b就是data
      data = b
    }
    // todo 如果子节点是数组,则存在子element节点
    if (is.array(c)) {
      children = c
      // todo 否则子节点为text节点
    } else if (is.primitive(c)) {
      text = c
    } else if (c && c.sel) {
      children = [c]
    }
  // todo 如果只有 b数据 存在,childNode不存在,则b有可能是子节点也有可能是数据
  } else if (b !== undefined && b !== null) {
    // todo 数组代表子element节点
    if (is.array(b)) {
      children = b
      // todo 代表子文本节点
    } else if (is.primitive(b)) {
      text = b
    } else if (b && b.sel) {
      children = [b]
      // todo 剩下的就是数据了
    } else { data = b }
  }
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      // todo 如果子节点数组中,存在节点是原始类型,说明该节点是text节点,因此我们将它渲染为一个只包含text的VNode
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
    }
  }
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    addNS(data, children, sel)
  }
  // todo 返回 Vnode
  return vnode(sel, data, children, text, undefined)
};

代码片段来源:src/init.ts
核心顺序是:从最后面的 patch() 函数入手 -> patchVnode() -> updateChildren(),中间会夹杂各个小函数,当然直接去跟着我参考的那个文章比较容易理解,我只是把部分注释搬移了过来。

查看核心的update 算法函数之前,最好看一下这个算法的解析:http://qiutianaimeili.com/html/page/2018/05/4si69yn4stl.html


// todo 对比函数 如果不传 key 结果就是true,就会去对比 sel
import { Module } from './modules/module'
import { vnode, VNode } from './vnode'
import * as is from './is'
import { htmlDomApi, DOMAPI } from './htmldomapi'

type NonUndefined<T> = T extends undefined ? never : T

function isUndef (s: any): boolean {
  return s === undefined
}
function isDef<A> (s: A): s is NonUndefined<A> {
  return s !== undefined
}

type VNodeQueue = VNode[]

const emptyNode = vnode('', {}, [], undefined, undefined)

// todo 如果不传 key 结果就是true,就会去对比 sel
/*
  * 如果新旧vnode的key和sel都相同,说明两个vnode相似,我们就可以保留旧的vnode节点,
  * 再具体去比较其差异性,在旧的vnode上进行'打补丁',否则直接替换节点
  * 
  * 这里需要说的是如果不定义key值,则这个值就为undefined,undefined===undefined //true
  * 所以平时在用vue的时候,在没有用v-for渲染的组件的条件下,是不需要定义key值的,不会影响其比较
 */
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

function isVnode (vnode: any): vnode is VNode {
  return vnode.sel !== undefined
}

type KeyToIndexMap = {[key: string]: number}

type ArraysOf<T> = {
  [K in keyof T]: Array<T[K]>;
}

type ModuleHooks = ArraysOf<Required<Module>>

function createKeyToOldIdx (children: VNode[], beginIdx: number, endIdx: number): KeyToIndexMap {
  const map: KeyToIndexMap = {}
  for (let i = beginIdx; i <= endIdx; ++i) {
    const key = children[i]?.key
    if (key !== undefined) {
      map[key] = i
    }
  }
  return map
}

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }

  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }

  /*
    todo 用来将一个真实的无子节点的DOM节点转化成vnode形式,
    * 如:<div id='a' class='b c'></div>
    * 将转换为{sel:'div#a.b.c',data:{},children:[],text:undefined,elm:<div id='a' class='b c'>}
   */
  function emptyNodeAt (elm: Element) {
    const id = elm.id ? '#' + elm.id : ''
    const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
    return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
  }

  /*
   * remove一个vnode时,会触发remove钩子作拦截器,只有在所有remove钩子
   * 回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操作的计数功能
  */
  function createRmCb (childElm: Node, listeners: number) {
    return function rmCb () {
      if (--listeners === 0) {
        const parent = api.parentNode(childElm) as Node
        api.removeChild(parent, childElm)
      }
    }
  }

  // todo //将vnode创建为真实dom
  // * 创建vnode对应的真实dom,并将其赋值给vnode.elm,后续对于dom的修改都是在这个值上进行
  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      // todo hook 钩子
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode)
        data = vnode.data
      }
    }
    const children = vnode.children
    const sel = vnode.sel
    if (sel === '!') {
      if (isUndef(vnode.text)) {
        vnode.text = ''
      }
      vnode.elm = api.createComment(vnode.text!)
    } else if (sel !== undefined) {
      // todo 解析sel参数
      // * 例如div#divId.divClass ==> id="divId"  class="divClass"
      const hashIdx = sel.indexOf('#')
      const dotIdx = sel.indexOf('.', hashIdx)
      const hash = hashIdx > 0 ? hashIdx : sel.length
      const dot = dotIdx > 0 ? dotIdx : sel.length
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
      // todo 创建一个DOM节点引用,并对其属性实例化
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag)
        : api.createElement(tag)
      // todo 获取id名 #a --> a
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      // todo 获取类名,并格式化  .a.b --> a b
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
      // *如果存在子元素Vnode节点,则递归将子元素节点插入到当前Vnode节点中,
      // *并将已插入的子元素节点在insertedVnodeQueue中作记录
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i]
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
          }
        }
      // todo 如果存在子文本节点,则直接将其插入到当前Vnode节点
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
      // todo create钩子回调
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
          insertedVnodeQueue.push(vnode)
        }
      }
    // todo 如果没声明选择器,则说明这个是一个text节点
    } else {
      vnode.elm = api.createTextNode(vnode.text!)
    }
    return vnode.elm
  }

  // todo 将vnode转换后的dom节点插入到dom树的指定位置中去
  function addVnodes (
    parentElm: Node,
    before: Node | null,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number,
    insertedVnodeQueue: VNodeQueue
  ) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
      }
    }
  }

  // todo 手动触发destory钩子回调
  /*
    * 先调用vnode上的destory
    * 再调用全局下的destory
    * 递归调用子vnode的destory
  */
  function invokeDestroyHook (vnode: VNode) {
    const data = vnode.data
    if (data !== undefined) {
      // todo 调用自身的destroy钩子
      // * 原来的写法  if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
      data?.hook?.destroy?.(vnode)
      // todo 调用全局destroy钩子
      for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
      if (vnode.children !== undefined) {
        for (let j = 0; j < vnode.children.length; ++j) {
          const child = vnode.children[j]
          if (child != null && typeof child !== 'string') {
            invokeDestroyHook(child)
          }
        }
      }
    }
  }

  // todo 批量删除DOM节点
  /*
    * @param parentElm 父节点
    * @param vnodes  删除节点数组
    * @param startIdx  删除起始坐标
    * @param endIdx  删除结束坐标
  */
  function removeVnodes (parentElm: Node,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number): void {
    for (; startIdx <= endIdx; ++startIdx) {
      let listeners: number
      let rm: () => void
      // todo ch代表子节点
      const ch = vnodes[startIdx]
      if (ch != null) {
        if (isDef(ch.sel)) {
          // todo 调用 destroy 钩子
          invokeDestroyHook(ch)
          // todo 对全局remove钩子进行计数
          listeners = cbs.remove.length + 1
          rm = createRmCb(ch.elm!, listeners)
          // todo 调用全局remove回调函数,并每次减少一个remove钩子计数
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
          const removeHook = ch?.data?.hook?.remove
          // todo 调用内部vnode.data.hook中的remove钩子(只有一个)
          if (isDef(removeHook)) {
            removeHook(ch, rm)
          } else {
            // todo 如果没有内部remove钩子,需要调用rm,确保能够remove节点
            rm()
          }
        } else { // Text node
          api.removeChild(parentElm, ch.elm!)
        }
      }
    }
  }

  // todo 更新函数 diff渲染 http://qiutianaimeili.com/html/page/2018/05/4si69yn4stl.html
  // * snabbdom的对比策略是针对同层级的节点进行对比 广度优先遍历
  function updateChildren (parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue) {
    // todo 定义长度,两边逐渐往中间移动,两个都重合时候就结束
    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 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]

      // todo 旧开始和新开始对比
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]

      // todo 旧结束和新结束对比
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]

      // todo 旧开始和新结束对比
      } 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]

      // todo 旧结束和新开始对比
      } 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]

      // todo 都未命中
      } else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        }
        // todo 当前的key,能否对应上某个节点的key
        idxInOld = oldKeyToIdx[newStartVnode.key as string]
        
        // todo 没对上可能是新的,直接插入就好了
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)

        // todo 对应上 
        } else {
          // todo 拿到 key 的节点
          elmToMove = oldCh[idxInOld]
          // todo sel 是否相等 (参考 sameVnode 的条件)
          if (elmToMove.sel !== newStartVnode.sel) {
            // todo 白搭和新的一样处理
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
          } else {
            // todo 开始递归, 如果还存在其他子节点则调用补丁patchVnode, 然后继续 updateChildren
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined as any
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
      }
    }
  }

  // todo 如果两个vnode相似,则会对具体的vnode进行‘打补丁’的操作
  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // todo 执行生命周期钩子
    const hook = vnode.data?.hook
    hook?.prepatch?.(oldVnode, vnode)

    // todo 设置 vnode.elm 直接复制旧的
    const elm = vnode.elm = oldVnode.elm!

    // todo 旧的 children
    const oldCh = oldVnode.children as VNode[]
    
    // todo 新的 children
    const ch = vnode.children as VNode[]
    // todo 如果oldnode和vnode的引用相同,说明没发生任何变化直接返回,避免性能浪费
    if (oldVnode === vnode) return
    // todo hook 相关
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // todo 新的 vnode.text === undefined 一般意味着 vnode.children != undefined
    // !ps:如果自身存在文本节点,则不存在子节点 即:有text则不会存在ch,反之亦然
     /*
      分情况讨论节点的更新: new代表新Vnode old代表旧Vnode
      ps:如果自身存在文本节点,则不存在子节点 即:有text则不会存在ch,反之亦然

      1 new不为文本节点
          1.1 new不为文本节点,new还存在子节点    
            1.1.1 new不为文本节点,new还存在子节点,old有子节点
            1.1.2 new不为文本节点,new还存在子节点,old没有子节点
              1.1.2.1 new不为文本节点,new还存在子节点,old没有子节点,old为文本节点

        1.2 new不为文本节点,new不存在子节点
          1.2.1 new不为文本节点,new不存在子节点,old存在子节点
          1.2.2 new不为文本节点,new不存在子节点,old为文本节点

      2.new为文本节点
          2.1 new为文本节点,并且old与new的文本节点不相等
          ps:这里只需要讨论这一种情况,因为如果old存在子节点,那么文本节点text为undefined,则与new的text不相等
          直接node.textContent即可清楚old存在的子节点。若old存在子节点,且相等则无需修改
    */
    // 1
    if (isUndef(vnode.text)) {
      // todo 新旧都有 children
      // 1.1.1
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
        // todo 新 children 有值,旧 children 无 (旧 text 有)
      // 1.1.2
      } else if (isDef(ch)) {
        // todo 先清空旧的 text
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        // todo 并添加vnode的children
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        // 1.2.1
        // todo 如果oldvnode有children,而vnode没children,则移除elm的children
      } else if (isDef(oldCh)) {
        // todo 移除旧的
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      //1.2.2
      // todo 如果vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, '')
      }
      // * else 直接意味着:vnode.text !== undefined 一般意味着 vnode.children 无值
      // todo ↓ 新的 text 有值,旧的 text 和我还不一样,直接干掉
      // 2.1
    } else if (oldVnode.text !== vnode.text) {
      // todo 移除旧的
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      // todo 设置新的 text
      api.setTextContent(elm, vnode.text!)
    }
    // todo 触发postpatch钩子
    hook?.postpatch?.(oldVnode, vnode)
  }

  // todo 入口函数
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    // todo 执行 pre hooks
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

    // todo 第一个参数不是 vnode,就创建一个空的 vnode,关联到这个 DOM 元素
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode)
    }
    
    // todo 判断相同的 vnode
    if (sameVnode(oldVnode, vnode)) {
      // todo vnode 对比
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // todo 不同的 vnode 直接销毁重建
      elm = oldVnode.elm!
      parent = api.parentNode(elm) as Node

      // todo 重建
      createElm(vnode, insertedVnodeQueue)

      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
  }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ldz_miantiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值