Virtual DOM

Virtual DOM

  • Virtual DOM(虚拟DOM)。是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫Virtual DOM
  • Virtual DOM的好处是当状态发生改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清楚如何有效(diff)的更新DOM
  • 虚拟DOM可以维护程序的状态,跟踪上一次的状态
  • 通过比较前后两次状态的差异更新真实DOM
虚拟DOM的作用
  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 除了渲染DOM以外,还可以实现SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
虚拟DOM库
  • Snabbdom
    • Vue2.x内部使用的Virtual DOM就是改造的Snabbdom
    • 大约200SLOC(single line of code)
    • 通过模块可扩展(类似插件的使用)
    • 源码使用TypeScript开发
    • 最快的Virtual DOM之一
  • virtual-dom

Snabbdom 基本使用

import { h, init } from 'snabbdom'

// 1. hello world
// 参数:数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM,并返回一个虚拟dom
let patch = init([]) // init初始化一个path函数
// 第一个参数:标签+选择器,如果需要包含类选择器后面加.cls
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h('div#container.cls', { 
  hook: {
    init (vnode) {
      console.log(vnode.elm)
    },
    create (emptyVnode, vnode) {
      console.log(vnode.elm)
    }
  }
}, 'Hello World')

let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
// 第二个参数:VNode
// 返回值:VNde
let oldVnode = patch(app, vnode)

// 假设的时刻
vnode = h('div', 'Hello Snabbdom')

patch(oldVnode, vnode)

// 2. div中放置子元素 h1,p
import { h, init } from 'snabbdom'

let patch = init([])

let vnode = h('div#container', [
  h('h1', 'Hello Snabbdom'),
  h('p', '这是一个p标签')
])

let app = document.querySelector('#app')

let oldVnode = patch(app, vnode)

setTimeout(() => {
  vnode = h('div#container', [
    h('h1', 'Hello World'),
    h('p', 'Hello P')
  ])
  patch(oldVnode, vnode)

  // 清空页面元素 -- 错误
  // patch(oldVnode, null)
  patch(oldVnode, h('!'))
}, 2000);

Snabbdom 模块

Snabbdom的核心库并不能处理元素的属性、样式、事件等,如果需要处理的话,可以使用模块

常用模块
  • 官方提供了6个模块
    • attributes
      • 设置了DOM元素的属性,使用setAttribute()
      • 处理布尔类型的属性(如:selected)
    • props
      • 和attributes模块相似,设置DOM元素的属性 element[attr] = value
      • 不处理布尔类型的属性
    • class
      • 切换类样式
      • 注意L给元素设置样式是通过sel选择器
    • dataset
      • 设置data-*的自定义属性
    • eventlisteners
      • 注册和移除事件
    • style
      • 设置行内样式。支持动画
      • delayed/remove/destroy
模块使用
import { init, h } from 'snabbdom'
// 1. 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 2. 注册模块
let patch = init([
  style,
  eventlisteners
])
// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
  style: {
    backgroundColor: 'red'
  },
  on: {
    click: eventHandler
  }
}, [
  h('h1', 'Hello Snabbdom'),
  h('p', '这是p标签')
])
function eventHandler () {
  console.log('点击我了')
}
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)
vnode = h('div', 'hello')
patch(oldVnode, vnode)

Snabbdom的核心

  • 使用h()函数创建Javascript对象(VNode)描述真实DOM
  • init()设置模块,创建patch()
  • patch()比较新旧两个VNode
  • 把变化的内容更新到真实DOM树上

Snabbdom 源码解析

源码地址
源码目录结构
example文件夹
example文件夹
src文件夹
src文件夹

h函数

