学习笔记(十四)Vue Virtual DOM的实现原理

什么是虚拟DOM

虚拟DOM是一个普通的JavaScript对象,用来描述真实的DOM

创建虚拟DOM的开销要比创建真实DOM小很多

为什么要使用虚拟DOM?

  • 手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery等库来简化DOM操作,但随着项目的复杂,DOM操作的复杂度提升

  • 为了简化DOM的复杂操作出现了各种MVVM框架,MVVM框架解决了视图和状态的同步问题

  • 为了简化视图的操作,我们可以使用模板引擎,但模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现

  • Virtual DOM的好处是,当状态发生变化时,不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部通过diff算法来有效的更新DOM

  • github上virtual-dom的描述

    • Virtual DOM可以维护程序的状态,同时跟踪上一次状态

    • 通过比较前后两次状态差异来更新视图(真实DOM)

      image-20201204204240808

虚拟DOM的作用和虚拟DOM库

虚拟DOM的作用

  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 除了渲染DOM外,还可以实现SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

虚拟DOM库

  • Snabbdom
    • Vue 2.x 内部使用的Virtual DOM就是改造的Snabbdom
    • 仅有200行代码SLOC (single line of code)
    • 通过模块可扩展
    • 源码使用TypeScript开发
    • 最快的Virtual DOM之一
  • virtual-dom

Snabbdom的基本使用

注:以下使用的Snabbdom均为2.1.x版本,不同版本的使用方式上存在一定的差异

创建项目

  • 创建项目目录,并使用yarn init -y初始化
  • 使用yarn add parcel-bundler命令安装parcel来进行项目的打包构建
  • pacakge.json中添加script属性,并设置相关的编译命令
    • "dev": "parcel index.html --open"
    • "build": "parcel build index.html"
  • 新建index.html文件
  • 新建src目录,并创建basicusage.js文件
  • 在index.html中引入创建的basicusage.js

导入Snabbdom

  • 使用yarn add snabbdom命令来安装snabbdom

  • 使用ES6 Module import语法引入snabbdom的相关方法

    // snabbdom 2.1.x版本导入方式
    import { init } from 'snabbdom/build/package/init'
    import { h } from 'snabbdom/build/package/h'
    

基本使用

通过两个简单的例子来演示snabbdom的基本使用方式

  • hello world

    • 调用init函数初始化

      • 参数:数组/模块
      • 返回值:patch函数,作用是对比两个vnode之间的差异,并更新到真实DOM
    • 调用h函数创建vnode

      • 第一个参数:标签 + 选择器
      • 第二个参数:如果是字符串的话,是标签的内容
      • 返回值:vnode对象
    • 调用patch函数

      • 第一个参数:可以是DOM元素,内部会把DOM元素转换为vnode
      • 第二个参数:vnode
      • 返回值:vnode
      // 1. hello world
      // init函数
      // 参数:数组/模块
      // 返回值:patch函数,作用是对比两个vnode的差异,更新到真实DOM
      const patch = init([])
      // h函数
      // 第一个参数:标签 + 选择器
      // 第二个参数:如果是字符串的话,是标签的内容
      // 返回值:vnode
      const vnode = h('div#container.cls', 'Hello World')
      // 获取app元素
      const app = document.querySelector('#app')
      // patch函数
      // 第一个参数:可以是DOM元素,内部会把DOM元素转换为vnode
      // 第二个参数:vnode
      // 返回值:vnode
      const oldVNode = patch(app, vnode)
      
      const newVNode = h('div', 'hello snabbdom')
      
      patch(oldVNode, newVNode)
      
      
  • div中放置子元素

    • h函数的第二个参数为数组时,接收一个子元素的vnode列表

    • 如果想要清空DOM元素,可以通过h函数h('!')创建一个注释节点,通过patch替换

      let vnode = h('div#container', [
          h('h1', 'hello snabbdom'),
          h('p', '这是一个p标签')
      ])
      
      const oldVNode = patch(app, vnode)
      
      setTimeout(() => {
          vnode = h('div#container', [
              h('h1', 'hello world'),
              h('p', 'hello p')
          ])
          patch(oldVNode, vnode)
          // 使用h('!')创建注释节点替换清空DOM元素
          // patch(oldVNode, h('!'))
      }, 2000)
      

