snabbdom原理解析

一. snabbdom介绍

snabbdom是虚拟DOM库,通过虚拟DOM操作视图的更新。

例如下面实例代码,调用init方法执行初始化逻辑,返回patch方法。通过调用h方法创建虚拟DOM节点,接着通过patch方法将其更新到指定DOM节点。

javascript 复制代码
import {
  init,
  classModule,
  styleModule,
  attributesModule,
  eventListenersModule,
  h,
} from 'snabbdom'

const patch = init([
  classModule,
  styleModule,
  attributesModule,
  eventListenersModule,
])

const vnode = h(
  'div',
  {
    attrs: { id: 'app' },
  },
  [h('h1', {}, 'hello world')],
)

patch(document.querySelector('#app'), vnode)

二. 实现snabbdom

2.1 定义vnode

vnode节点包含以下属性:

  • sel属性是标签选择器
  • key属性是节点唯一标识,用于判断节点是否一致
  • data属性包含样式,类名和事件的对象
  • childrenchild vnode数组
  • text是节点文本
  • elm是节点对应的真实DOM节点
javascript 复制代码
function vnode(sel, data, children, text, elm) {
  const key = data === undefined ? undefined : data.key
  return { sel, key, data, children, text, elm }
}

2.1.1 h

便捷创建vnode方法

javascript 复制代码
import { vnode } from './vnode'

export function h(sel, data, children) {
  let text
  if (typeof children === 'string') {
    text = children
  } else if (!Array.isArray(children)) {
    children = [children]
  }
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; i++) {
      if (typeof children[i] === 'string')
        children[i] = vnode(
          undefined,
          undefined,
          undefined,
          children[i],
          undefined,
        )
    }
  }
  return vnode(
    sel,
    data,
    Array.isArray(children) ? children : undefined,
    text,
    undefined,
  )
}

2.2 定义modules

vnode节点的data属性中的每一个key都对应一个module,如class对应classModule,专门处理class

每个module会暴露一些生命周期方法,如createupdate等,会在特定时期会调用执行。

2.2.1 classModule

处理class,暴露createupdate两个钩子。逻辑比较简单,主要diff新旧class属性值,然后调用DOM节点的classList属性方法修改class

javascript 复制代码
function updateClass(oldVnode, newVnode) {
  let oldClass = oldVnode.data?.class
  let newClass = newVnode.data?.class
  const elm = newVnode.elm
  if (!oldClass && !newClass) return
  if (oldClass === newClass) return
  oldClass = oldClass || {}
  newClass = newClass || {}
  for (const key in oldClass) {
    if (oldClass[key] && !Object.prototype.hasOwnProperty(newClass, key)) {
      elm.classList.remove(key)
    }
  }
  for (const key in newClass) {
    const value = newClass[key]
    if (value !== oldClass[key]) {
      elm.classLcist[value ? 'add' : 'remove'](key)
    }
  }
}

export const classModule = { create: updateClass, update: updateClass }

2.2.2 styleModule

处理style,暴露createupdate两个钩子。逻辑比较简单,先将旧style属性重置,然后赋值新style属性值

javascript 复制代码
function updateStyle(oldVnode, newVnode) {
  const elm = newVnode.elm
  let oldStyle = oldVnode.data?.style
  let newStyle = newVnode.data?.style
  if (!oldStyle && !newStyle) return
  if (oldStyle === newStyle) return
  oldStyle = oldStyle || {}
  newStyle = newStyle || {}
  for (const key in oldStyle) {
    elm.style[key] = ''
  }
  for (const key in newStyle) {
    const value = newStyle[key]
    if (value !== oldStyle[key]) {
      elm.style[key] = value
    }
  }
}

export const styleModule = { create: updateStyle, update: updateStyle }

2.2.3 attributesModule

处理attrs,暴露createupdate两个钩子。逻辑比较简单,主要diff新旧attrs属性值,然后调用DOM节点的removeAttribute方法修改attr

javascript 复制代码
function updateAttributes(oldVnode, newVnode) {
  let oldAttrs = oldVnode.data?.attrs
  let newAttrs = newVnode.data?.attrs
  const elm = newVnode.elm
  if (!oldAttrs && !newAttrs) return
  if (oldAttrs === newAttrs) return
  oldAttrs = oldAttrs || {}
  newAttrs = newAttrs || {}
  for (const key in oldAttrs) {
    if (!Object.prototype.hasOwnProperty.call(newAttrs, key)) {
      elm.removeAttribute(key)
    }
  }
  for (const key in newAttrs) {
    const value = newAttrs[key]
    if (value !== oldAttrs[key]) {
      if (value === true) {
        elm.setAttribute(key, '')
      } else if (value === false) {
        elm.removeAttribute(key)
      } else {
        elm.setAttribute(key, value)
      }
    }
  }
}