作用:创建vnode
核心:调用vnode函数返回一个虚拟节点
vue中的舒勇

  • 函数重载
    • 参数个数或类型不同的函数

    • JavaScript中没有重载的概念

    • TypeScript中有重载,不过重载的实现还是通过代码调整参数

    • 函数的重载

    • h函数中的重载

      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)
            }
          }
        }
      }
      
      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
        if (c !== undefined) {
          if (b !== null) {
            data = b
          }
          if (is.array(c)) {
            children = c
          } else if (is.primitive(c)) {
            text = c
          } else if (c && c.sel) {
            children = [c]
          }
        } else if (b !== undefined && b !== null) {
          if (is.array(b)) {
            children = b
          } else if (is.primitive(b)) {
            text = b
          } else if (b && b.sel) {
            children = [b]
          } else { data = b }
        }
        if (children !== undefined) {
          for (i = 0; i < children.length; ++i) {
            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)
        }
        return vnode(sel, data, children, text, undefined)
      };
      

      定义了h函数的四种形式的重载,ts支持重载,但js不支持重载,ts最终会被编译成js,所以这里仅仅是定义了四种重载的形式,并没有相应的实现,真正的实现是在最后一个函数中,通过判断参数的数量做出相应的响应。

Vnode.ts

vnode最终返回的其实就是js对象,使用js对象描述一个虚拟节点

import { Hooks } from './hooks'
import { AttachData } from './helpers/attachto'
import { VNodeStyle } from './modules/style'
import { On } from './modules/eventlisteners'
import { Attrs } from './modules/attributes'
import { Classes } from './modules/class'
import { Props } from './modules/props'
import { Dataset } from './modules/dataset'
import { Hero } from './modules/hero'

export type Key = string | number

export interface VNode {
    // 选择器
  sel: string | undefined
  // 节点数据:属性/样式/事件等
  data: VNodeData | undefined
  // 子节点,和text只能互斥
  children: Array<VNode | string> | undefined
  // 记录vnode对应的真实DOM
  elm: Node | undefined
  // 节点中的内容,和children只能互斥
  text: string | undefined
  // 优化用
  key: Key | undefined
}

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
}

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

Vnode渲染成真实dom

  • 源码的snabbdom.ts中实现
  • 核心函数是patch(oldVnode, newVnode)
  • 打补丁,把新节点中变化的内容渲染到真实dom,最后返回新节点作为下一次处理的旧节点
  • patch执行的整体过程
    • 对比新旧VNode是否相同节点(节点的key和sel相同,key是节点的唯一值,sel是节点的选择器)
    • 如果不是相同节点,删除之前的内容,重新渲染
    • 如果是相同节点,在判断新的VNode是否有text,如果有并且和oldVnode的text不同,直接更新文本内容
    • 如果新的VNode有children,判断子节点是否有变化,判断子节点的过程使用的就是diff算法
    • diff过程只进行同级比较
init函数

下图是init函数的部分源码
init函數部分源碼

  • 参数:两个参数。1.modules模块,用来处理元素的样式,属性,事件等,2.domApi执行一些dom操作(如果domApi不传值,默认情况下是一个dom操作的对象,最终会将虚拟dom转换成真实dom,如果我们想将虚拟dom转换成html字符串或者其他的内容,可以通过传递第二个参数实现)
  • 返回值:patch函数
patch 函数
// init 内部返回patch函数,把vnode渲染成真实dom,并返回vnode
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    // 保存新插入节点的队列,为了触发钩子函数
    const insertedVnodeQueue: VNodeQueue = []
    // 执行模块的pre钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

    // 如果oldVnode不是VNode,创建VNode并设置elm
    if (!isVnode(oldVnode)) {
        // 把DOM元素转换成空的VNode
      oldVnode = emptyNodeAt(oldVnode)
    }
    // 如果新旧节点是相同节点(key和sel相同)
    if (sameVnode(oldVnode, vnode)) {
        // 找节点差异并更新dom
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
        // 如果新旧节点不同,vnode创建对应的dom
        // 获取当前的dom元素
      elm = oldVnode.elm! // !是ts语法,确保elm元素是不为空的
      parent = api.parentNode(elm) as Node
        // 创建vnode对应的dom元素,并触发 init/create钩子函数
      createElm(vnode, insertedVnodeQueue)

      if (parent !== null) {
        //   如果父节点不为空,把vnode对应的DOM插入到文档中
        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])
    }
    // 执行模块的post钩子函数
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
    // 返回vnode
    return vnode
  }