snabbdom中的模块

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

官方提供了6个常用的模块

  • attributes
    • 设置DOM元素的属性,使用setAttribute()
    • 处理布尔类型的属性
  • props
    • attributes类似,设置DOM元素的属性element[attr] = value
    • 不处理布尔类型的属性
  • class
    • 切换类样式
  • dataset
    • 设置data-*的自定义属性
  • eventlisteners
    • 注册和移除事件
  • style
    • 设置行内样式,支持动画
    • delayed/remove/destory

模块的使用

  • 导入需要的模块
  • init()中注册模块
  • 使用h()创建vnode时,可以将第二个参数设置为对象,传入模块相应的数据,子元素数组等参数后移到第三个参数
// 1. 导入模块
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
import { styleModule } from 'snabbdom/build/package/modules/style';
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners';
 
// 2. 注册模块
const patch = init([
    styleModule,
    eventListenersModule,
])
// 3. 使用h()函数第二个参数传入模块需要的数据
let vnode = h('div', {
    style: {
        backgroundColor: 'red'
    },
    on: {
        click: function() {
            console.log('click')
        }
    }
}, [
    h('h1', 'hello world'),
    h('p', 'hello p')
])
 
const app = document.querySelector('#app')
 
patch(app, vnode)

Snabbdom核心源码解析

h()

  • Vue中也存在h()函数,在snabbdom的基础上,支持传入组件作为参数,snabbdom的h()函数不支持组件参数

  • h()函数最早见于HyperScript,使用javascript创建超文本

  • snabbdom的h()函数不用来创建超文本,而是用来创建vnode

  • snabbdom使用TypeScript编写,并使用了函数重载,h()函数通过重载来处理不同参数时的不同逻辑

    // 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>
    
    // 递归添加ns命名空间属性
    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)
          }
        }
      }
    }
    // 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
    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) {
        // 处理三个参数的情况 sel、data、children/text
        if (b !== null) {
          data = b
        }
        if (is.array(c)) { // 子元素数组
          children = c
        } else if (is.primitive(c)) { // 元素内容
          text = c
        } else if (c && c.sel) { // vnode
          children = [c]
        }
      } else if (b !== undefined && b !== null) {
        // 处理两个参数的情况 sel、children/text
        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) {
        // 处理children中的文本节点
        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)
    };
    
    

vnode

  • vnode是包含sel、data、children、elm、text、key这6个属性的对象

  • vnode创建时接收key以外的5个参数,key的指取data.key或者undefined

    import ...
    
    export type Key = string | number
    
    export interface VNode {
      sel: string | undefined // 选择器
      data: VNodeData | undefined // 模块节点数据 属性/样式/事件等
      children: Array<VNode | string> | undefined // 子节点,和text互斥
      elm: Node | undefined // vnode对应的真实DOM
      text: string | undefined // 节点内容
      key: Key | undefined // 用于优化性能,取data.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 }
    }
    

