学习Snabbdom库源码,掌握Vue核心的Virtual DOM的实现原理(下)

本文深入探讨Snabbdom库,讲解其核心函数h()、vnode()以及patch()的工作原理。通过分析源码,理解Virtual DOM的创建、更新过程,从而揭示Vue.js中Virtual DOM的实现细节。文章详述了h()函数的重载处理,vnode()用于生成虚拟节点,以及patch()如何通过diff算法找到新旧vNode的差异并更新DOM。
摘要由CSDN通过智能技术生成

想了解Snabbdom基础请看上一篇博客。

1.Snabbdom的核心
  • 使用h()函数创建vNode对象描述真实DOM
  • init()设置模块,创建并返回patch()
  • patch()比较新旧两个vNode,更新新内容到真实DOM

源码通常比较难理解,我们不可能完全阅读源码,应该有个重点目标,比如:

  • vNode是如何创建的
  • vNode是如何渲染成真实DOM…
2. 分析 h 函数

在Vue项目的main.js中也看见过h(),只是升级了Snabbdon的h().

在上一篇博客,我就说过了h()是很多形态的,即函数重载,参数的类型或个数,解决了调用哪个h()。

看h()源码:(重点注释都已标注出来)

//h函数的重载(有四种形态)
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

//无论是哪种形态,最终都会执行这个h(),因为这里的第二第三个参数都使用了"?",作为了可选参数,可以匹配上面四种状态
export function h (sel: any, b?: any, c?: any): VNode {
 //变量data是一个自定义的VNodeData类型
  var data: VNodeData = {}
  //用于记录该节点的子节点
  var children: any
  //用于记录该结点的文本内容
  var text: any
  var i: number

  //下面一系列的判断是给新建的vNode这个对象初始化
  //先判断c是否有值,c有值说明传了3个参数
  //有三个参数情况:sel、data、children或text字符串
  //对应上面的第四种h()重载形态:h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
  if (c !== undefined) {
    //如果b不为null,那么b就是VNodeData类型的数据
    if (b !== null) {
      data = b
    }
    //如果c是一个数组,那么数组里面的元素就是该vNode的子vNode
    if (is.array(c)) {
      children = c
      //判断c是不是字符串或数字
    } else if (is.primitive(c)) {
      text = c
      //如果不是字符串或数字,那就判断c是不是一个单一的vNode
    } else if (c && c.sel) {
      //把单一的vNode也变成数组类型,便于children统一处理,因为多个vNode就是用数组存储的
      children = [c]
    }

    //下面就是两个参数的情况,即c未定义
    //对应h()的第二、第三种重载形态
  } else if (b !== undefined && b !== null) {
    //如果b是数组,那么就是第三种形态:h (sel: string, children: VNodeChildren): VNode
    if (is.array(b)) {
      children = b
    //如果b是字符串或数字,就是第二种形态:h (sel: string, data: VNodeData | null): VNode
    } else if (is.primitive(b)) {
      text = b
    } else if (b && b.sel) {
      children = [b]
    } else { data = b }
  }

  //如果children在上面被初始化了,就说明vNode有子节点,那就每个子节点要调用vnode()去生成对应的vNode
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i])) 
            //每个子节点生成相对应的子vNode
            children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
    }
  }

  //如果选择器是svg类型,那就是svg格式图片,调用addNS给它空间
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    addNS(data, children, sel)
  }

//返回由传进来的参数生成的vNode,作为下次对比Vnode的旧虚拟节点
  return vnode(sel, data, children, text, undefined)
};

这段源码复杂的地方是h()函数的重载,它有两个可选参数,这两个可选参数根据参数类型决定存储的内容。

比如第三个参数:

  • c如果是字符串,那么它是该节点的内容
  • c如果是数组,那么它就是该结点的子虚拟节点

注意:h()主要是负责初始化text、children、data,然后利用这些数据,再调用vnode()生成vNode节点。

我们看看上面data的接口类型,即VNodeData:

export interface VNodeData {
  props?: Props
  attrs?: Attrs
  class?: Classes
  style?: VNodeStyle
  dataset?: Dataset
  on?: On
  hero?: Hero
  attachData?: AttachData
  hook?: Hooks
  key?: Key
  ns?: string // for SVGs
  fn?: () => VNode // for thunks
  args?: any[] // for thunks
  [key: string]: any // for any other 3rd party module
}

所以data对象可以传递一些props、attrs、on等等属性。

3. 分析vnode函数

上面我们说过h函数在初始化好数据后,是调用vnode函数去生成虚拟节点的,下面我们就看看vnode函数。

export function vnode (sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined): VNode {
	  const key = data === undefined ? undefined : data.key
	  return { sel, data, children, text, elm, key }
}

这个函数非常简单,就是返回一个JS对象,所以虚拟节点就是一个JS的对象而已。

到这里,我们已经了解了vNode是如何创建的了

4. 分析 patch 函数

下面这部分学习就是关于vNode如何渲染成真实DOM的,这部分比上面更加复杂。patch是重点,通过patch实现对比新旧vNode的差异,进而更新DOM.

看源码:

function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode)
    }

    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      elm = oldVnode.elm!
      parent = api.parentNode(elm) as Node

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

分析后的源码:

function patch (oldVnode: VNode | Element, vnode: VNode): VNode {

  let i: number;

  //ele就是一个真实的DOM,在虚拟vNode中,就是利用ele属性记录着vNode对应哪个真实DOM的
  let elm: Node;

  //待会用于记录旧节点的父节点
  let parent: Node;

  //一个数组,存储着很多vNode
  const insertedVnodeQueue: VNodeQueue = []

  //通过for循环,将对比前需要调用的钩子函数都调用
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

  //判断是否是虚拟节点,如果不是,则调用emptyNodeAt(),该函数内部调用vnode()去生成虚拟节点并返回
  if (!isVnode(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode)
  }

  //判断新旧节点是不是相同的节点,利用key和sel属性判断,两者都相等,则是相同节点
  if (sameVnode(oldVnode, vnode)) {
    //如果相同,就调用patchVnode()去找出两者的差异
    patchVnode(oldVnode, vnode, insertedVnodeQueue)
  } else {
    //如果不相同,则获取旧虚拟节点的真实DOM,通过DOM找出它的父元素
    elm = oldVnode.elm!
    parent = api.parentNode(elm) as Node

    //通过新的vnode,调用createEle()去创建真实的DOM,此时页面还没有显示该元素
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      //将新的DOM利用appendChild(),插入到父元素中,此时页面显示该元素
      api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
      //移除父元素里面的那个老节点
      removeVnodes(parent, [oldVnode], 0, 0)
    }
  }

  //执行用户设置的insert钩子函数
  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

}

上面调用了createEle()、patchVnode()、removeVnodes()比较复杂,我们下面再分析。

path的工作流程大致如下:

  • 对比新旧vNode是否是相同的节点(节点的key和sel相同,则相同)
  • 如果不是相同节点,则把新节点绑定到旧节点的父节点上,然后删除旧节点
  • 如果是相同的节点,再判断vNode是否又text,如果有并且和oldVNode的text不同,直接更新文本内容
  • 如果新的vNode还有children,判断子节点是否有变化,判断过程使用diff算法,diff过程只能进行同层级比较,即相互对比的是同一层的子节点。
5.分析createEle函数

该函数主要根据vnode创建真实的DOM。

看源码:

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      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) {
      // Parse selector
      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
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag)
        : api.createElement(tag)
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      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)
      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))
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!)
    }
    return vnode.elm
  }