createElm 函数

作用:把vnode转换成对应的dom元素
思维导图

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data // 通过h函数创建vnode的时候传入的第二个参数,模块中所需要的参数
    if (data !== undefined) {
        // 执行用户设置的init钩子函数
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode) // 有用户来传递的
        data = vnode.data // 用户可能会重新修改data的值,所以这里需要重新赋值
      }
    }
    // 把vnode转换成真实DOM对象(没有渲染到页面)
    const children = vnode.children
    const sel = vnode.sel
    if (sel === '!') { // 如果sel的值为!,则去创建注释节点
      if (isUndef(vnode.text)) { // isUndef判断是否为空
        vnode.text = ''
      }
      vnode.elm = api.createComment(vnode.text!)
    } else if (sel !== undefined) { // 创建对应的dom元素
      // Parse selector
    //   先找到#和.的位置,然后再从sel中解析出标签名
      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元素 
    // data.ns命名空间
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag) // 创建带有命名空间的标签,一般情况下是svg
        : 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, ' '))
    //   执行模块的create钩子函数
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
    //   如果vnode中有子节点,递归调用createElm创建子vnode对应的dom元素并追加到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)) { 
          // 如果vnode的text值是string/number, 创建文本节点并追加到dom树上
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
    //   获取钩子函数
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        //   执行用户传入的钩子 create
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
            // 把vnode添加到队列中,为后续执行insert钩子做准备
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!)
    }
    // 返回新创建的dom
    return vnode.elm
  }
addVnodes和removeVnodes 方法

批量添加和删除节点
removeVnodes

  • 参数:四个参数,1.父节点,2.要删除的子节点集合,后两个参数是循环的变量
function invokeDestroyHook (vnode: VNode) {
    const data = vnode.data
    if (data !== undefined) {
        // 执行用户设置的destory钩子函数
      data?.hook?.destroy?.(vnode)
    //   调用模块的destory钩子函数
      for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
    //   执行子节点的destory钩子函数
      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)
          }
        }
      }
    }
  }

  function removeVnodes (parentElm: Node,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number): void {
    for (; startIdx <= endIdx; ++startIdx) {
      let listeners: number
      let rm: () => void
      const ch = vnodes[startIdx] // 获取vnodes中的每一个元素
      if (ch != null) {
        // 如果sel有值
        if (isDef(ch.sel)) { // 元素节点
            // 执行destory钩子函数(会执行所有子节点的destory钩子函数)
          invokeDestroyHook(ch)
          listeners = cbs.remove.length + 1 // cbs中remove函数的个数,所用是防止多次删除节点
        //   创建删除的回调函数
          rm = createRmCb(ch.elm!, listeners)
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
        //   执行用户设置的remove钩子函数
          const removeHook = ch?.data?.hook?.remove
          if (isDef(removeHook)) {
            removeHook(ch, rm)
          } else {
            //   如果没有用户钩子函数,直接调用删除元素的方法
            rm()
          }
        } else { // Text node
            // 如果是文本节点,直接调用删除元素的方法
          api.removeChild(parentElm, ch.elm!)
        }
      }
    }
  }
function createRmCb (childElm: Node, listeners: number) {
    //   返回删除元素的回调函数
    return function rmCb () {
      if (--listeners === 0) {
        const parent = api.parentNode(childElm) as Node
        api.removeChild(parent, childElm)
      }
    }
  }