export const attributesModule = {
  create: updateAttributes,
  update: updateAttributes,
}

2.2.4 eventListenersModule

处理eventlistener,核心逻辑是提供一个装饰的listener方法,作为统一的事件类型监听回调方法。

该方法主要会记录当前vnode节点,当触发事件时,会通过event获取事件类型,然后跟vnode节点的on属性进行比对,如果有对应的事件类型回调则调用执行。

javascript 复制代码
function invokeHandler(handler, vnode, event) {
  if (typeof handler === 'function') {
    handler.call(vnode, event)
  } else if (typeof handler === 'object') {
    for (let i = 0; i < handler.length; i++) {
      invokeHandler(handler[i], vnode, event)
    }
  }
}

function handleEvent(event, vnode) {
  const name = event.type
  const on = vnode.data?.on
  if (on && on[name]) {
    invokeHandler(on[name], vnode, event)
  }
}

function createListener() {
  return function handler(event) {
    handleEvent(event, handler.vnode)
  }
}

function updateEventListeners(oldVnode, newVnode) {
  const oldOn = oldVnode.data?.on
  const oldListener = oldVnode.listener
  const oldElm = oldVnode.elm
  const on = newVnode?.data?.on
  const elm = newVnode?.elm
  if (oldOn === on) return
  if (oldOn && oldListener) {
    if (!on) {
      for (const name in oldOn) {
        oldElm.removeEventListener(name, oldListener, false)
      }
    } else {
      for (const name in oldOn) {
        if (!on[name]) {
          oldElm.removeEventListener(name, oldListener, false)
        }
      }
    }
  }
  if (on) {
    const listener = (newVnode.listener = oldListener || createListener())
    listener.vnode = newVnode
    if (!oldOn) {
      for (const name in on) {
        elm.addEventListener(name, listener, false)
      }
    } else {
      for (const name in on) {
        if (!oldOn[name]) {
          elm.addEventListener(name, listener, false)
        }
      }
    }
  }
}

export const eventListenersModule = {
  create: updateEventListeners,
  update: updateEventListeners,
  destroy: updateEventListeners,
}

2.3 定义init

init方法主要负责收集module的生命周期函数,返回patch方法

javascript 复制代码
function init(modules) {
  const cbs = {
    create: [],
    update: [],
    destroy: [],
  }

  for (const hook in cbs) {
    for (const module of modules) {
      const cb = module[hook]
      if (cb) cbs[hook].push(cb)
    }
  }
  
  return function patch(oldVnode, newVnode) {}
}

2.4 定义patch

patch方法主要负责挂载vnode对应DOM节点以及更新DOM,接收两个入参,第一个是旧vnode,第二个是新vnode,通过diff新旧vnode完成视图更新。

需要注意的是第一个vnode可以是真实DOM节点,会将其转换成vnode