init(modules, domApi)

  • 接收模块数组作为第一个参数,没有使用模块时可以传空数组

  • 接收domApi作为第二个参数,这是一个可选参数,用来提供转换虚拟节点的api,不传时,默认使用htmlDomApi

  • 最终返回patch函数

    ...
    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: []
      }
    
      // 初始化转换虚拟节点的 api ,如果没有通过第二个参数传入,则使用默认的 htmlDomApi
      const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
    
      // 遍历并把传入的所有模块的钩子函数,统一存储到 cbs 对象中
      // 最终构建的 cbs 对象的形式为 cbs = { create: [fn1, fn2, ...], update: [fn1, fn2, ...], ... }
      for (i = 0; i < hooks.length; ++i) {
        // 遍历 hooks 并将 hook 的名称作为 cbs 的属性名初始化成空数组 []   
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
          // 遍历 modules 传入的模块数组
          // 获取模块中的 hook 函数     
          const hook = modules[j][hooks[i]]
          if (hook !== undefined) {
            // 把获取到的 hook 函数放入到 cbs 对应的钩子函数数组中
            (cbs[hooks[i]] as any[]).push(hook)
          }
        }
      }
    
      function emptyNodeAt (elm: Element) {
        ...
      }
    
      function createRmCb (childElm: Node, listeners: number) {
        ...
      }
    
      function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
        ...
      }
    
      function addVnodes (
        parentElm: Node,
        before: Node | null,
        vnodes: VNode[],
        startIdx: number,
        endIdx: number,
        insertedVnodeQueue: VNodeQueue
      ) {
        ...
      }
    
      function invokeDestroyHook (vnode: VNode) {
        ...
      }
    
      function removeVnodes (parentElm: Node,
        ...
      }
    
      function updateChildren (parentElm: Node,
        ...
      }
    
      function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
        ...
      }
    
      // 返回 patch 函数
      return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
        ...
      }
    }
    
    

patch(oldVNode, newVNode)

比较新旧vnode的变化,并将新节点中变化的内容渲染到真实DOM,最终返回新节点作为下一次处理的旧节点

patch执行的整体过程

  • 对比新旧vnode是否相同节点(key 和 sel相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,判断新的vnode是否有text且与旧vnode的text不同,则更新文本内容
  • 如果新的vnode有children,判断子节点是否有变化,判断子节点的过程使用diff算法
  • diff过程只进行同层比较
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)) { // 通过对象是否包含 sel 属性判断
      // 把 DOM 元素转换成空的 VNode
      // 调用 vnode 构造函数创建 VNode
      oldVnode = emptyNodeAt(oldVnode)
    }
 
    // 判断新旧节点是否相同
    // 判断新旧vnode的key与sel是否相同
    if (sameVnode(oldVnode, vnode)) {
      // 找节点的差异并更新 DOM
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 如果新旧节点不同,vnode创建对应的 DOM
      // 获取当前的 DOM 元素
      elm = oldVnode.elm!
      // 获取 DOM 元素的父节点
      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, insertedVnodeQueue)

创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      // 执行用户传入的 init 钩子函数
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode)
        // 用户传入的 init 函数可能修改 vnode 的 data
        // 需要重新赋值 data
        data = vnode.data
      }
    }
    // 把 vnode 转换成真实 DOM 对象 (但并没有渲染到页面)
    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) // 创建带命名空间的 DOM 元素
        : api.createElement(tag) // 创建普通的 DOM 元素
      // 设置元素 id
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      // 设置元素 class
      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
  }
image-20201211002029821

removeVnodes(parentElm, vnodes, start, end)

用于批量移除节点

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]
      // 判断 vnode 是否有值     
      if (ch != null) {
        // 判断 sel 是否有值
        // 有值为元素节点,否则为文本节点
        if (isDef(ch.sel)) {
          // 执行用户定义的 destory 钩子函数(包含子节点)
          invokeDestroyHook(ch)
          listeners = cbs.remove.length + 1
          // 创建删除的回调函数
          // 通过listeners计数判断,最终当listeners为0时才会真正执行
          rm = createRmCb(ch.elm!, listeners)
          // 执行用户设置模块的 remove 钩子函数
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
          const removeHook = ch?.data?.hook?.remove
          // 判断是否存在用户定义的 remove 钩子函数
          // 存在则先执行用户定义的钩子函数
          // 不存在则直接执行删除元素的方法
          if (isDef(removeHook)) {
            removeHook(ch, rm)
          } else {
            rm()
          }
        } else { // Text node
          // 文本节点,直接移除
          api.removeChild(parentElm, ch.elm!)
        }
      }
    }
  }

addVnodes(parentElm, before, vnodes, start, end, insertedVnodeQueue)