addVnodes

  • 参数:1.父节点 2.参考节点(插入元素的时候,会把插入的元素放到参考元素之前)3.添加在子节点结合 4.5.循环变量 6.插入队列
  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) { // 如果节点不能空
        // 先通过createElm 把节点转换成真实dom,然后通过insertBefore把节点插入到before元素之前
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
      }
    }
  }
patchVnode

作用:对比新旧两个节点的差异
执行过程

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // ================= 第一部分(执行钩子函数)start =======================
    const hook = vnode.data?.hook
    // 首先执行用户设置的prepatch钩子函数
    hook?.prepatch?.(oldVnode, vnode)
    const elm = vnode.elm = oldVnode.elm! // 将老节点的dom元素赋值给新节点的dom元素属性
    const oldCh = oldVnode.children as VNode[] // 老节点所有的子节点
    const ch = vnode.children as VNode[] // 新节点所有的子节点
    // 判断新旧两个节点的内存地址是否相同,如果相同则返回
    if (oldVnode === vnode) return
    if (vnode.data !== undefined) {
        // 执行模块的update钩子函数 ,update只有开始对比才会执行
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    //   执行用户设置的update钩子函数
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // ==================第二部分(对比两个vnode)start ==================================
    // 如果vnode.text未定义
    if (isUndef(vnode.text)) {
        // 如果新老节点都有children
      if (isDef(oldCh) && isDef(ch)) {
        //   使用diff算法对比子节点,更新子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        //   如果新节点有children,老节点没有children
        // 如果老节点有text,清空dom元素内容
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        // 批量添加子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        //   如果老节点有children,新节点没有children
        // 批量移除子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        //   如果老节点有text,清空dom元素
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
        // 如果没有设置vnode.text
      if (isDef(oldCh)) {
        //   如果老节点有children,移除
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
    //   设置Dom元素的textContent为vnode.text
      api.setTextContent(elm, vnode.text!)
    }
    // ================== 第三部分 start ==========================
    // 最后执行用户设置的postpatch钩子函数
    hook?.postpatch?.(oldVnode, vnode)
  }
updateChildren函数

作用:在新老节点都有children的情况下比较新老节点,并且更新dom
功能:diff算法的核心,对比新旧节点的子节点,更新dom
执行过程

  • 要对比两棵树的差异,我们可以去第一棵树的每一个节点一次和第二棵树的每一个节点比较,但是这样的时间复杂度为o(n^3)
    • 在DOM操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点
    • 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为o(n)
    • 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
    • 在对开始和结束节点比较的时候,总共有四种情况
      • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
      • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
      • oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
      • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
    • 开始节点和结束节点比较,这两种情况类似
      首先是旧的开始节点和新的开始节点之间的比较。会先调用sameVnode判断他们是否是相同的节点,也就是比较他们的key和sel是否相同,如果他们相同再调用patchVnode比较他们的差异,并更新到dom上。然后移动索引,移动到下一个位置上,再去比较是否是相同节点,是的话调用patchVnode,如果这两个节点不相同的话,就会去比较旧的结束节点和新的结束节点是否是相同的节点,如果是的话调用patchVnode,比较完之后移动索引到上一个位置,再去比较。
      • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
        调用sameVnode比较这两个节点是否相同,如果相同,调用patchVnode比较差异更新dom,然后移动旧的节点的dom元素到最后的位置,因为他和新的结束节点相同,移动完毕之后更新索引,旧的节点会移动到下一个元素的位置上,新的节点会移动到上一个位置的元素上。
      • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
    • 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
      • 调用 patchVnode() 对比和更新节点
      • 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
    • oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
      • 调用 patchVnode() 对比和更新节点
      • 把 oldStartVnode 对应的 DOM 元素,移动到右边
      • 更新索引
    • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同、
      调用sameVnode比较这两个节点是否相同,如果相同,调用patchVnode比较差异更新dom,然后移动旧的结束节点到开始位置,因为他和新开始节点相同,
      • 调用 patchVnode() 对比和更新节点
      • 把 oldEndVnode 对应的 DOM 元素,移动到左边
      • 更新索引
    • 如果不是以上四种情况
      • 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
      • 如果没有找到,说明 newStartNode 是新节点
      • 创建新节点对应的 DOM 元素,插入到 DOM 树中
      • 如果找到了具有相同key值的节点
      • 判断新节点和找到的老节点的 sel 选择器是否相同
      • 如果不相同,说明节点被修改了
      • 重新创建对应的 DOM 元素,插入到 DOM 树中
      • 如果sel相同,把 elmToMove 对应的 DOM 元素,移动到左边(最前面)
  • 循环结束
    • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
    • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
    • 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边
      • 老节点个数小于新节点的情况。首先对比开始节点,如果相同,则移动索引到下一个元素,如果也是相同的,也移动到第三个节点上,假设第三个节点不相同,那么比较开始节点的过程就结束了,然后再去比较结束节点,比较两个结束元素是否相同,如果相同会把索引往前移动,再去比较,如果遇到不相同的元素,那么结束节点的比较就结束了,当执行完毕之后,会发现新节点有剩余的元素,那么这些节点就是新增的节点,把它们插入到老节点的末尾
    • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除
      • 如果老节点的个数超过新节点的个数。先去比较开始位置的节点,当遇到不同的元素的节点,则开始节点对比的过程结束,再去对比结束节点,当遇到不同元素的节点,则结束节点的对比过程结束,最终老节点会出现剩余元素,将剩余的老节点元素删除。
  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]
        // 比较开始节点和结束节点的四种情况
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        //   1. 比较老开始节点和新的开始节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        //   2.比较老的结束节点和新的结束节点
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 3.比较老的开始节点和新的结束节点
        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
        // 4.比较老的结束节点和新的开始节点
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        //   开始节点和结束节点都不同
        // 使用newStartNode的key在老节点数组中找相同节点
        // 先设置记录key和index的对象
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        }
        // 遍历newStartVnode,从老的节点中找相同key的oldVnode的索引
        idxInOld = oldKeyToIdx[newStartVnode.key as string]
        // 如果是新的vnode
        if (isUndef(idxInOld)) { // New element
            // 如果没找到,newStartNode是新节点
            // 创建元素插入dom树
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
            // 如果找到相同的key相同的老节点,记录到elmToMove遍历
          elmToMove = oldCh[idxInOld]
          if (elmToMove.sel !== newStartVnode.sel) {
            //   如果新旧节点的选择器不同
            // 创建新开始节点对应的DOM元素,插入到DOM树中
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
          } else {
            //   如果相同,patchVnode()
            // 把elmToMove对应的dom元素,移动到左边
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined as any
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
          }
        }
        // 重新给newStartVnode赋值,指向下一个新节点
        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)
      }
    }
  }