javascript 复制代码
function patch(oldVnode, newVnode) {
  // 将DOM节点转成vnode
  if (oldVnode.nodeType === Node.ELEMENT_NODE) {
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 如果新旧vnode相同,则进行diff,更新对应节点
  if (sameVnode(oldVnode, newVnode)) {
    patchVnode(oldVnode, newVnode)
  } else {
    const elm = oldVnode.elm
    const parent = elm.parentNode
    // 创建vnode对应的DOM节点
    createElm(newVnode)
    // 将新DOM节点插入到页面指定节点
    parent.insertBefore(newVnode.elm, elm.nextSibling)
    // 移除旧vnode对应的DOM节点
    removeVnodes(parent, [oldVnode], 0, 0)
  }
}

2.4.1 emptyNodeAt

emptyNodeAt方法逻辑比较简单,即创建DOM节点对应的vnode节点。

javascript 复制代码
function emptyNodeAt(elm) {
  const id = elm.id ? `#${elm.id}` : ''
  return vnode(elm.tagName.toLowerCase() + id, {}, [], undefined, elm)
}

2.4.2 createElm

  • 根据vnode.sel创建对应类型的DOM节点
  • 调用modulecreate钩子
  • 递归创建vnode.children对应的DOM节点
javascript 复制代码
function createElm(vnode) {
  if (vnode.sel === '') {
    vnode.elm = document.createTextNode(vnode.text)
  } else {
    const tag = vnode.sel
    const elm = (vnode.elm = document.createElement(tag))
    const text = vnode.text
    const children = vnode.children
    // 调用module的create钩子
    for (let i = 0; i < cbs.create.length; i++)
      cbs.create[i](emptyNode, vnode)
    if (
      (typeof text === 'string' && !Array.isArray(children)) ||
      children.length === 0
    ) {
      elm.appendChild(document.createTextNode(text))
    }
    if (Array.isArray(children)) {
      for (let i = 0; i < children.length; i++) {
        elm.appendChild(createElm(children[i]))
      }
    }
  }
  return vnode.elm
}

2.4.3 removeVnodes

  • 调用moduledestroy钩子
  • 移除DOM节点
javascript 复制代码
function invokeDestroyHook(vnode) {
  if (vnode.data) {
    for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
    if (vnode.children) {
      for (let i = 0; i < vnode.children.length; i++) {
        if (typeof vnode.children[i] !== 'string') {
          invokeDestroyHook(vnode.children[i])
        }
      }
    }
  }
}

function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const vnode = vnodes[startIdx]
    if (vnode.sel) {
      invokeDestroyHook(vnode)
    }
    parentElm.removeChild(vnode.elm)
  }
}

2.4.4 addVnodes

创建vnode对应的DOM节点,插入到DOM树中。

javascript 复制代码
function addVnodes(parentElm, before, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    if (vnodes[startIdx]) {
      parentElm.insertBefore(createElm(vnodes[startIdx]), before)
    }
  }
}

2.4.5 patchVnode

diff核心逻辑如下:

  • 新节点是文本节点且新旧节点文本不一致,则移除旧vnode.children对应的DOM节点,更新节点文本内容。

  • 新节点非文本节点

    • 如果新旧vnode.children都存在,则diff新旧vnode.children
    • 如果新vnode.children存在,旧vnode.children不存在,则添加新vnode.children对应的DOM节点
    • 如果旧vnode.children存在,新vnode.children不存在,则删除旧vnode.children对应的DOM节点
    • 如果新旧vnode.children都不存在,判断旧vnode是否为文本节点,是则清空文本内容
javascript 复制代码
function patchVnode(oldVnode, newVnode) {
  if (oldVnode === newVnode) return
  const elm = (newVnode.elm = oldVnode.elm)
  if (newVnode.data || (newVnode.text && newVnode.text === oldVnode.text)) {
    newVnode ??= {}
    oldVnode ??= {}
    for (let i = 0; i < cbs.update.length; i++)
      cbs.update[i](oldVnode, newVnode)
  }
  const oldChild = oldVnode.children
  const newChild = newVnode.children
  if (newVnode.text === undefined) {
    if (oldChild && newChild) {
      if (oldChild !== newChild) updateChildren(elm, oldChild, newChild)
    } else if (newChild) {
      if (oldChild.text !== undefined) elm.textContent = ''
      addVnodes(elm, null, newChild, 0, newChild.length - 1)
    } else if (oldChild) {
      removeVnodes(elm, oldChild, 0, oldChild.length - 1)
    } else if (oldChild.text !== undefined) {
      elm.textContent = ''
    }
  } else if (oldVnode.text !== newVnode.text) {
    if (oldChild) {
      removeVnodes(elm, oldChild, 0, oldChild.length - 1)
    }
    elm.textContent = newVnode.text
  }
}

2.4.6 updateChildren

首先设置oldStartIdxoldEndIdx指向旧vnode.children的首尾元素下标,oldStartVnodeoldEndVnode指向旧vnode.children的首尾元素,newStartIdxnewEndIdx执行新vnode.children的首尾元素下标,newStartVnodenewEndVnode执行新vnode.children的首尾元素。

接着判断新旧vnode.children元素是否相同,会有以下四种比较:

  • oldStartVnodenewStartVnode
  • oldEndVnodenewEndVnode
  • oldStartVnodeoldEndVnode
  • newStartVnodenewEndVnode

如果上述其一满足条件,则调用patchVnode方法进行下一轮diff,然后移动对应指针。

