学习笔记:Snabbdom源码

723 阅读5分钟

抽空补充中 内容仅为学习笔记,如果有错误欢迎指出,谢谢

Virtual DOM

1.基本知识

  • virtual DOM指的是用JS模拟的DOM结构,将DOM变化的对比放在JS层来做。换而言之,vdom就是JS对象。

  • 浏览器操作DOM的开销很大,Virtual DOM用来解决此问题

  • 模板引擎可以简化试图操作,到时无法跟踪状态。而虚拟DOm可以跟踪状态。

  • 虚拟DOM作用

    • 维护视图和状态的关系,保存视图的状态
    • 复杂视图情况下提升渲染性能
    • 跨平台
  • 开源库

    • Snabbdom
      • Vue.js 2.x内部使用的虚拟DOm就是改造 Snabbdom
      • 源代码比较小
      • 通过模块可扩展
      • 源码使用TS开发
    • Virtual-dom

2.Snabbdom基本使用

// 注意路径问题
import {init} from "snabbdom/build/package/init.js";
import {h} from "snabbdom/build/package/h.js";

const patch = init([])  

// h函数接收一个字符串形式的标签/选择器、一个可选的数据对象、一个可选的字符串或数组作为子代。
let vnode = h('div#container.cls',[
    h('h1','Hello snabbdom'),
    h('p','这里是一个p')
])
// 获取占位容器系欸点
let app = document.querySelector('#app')

// patch反法旧时对比 old和new的dom的差异,把数据渲染到页面上,第一个参数可以传入一个真实的dom,petch会自动转化
// patch返回一个新vnod
let oldVnode = patch(app, vnode)

// 两秒后更新
setTimeout(() => {
    vnode = h('div#container.cls',[
        h('h1','Hello 51C'),
        h('p','这里是一个新的p')
    ])
    oldVnode = patch(oldVnode,vnode)
},2000)

// 5秒后清空 html变成注释文本
setTimeout(() => {
    patch(oldVnode,h('!')) // h('!')创建一个空的节点
},5000)
  • 模块使用
    • Snabbdom的核心库并不能处理DOM元素的属性、样式、事件等,可以通过注册Snabbdom默认提供的模块来实现的
    • Snabbdom中的模块可以用来扩展Snabbdom的功能
    • Snabbdom中的模块的实现是通过组测全局的钩子函数来实现的
  • 官方提供的模块
    • attributes,设置DOM的属性,通过setAttribute()方法。处理布尔类型的属性。
    • props,处理非布尔类型的属性,通过对象.属性的方法实现
    • class,切换样式
    • dataset,设置自定义data-*属性。
    • eventlisteners
    • style ,设置行内样式
  • 模块的使用
    • 导入模块。
    • init()函数注册模块
    • 使用h()函数创建VNode时,在第二个参数中传入对象。
import {init} from "snabbdom/build/package/init.js";
import {h} from "snabbdom/build/package/h.js";

// 1.导入模块
import { styleModule } from "snabbdom/build/package/modules/style";
import { eventListenersModule } from "snabbdom/build/package/modules/eventlisteners";

// 2.注册模块

const petch = init([
    styleModule,
    eventListenersModule
])

// 3.使用h()函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div',[
    h('h1',{style:{backgroundColor:'red'}},'Hello Snabbdom'),
    h('p',{on:{click: handleClick}},'Change P')
])

function handleClick(){
    console.log('点击了p');
}

let app = document.querySelector('#app')
petch(app,vnode)

3.Snabbdom源码解析(2.0版本)

3.1 Snabbdom的核心

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

3.2 h函数

  • 作用:创建V-node对象
  • 知识补充:函数重载
  • 参数个数或参数类型不同的函数
    • JavaScript中没有重载的概念
    • TypeScript中有重载,不过重载的实现还是通过代码调整参数
  • 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
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
    }
    // 如果c时字符串或数字
    if (is.array(c)) {
      children = c
    } else if (is.primitive(c)) {
      text = c
      // 如果c时node节点
    } 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) {
    // 处理CHildren中的原始值(string/number)
    for (i = 0; i < children.length; ++i) {
      // 如果chuld时String/number就创建文本节点
      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] === '#')
  ) {
    // 如果时Svg,添加命名空间
    addNS(data, children, sel)
  }
  // 返回Vnode
  return vnode(sel, data, children, text, undefined)
};

3.2 vnode函数

vode属性

export interface VNode {  
  sel: string | undefined  // 选择器 
  data: VNodeData | undefined // 描述模块中所需要的数据
  children: Array<VNode | string> | undefined // 描述对应子节点
  elm: Node | undefined // 存储vonde对象装欢的dom元素
  text: string | undefined // 描述文本节点中的文本内容,与childeren是互排斥
  key: Key | undefined
}

3.3 init函数

  • 作用

    • 用来处理跨平台的对应API
    • 初始化模块
    • 定义一个cbs变量用来储存处理钩子函数的回调函数数组
  • 过程

    • init函数将不同模块的钩子函数存储起来,并且返回一个patch函数,这里是高阶函数的做法,处理一部分的变量,返回一个函数。

    • 入参中domApi,是可以指定任意api对象(好处是可以跨平台),若没有指定就是默认是操作浏览器的DOM的api

    • 入参modules

    • cbs对象用来储存模块中的处理钩子的回调函数,后面会在合适的时机调用

    • // 遍历moudle中的钩子函数并保存下来
      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)  // 存储回调函数
          }
        }
      }
      