Modules 源码

  • snabbdom的核心函数是patch函数,在patch函数内部会调用patchVnode,在patchVnode内部会调用updateChildren。整个patch内部会做的事情是对比两个虚拟节点,找到差异并更新dom元素。patch内部的dom操作:创建dom元素,更新dom元素内部子元素以及文本内容,删除dom元素
  • snabbdom为了保证核心库的精简,把处理元素的属性、时间、样式等工作,放置到模块中
  • 模块可以按需引入官方文档
  • 模块实现的核心是基于Hooks
Hooks
  • 预定义的钩子函数的名称
    hooks.ts
import { VNode } from './vnode'

// 定义函数类型,定义钩子函数应该具有什么样的参数和返回值
export type PreHook = () => any
export type InitHook = (vNode: VNode) => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type InsertHook = (vNode: VNode) => any
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

// 定义了一个接口,这个接口定义了钩子函数所需要的所有的钩子函数的名称以及对应的类型
export interface Hooks {
    // patch 函数开始执行的时候触发
  pre?: PreHook
//   createElm函数开始之前的时候触发
// 在把VNode转换成真实dom之前触发
  init?: InitHook
//   createElm函数末尾调用
// 创建完真实dom后触发
  create?: CreateHook
//   patch函数末尾执行
// 真实dom添加到dom树中触发
  insert?: InsertHook
//   patchVnode函数开头调用
// 开始对比两个vnode的差异之前触发
  prepatch?: PrePatchHook
//   patchVnode函数开头触发
// 两个vnode对比过程中触发,比prepatch稍晚
  update?: UpdateHook
//   patchVnode的最末尾调用
// 两个Vnode对比结束执行
  postpatch?: PostPatchHook
//   removeVnodes =》 invokeDestroyHook中调用
// 在删除元素之前触发,子节点的destroy也触发
  destroy?: DestroyHook
//   removeVnodes中调用
// 元素被删除的时候触发
  remove?: RemoveHook
//   patch函数的最后调用
  post?: PostHook
}