如果上述条件都不满足,则获取旧vnode.childrenkeyindex的映射关系,然后进行以下判断:

  • 判断新newStartVnode.key在旧映射关系中是否有对应索引,如果没有说明是新vnode,则创建对应的DOM节点,然后插入到对应位置中
  • 判断新newEndVnode.key在旧映射关系中是否有对应索引,如果没有说明是新vnode,则创建对应的DOM节点,然后插入到对应位置中
  • 获取新newStartVnode对应的旧vnode,判断sel是否相同,不相同说明则直接创建newStartVnode对应的DOM节点,插入到对应位置中。相同则调用patchVnode方法进行下一轮diff,然后调整旧vnodeDOM节点位置
javascript 复制代码
// 建立vnode.key -> index映射关系
function createKeyToOldIdx(children, beginIdx, endIdx) {
  const map = {}
  for (let i = beginIdx; i <= endIdx; i++) {
    const key = children[i]?.key
    if (key !== undefined) {
      map[key] = i
    }
  }
  return map
}

function updateChildren(parentElm, oldChild, newChild) {
  let oldStartIdx = 0
  let oldEndIdx = oldChild.length - 1
  let newStartIdx = 0
  let newEndIdx = newChild.length - 1
  let oldStartVnode = oldChild[oldStartIdx]
  let oldEndVnode = oldChild[oldEndIdx]
  let newStartVnode = newChild[newStartIdx]
  let newEndVnode = newChild[newEndIdx]
  let oldKeyToIdx
  let idxInOld
  let elmToMove
  let before

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldChild[++oldStartIdx]
    } else if (oldEndVnode == null) {
      oldEndVnode = oldChild[--oldEndIdx]
    } else if (newStartVnode == null) {
      newStartVnode = newChild[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newChild[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldChild[++oldStartIdx]
      newStartVnode = newChild[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldChild[--oldEndIdx]
      newEndVnode = newChild[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      // 把旧start vnode对应的DOM节点移到末尾
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm)
      oldStartVnode = oldChild[++oldStartIdx]
      newEndVnode = newChild[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      // 把旧end vnode对应的DOM节点移到开头
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldChild[--oldEndIdx]
      newStartVnode = newChild[++newStartIdx]
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldChild, oldStartIdx, oldEndIdx)
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      if (idxInOld === undefined) {
        // `newStartVnode` is new, create and insert it in beginning
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
        newStartVnode = newChild[++newStartIdx]
      } else if (oldKeyToIdx[newEndVnode.key] === undefined) {
        // `newEndVnode` is new, create and insert it in the end
        parentElm.insertBefore(
          createElm(newEndVnode),
          oldEndVnode.elm.nextSibling,
        )
        newEndVnode = newChild[--newEndIdx]
      } else {
        elmToMove = oldChild[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
        } else {
          patchVnode(elmToMove, newStartVnode)
          oldChild[idxInOld] = undefined
          parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
        }
        newStartVnode = newChild[++newStartIdx]
      }
    }
  }

  if (newStartIdx <= newEndIdx) {
    before =
      newChild[newEndIdx + 1] == null ? null : newChild[newEndIdx + 1].elm
    addVnodes(parentElm, before, newChild, newStartIdx, newEndIdx)
  }

  if (oldStartIdx <= oldEndIdx) {
    removeVnodes(parentElm, oldChild, oldStartIdx, oldEndIdx)
  }
}

三. 总结

snabbdom通过虚拟DOM操作视图更新,自定义diff算法,值得借鉴学习。

相关推荐
Fantasywt3 小时前
THREEJS 片元着色器实现更自然的呼吸灯效果
前端·javascript·着色器
IT、木易4 小时前
大白话JavaScript实现一个函数,将字符串中的每个单词首字母大写。
开发语言·前端·javascript·ecmascript
Mr.NickJJ5 小时前
JavaScript系列06-深入理解 JavaScript 事件系统:从原生事件到 React 合成事件
开发语言·javascript·react.js
张拭心6 小时前
2024 总结,我的停滞与觉醒
android·前端
念九_ysl6 小时前
深入解析Vue3单文件组件:原理、场景与实战
前端·javascript·vue.js
Jenna的海糖6 小时前
vue3如何配置环境和打包
前端·javascript·vue.js
Mr.NickJJ6 小时前
React Native v0.78 更新
javascript·react native·react.js
星之卡比*6 小时前
前端知识点---库和包的概念
前端·harmonyos·鸿蒙
灵感__idea6 小时前
Vuejs技术内幕:数据响应式之3.x版
前端·vue.js·源码阅读
烛阴7 小时前
JavaScript 构造器进阶:掌握 “new” 的底层原理,写出更优雅的代码!
前端·javascript