用于批量添加节点

function addVnodes (
    parentElm: Node,
    before: Node | null,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number,
    insertedVnodeQueue: VNodeQueue
  ) {
    // 遍历 vnodes
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        // 通过 createElm 将 vnode 转换为真实的 DOM,并插入指定的元素之前
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
      }
    }
  }

patchVnode(oldVnode, vnode, insertedVnodeQueue)

用于比较新老 vnode 之间的差异,并更新 DOM

image-20201211183117085
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    const hook = vnode.data?.hook
    // 执行用户设置的 prepatch 钩子函数
    hook?.prepatch?.(oldVnode, vnode)
    const elm = vnode.elm = oldVnode.elm!
    const oldCh = oldVnode.children as VNode[]
    const ch = vnode.children as VNode[]
    // 如果新老 vnode 相同,则返回
    if (oldVnode === vnode) return

    if (vnode.data !== undefined) {
      // 执行模块的 update 钩子函数
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      // 执行用户设置的 update 钩子函数
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // 如果 vnode.text 未定义
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        // 新旧 vnode 都存在子节点,且子节点不相同
        // 调用 updateChildren 更新子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        // 新 vnode 存在子节点,老 vnode 不存在子节点
        // 如果老 vnode.text 存在,则清空 DOM 元素的 textContent
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        // 批量添加新 vnode 中的子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 老 vnode 存在子节点,新 vnode 不存在子节点
        // 批量删除老 vnode 下的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 新老 vnode 都不存在子节点
        // 老 vnode.text 存在,则清空 DOM 元素的 textContent
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 老 vnode.text 与新 vnode.text 不相同
      // 老 vnode 存在子节点,则批量移除老 vnode 的所有子节点
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      // 将 DOM 元素的 textContent 设置成新 vnode.text
      api.setTextContent(elm, vnode.text!)
    }
    // 执行用户设置的 postpatch 钩子函数
    hook?.postpatch?.(oldVnode, vnode)
  }

updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue)