3.4 patch函数

  • 整体过程分析(首次渲染)

    return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
      let i: number, elm: Node, parent: Node
      const insertedVnodeQueue: VNodeQueue = []
      // pre是处理vonde前触发的函数(预处理)
      for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
     
      if (!isVnode(oldVnode)) {  // 判断是不是一个vnode对象
        oldVnode = emptyNodeAt(oldVnode) // 将DOM对象转化为Vnode对象
      }
      if (sameVnode(oldVnode, vnode)) {   // 判断是否是相同节点
        patchVnode(oldVnode, vnode, insertedVnodeQueue)  // 相同节点调用函数,对比这个节点中的变化
      } else {  // 会新建一个DOM元素,并把这个元素插入到DOM树上,并移除老节点
        elm = oldVnode.elm!  // 获取旧的DOm元素
        parent = api.parentNode(elm) as Node //获取父元素
        createElm(vnode, insertedVnodeQueue)  // 创建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
    }
    

3.5 createElm函数

  • 作用:把Vnode节点转换成DOM元素,把DOM元素存储再vnode的elm属性上,但是没有挂载到dom树上
 function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 执行用户的设置的init钩子函数
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      const init = data.hook?.init
      if (isDef(init)) {  // init 使用同通过hook传入的钩子函数
        init(vnode)
        data = vnode.data // 重新赋值避免在钩子函数中改变值
      }
    }
    const children = vnode.children
    const sel = vnode.sel
    // 把Vnode转换成真实DOM对象(没有渲染到页面)
    if (sel === '!') {  // 如果是!,创建注释节点
      if (isUndef(vnode.text)) { // isUndef判断文本内容是否为空
        vnode.text = ''
      }
      vnode.elm = api.createComment(vnode.text!)  // 创建注释节点,并保存再vnode.elm属性上,再petch通过insertBefore插入节点
    } else if (sel !== undefined) { // 创建对应的DOM元素
      // 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  // 处理选择器 tag值标签名
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns) // ns是命名空间,判断创建对应对应的DOM元素
        ? api.createElementNS(i, tag)
        : api.createElement(tag)
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot)) // 添加ID
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' ')) // 添加CLass
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // 执create钩子函数
      if (is.array(children)) {  // 判断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)) { // primitive 判断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)   // insertedVnodeQueue是插入到DOM所执行的函数,先暂存起来
        }
      }
    } else { // 如果选择器sel是空值,那么插入的就是文本节点
      vnode.elm = api.createTextNode(vnode.text!)
    }
    // 返回创建的DOM

    return vnode.elm
  }

3.6 removeVnodes/addVnode函数

  • 作用:添加和移除节点
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]
  if (ch != null) {  
    if (isDef(ch.sel)) { // 处理元素节点
      invokeDestroyHook(ch)  // 触发vnode destroy钩子函数
      listeners = cbs.remove.length + 1 // listeners 用来防止内除重复删除dom 
      rm = createRmCb(ch.elm!, listeners) // 返回一个真实删除的函数是一个高阶函数,并且会在listeners(计数器)等于0的时候执行移除
      for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
      const removeHook = ch?.data?.hook?.remove // 使用用户传入的romove钩子函数
      if (isDef(removeHook)) {
        removeHook(ch, rm) // 用户在hook如果传入remove了钩子函数,需要手动调用给rm函数
      } else {
        rm()
      }
    } else { // Text node
      api.removeChild(parentElm, ch.elm!)  // 文本节点直接移除
    }
  }
}
}
    
 // 添加系节点   
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)
      }
    }
  }

3.7 pachVnode

  • 作用:对比新旧两个vnode差异,然后新DOM更新到页面上

  • patchVnode.png

  •  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
        // 第一过程:触发prepatch 和updata钩子函数
        const hook = vnode.data?.hook // 获取用户传入的hook属性
        hook?.prepatch?.(oldVnode, vnode)  // 如果用户传入的prepatch函数就是
        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) { // 如果不同旧执行 模块update函数 =》用户定义的updata函数
          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)) { // 判断是是否有文本节点,注意是很children互斥的
          if (isDef(oldCh) && isDef(ch)) { // 新旧节点都有子节点就触发,yupdateChildren函数
            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!) // 如果没有子节点就直接更新文本
        }
        // 第三过程 触发postpatch钩子函数,获取最新的数据
        hook?.postpatch?.(oldVnode, vnode)
      }
    

3.8 DOM Diff算法

  • 真实DOM和虚拟DOM的区别
    • 真实DOM对比每个节点,发现不同旧重新绘制整个页面
    • 虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分,最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗
    • 虚拟DOM有效降低大面积(真实DOM节点)的重绘与排版,因为最终与真实DOM比较差异,可以只渲染局部】
  • 参考文章

3.9 updateChildren函数

  • 理解key的作用(后续补充),如果没有key就最大程度复用当前的虚拟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) {  // 循环条件 结束标记位大于开始标记位置
      // 处理节点为空情况
      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]
        // 比较开始和结束的4种情况
      } 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) // 根据key找老索引。返回一个map对象、键名为key值为index
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string] //  用newVnode的key值去找oldKeyToIdx对象有没有对应值
        if (isUndef(idxInOld)) { // New element 
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
          elmToMove = oldCh[idxInOld] // 取这个相同key值的老节点
          if (elmToMove.sel !== newStartVnode.sel) { // 如果不相同也时重新创建
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
          } else { 
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) // 递归调用pachVnode
            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)  // 添加新虚拟DOM
      } else {
          // 新节点数组遍历完,老节点数组有剩余
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) // 移除虚拟DOm
      }
    }
  }