module.ts

import { PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook } from '../hooks'
// 导入hooks,并且定义了模块所需要的钩子函数,并不是所有的钩子函数
export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}

attributes.ts

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

export type Attrs = Record<string, string | number | boolean>

const xlinkNS = 'http://www.w3.org/1999/xlink'
const xmlNS = 'http://www.w3.org/XML/1998/namespace'
const colonChar = 58
const xChar = 120

// attributes中的核心函数
function updateAttrs (oldVnode: VNode, vnode: VNode): void {
  var key: string
  var elm: Element = vnode.elm as Element
  var oldAttrs = (oldVnode.data as VNodeData).attrs // 老的节点的属性
  var attrs = (vnode.data as VNodeData).attrs // 新节点的属性
    // 新老节点没有attrs属性,则返回
  if (!oldAttrs && !attrs) return
  // 新老节点的属性相同,则返回
  if (oldAttrs === attrs) return
  oldAttrs = oldAttrs || {}
  attrs = attrs || {}

  // update modified attributes, add new attributes
//   遍历新节点的属性
  for (key in attrs) {
    //   新老节点的属性值
    const cur = attrs[key]
    const old = oldAttrs[key]
    // 如果新老节点的属性值不同
    if (old !== cur) {
        // 布尔类型值的处理
      if (cur === true) {
        elm.setAttribute(key, '')
      } else if (cur === false) {
        elm.removeAttribute(key)
      } else {
        //   xChar=》x
        //   判断这个属性的第一个字母是否是x,如果是x,则<sxg xmlns='http://www.w3.org/2000/svg'>处理svg中命名空间的属性
        if (key.charCodeAt(0) !== xChar) {
          elm.setAttribute(key, cur as any)
        } else if (key.charCodeAt(3) === colonChar) {
          // Assume xml namespace
          elm.setAttributeNS(xmlNS, key, cur as any)
        } else if (key.charCodeAt(5) === colonChar) {
          // Assume xlink namespace
          elm.setAttributeNS(xlinkNS, key, cur as any)
        } else {
          elm.setAttribute(key, cur as any)
        }
      }
    }
  }
  // remove removed attributes
  // use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
  // the other option is to remove all attributes with value == undefined
//   如果是老节点的属性在新节点中不存在,移除
  for (key in oldAttrs) {
    if (!(key in attrs)) {
      elm.removeAttribute(key)
    }
  }
}

export const attributesModule: Module = { create: updateAttrs, update: updateAttrs }

通过看源码可以发现,在模块中无非就是定义一些钩子函数,如create,update,当dom元素创建好之后对dom元素做一些额外的操作,比说说操作dom元素的属性样式或者事件等,我们也可以自己定义自己的钩子函数做额外的操作。
在模块定义好之后,在snabbdom.ts中的init函数中首先遍历模块中的钩子函数,并且把它放到cbs对象中,当patch执行到特定时机的时候会去触发钩子函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值