比较新老 vnode 的子节点,并更新,是diff算法的核心

  • 关于diff算法

    • 要对比两个 DOM 树的差异,可以取第一棵树的每一个节点依次与第二棵树的每一个节点进行比较,这样的时间复杂度是O(n^n)
    • 实际使用过程中,极少的情况下会将一个父节点移动更新到某一个子节点
    • 因此只需要找同级别的子节点依次比较,这样算法的时间复杂度是O(n)
    • 在同级别比较时,对新老节点数组的开始和结尾设置标记索引,比较过程中移动索引的位置
    • 比较时依次共有四种情况(由上至下,不是相同节点时继续使用下一种比较):
      • oldStartVnode vs newStartVnode
      • oldEndVnode vs newEndVnode
      • oldStartVnode vs newEndVnode
      • oldEndVnode vs newStartVnode
    • 如果比较的两个新旧节点相同,则调用patchVnode更新DOM,并移动标记索引(开始索引++,结尾索引--)
    • 如果上面四种情况均不满足,则使用newStartVnode在所有oldVnode中查找是否存在相同的节点(比较key与sel),如果找到相同节点,则将相应的节点移动到数组的最前面,如果未找到相同节点,则创建一个新的DOM元素,插入到节点数组最前面
    • 当新老节点数量不一样
      • 老节点数量大于新节点数量,则删除多余的老节点
      • 老节点数量小于新节点数量,则创建多余的新节点
    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) {
          // 判断节点是否为null并移动索引标记
          // 比较移动过程中,可能将节点设置为null
          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)) {
            // 都不为null的情况下
            // 比较 oldStartVnode 与 newStartVnode 是否相同节点
            // 是则调用 patchVnode 更新 DOM
            // 然后移动索引
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
          } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // 比较 oldEndVnode 与 newEndVnode 是否相同节点
            // 是则调用 patchVnode 更新 DOM
            // 然后移动索引
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
          } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            // 比较 oldStartVnode 与 newEndVnode 是否相同节点
            // 是则调用 patchVnode 更新 DOM
            // 将 oldStartVnode 向右移动到 oldEndVnode 前面
            // 然后移动索引
            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
            // 比较 oldStartVnode 与 newEndVnode 是否相同节点
            // 是则调用 patchVnode 更新 DOM
            // 将 oldEndVnode 向左移动到 oldStartVnode 前面
            // 然后移动索引
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
            api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
          } else {
            // 如果上述四种比较均不能找到相同的节点
            // 使用 newStartVnode.key 在老节点数组中寻找相同节点
    
            // 调用 createKeyToOldIdx 记录 key 和 index 对象
            // 遍历老节点数组,将非 undefined 的 key 作为 map 的属性,保存对应的 index,最终返回 map,即为 oldKeyToIdx
            if (oldKeyToIdx === undefined) {
              oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            }
            // 获取 newStartVnode.key 在老节点中的索引 idxInOld
            idxInOld = oldKeyToIdx[newStartVnode.key as string]
            
            if (isUndef(idxInOld)) { // New element
              // 如果 idxInOld 是 undefined,说明 newStartVnode 在老节点数组中不存在
              // 创建一个新的 DOM 元素,并插入 oldStartVnode 之前
              api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
            } else {
              // newStartVnode.key 在老节点数组中存在
              // 保存到 elmToMove
              elmToMove = oldCh[idxInOld]
              if (elmToMove.sel !== newStartVnode.sel) {
                // key 相同但 sel 不同,说明节点被修改
                // 创建一个新的 DOM 元素,并插入 oldStartVnode 之前
                api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
              } else {
                // key 与 sel 相同,是相同节点
                // 调用 patchVnode 更新 DOM
                // 将老数组中的节点置为 undefined
                // 并将该节点移动到 oldStartVnode 之前
                patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
                oldCh[idxInOld] = undefined as any
                api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
              }
            }
            // 移动 newStartIdx 索引,进入下一次循环
            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)
          }
        }
      }
    

    **从diff算法执行过程看 key 的重要性,未指定 key 的时候,key的值都是undefined,此时不同的节点会被误认为是相同的节点发生内容修改,从而导致频繁的 DOM 更新,而指定了 key 的情况下,对于只是节点顺序发生变化的情况,只需要调整节点的顺序,而不需要更新 DOM **

模块源码

snabbdom为了保证核心代码的精简,将处理元素属性、样式、事件等工作,放到了模块中

模块可以按需引入

模块实现的核心基于Hooks

以 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

  // 新老节点没有属性,直接返回
  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 {
        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)
    }
  }
}

// 使用 create update 两个 hook
export const attributesModule: Module = { create: updateAttrs, update: updateAttrs }

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,348评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,122评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,936评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,427评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,467评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,785评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,931评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,696评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,141评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,483评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,625评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,291评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,892评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,741评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,324评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,492评论 2 348

推荐阅读更多精彩内容

  • Vue2.0加入了Virtual Dom,Vue的Diff位于patch.js文件中,该算法来源于snabbdom...
    hellomyshadow阅读 536评论 0 2
  • 首先理解VNode对象 一个VNode的实例对象包含了以下属性,参见源码src/vdom/vnode.js 其中几...
    KlausXu阅读 1,176评论 0 2
  • 也看过其他讲vue diff过程的文章,但是感觉都只是讲了其中的一部分(对比方式),没有对其中细节的部分做详细的讲...
    小鱼儿_61f5阅读 2,539评论 5 3
  • 前言 DOM是很慢的。真正的 DOM 元素非常庞大,这是因为标准就是这么设计的。而且操作它们的时候你要小心翼翼,轻...
    梁王io阅读 1,117评论 0 1
  • Vue原理 未经允许 禁止转载 MVVM 数据驱动视图 传统组件只是静态渲染,更新还要依赖于操作DOM vue M...
    吾名刘斩仙阅读 205评论 0 0