分析源码:

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  let i: any
  let data = vnode.data

  //这个if主要是执行init函数,调用钩子函数
  //data是调用h()函数传入的第二个参数
  if (data !== undefined) {
    const init = data.hook?.init
    if (isDef(init)) {
      init(vnode)
      data = vnode.data
    }
  }

  //把vnode转换成真实DOM。(没有渲染到页面)
  const children = vnode.children
  const sel = vnode.sel
  //sel是元素的选择器
  //如果sel是"!",则说明是创建一个“注释”节点
  if (sel === '!') {
    if (isUndef(vnode.text)) {
      vnode.text = ''
    }
    //api.createComment()内部就是创建了注释节点
    vnode.elm = api.createComment(vnode.text!)
    
    //下面是选择器不为空
  } else if (sel !== undefined) {
    // 下面是解析sel是什么标签以及类型、id
    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
    
    //根据上面分析出来的标签创建DOM
    //ns是命名空间,通常是svg
    const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
      ? api.createElementNS(i, tag)
      : api.createElement(tag)
	//api.createElement(tag)本质就是调用document.createElement(tag)创建dom
    //给节点添加id、class
    if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
    if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
    
    //cbs记录的是钩子函数,执行
    for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
    
    //如果children是数组,那么就是子节点,递归调用createElm,生成子节点DOM,并插入该节点中
    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))
        }
      }
      //不是数组,则判断是不是字符串,是则创建文本节点,并插入
    } else if (is.primitive(vnode.text)) {
      api.appendChild(elm, api.createTextNode(vnode.text))
    }
    const hook = vnode.data!.hook
    if (isDef(hook)) {
      hook.create?.(emptyNode, vnode)
      if (hook.insert) {
      //把vnode添加到队列中,为后续执行insert钩子做准备
        insertedVnodeQueue.push(vnode)
      }
    }
  } else {
    //如果sel选择器为空,则创建文本节点
    vnode.elm = api.createTextNode(vnode.text!)
  }
  //返回真实DOM
  return vnode.elm
}

分析:

  • 创建的节点有三种类型,一种是普通的标签,一种是注释标签,还有一种是文本标签
  • 根据选择器类型来判断创建不同的标签。最复杂的就是创建普通标签,需要给元素添加class、id,以及判断是否还有子节点。
6. 分析patchVnode函数

该函数是用来对比新旧vNode的差异的。

先来看看patchVnode的流程:
在这里插入图片描述
上面流程图很清晰的表明了如何对比新旧节点的差异。

看源码:

  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)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      api.setTextContent(elm, vnode.text!)
    }
    hook?.postpatch?.(oldVnode, vnode)
  }

分析源码:

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {

  //获取钩子函数
  const hook = vnode.data?.hook
  hook?.prepatch?.(oldVnode, vnode)
  //获取DOM,因为两个vNode是相同的,所以两者的elm指向同一个DOM
  const elm = vnode.elm = oldVnode.elm!

  //获取新旧节点的子节点,两者可能就是子节点发生了变化,页面需要更新
  const oldCh = oldVnode.children as VNode[]
  const ch = vnode.children as VNode[]

  //如果新旧节点相同,即没有发生变化,则直接返回即可
  if (oldVnode === vnode) return

 //如果新的vNode的data是有定义的,那么就触发update钩子函数
  if (vnode.data !== undefined) {
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    vnode.data.hook?.update?.(oldVnode, vnode)
  }

  //新的vnode没有定义文本
  if (isUndef(vnode.text)) {
    //如果两者都有子元素
    if (isDef(oldCh) && isDef(ch)) {
      //且两者的子元素不相同,则更新元素中的自己欸但
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)

      //如果只是新的vNode有子节点
    } else if (isDef(ch)) {
      //且旧节点有text,则清空text后再将子节点田家庵进去
      if (isDef(oldVnode.text)) api.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)

      //如果旧节点有子节点,则旧节点的子节点
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)

      //如果旧节点有text,则也要清除text
    } else if (isDef(oldVnode.text)) {
      api.setTextContent(elm, '')
    }
    
    //如果定义了文本,就对比新旧节点文本是否相同
  } else if (oldVnode.text !== vnode.text) {
    //新旧文本不相同,则需要清空旧节点的子元素
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    }
    //清空后,将新vNode的文本放入元素中
    api.setTextContent(elm, vnode.text!)
  }
  hook?.postpatch?.(oldVnode, vnode)
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值