浅析Virtual DOM库 - snabbdom的源码以及 仿写一个本身的vDOM库

原本打算看下virtualDOM的实现原理,但看到许多文章都只是在讲原理,不多有对vDOM库的源码的分析,今天打算尝试着从本身的角度出发,写一篇源码解析的文章css

首先请出今天的主角——Vue2的vDOM所基于的库,snabbdom,github地址以下html

GitHub: github.com/snabbdom/sn…vue


1、类型

首先咱们来看下他的类型定义node

vNode类型react

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?: Array<any>; // for thunks
  [key: string]: any; // for any other 3rd party module
}

// Recode的含义(至关于定义了key和value的类型)
// const user: Record<'name'|'email', string> = {
//   name: '', 
//   email: ''
// }

type Props = Record<string, any>;

type Classes = Record<string, boolean>

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


interface Hooks {
  pre?: PreHook;
  init?: InitHook;
  create?: CreateHook;
  insert?: InsertHook;
  prepatch?: PrePatchHook;
  update?: UpdateHook;
  postpatch?: PostPatchHook;
  destroy?: DestroyHook;
  remove?: RemoveHook;
  post?: PostHook;
}
复制代码

能够看到snabbdom定义的虚拟dom节点并不像许多Vue里面所定义的同样, 他有一系列的符合咱们认知的诸如class,attrs等属性,但同时他又给咱们提供了hook,让咱们能够在更新节点是对他进行操做git

2、方法

先看下官方给咱们的示例github

var snabbdom = require('snabbdom')
var patch = snabbdom.init([ // Init patch function with chosen modules
  require('snabbdom/modules/class').default, // makes it easy to toggle classes
  require('snabbdom/modules/props').default, // for setting properties on DOM elements
  require('snabbdom/modules/style').default, // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);
var h = require('snabbdom/h').default; // helper function for creating vnodes
var toVNode = require('snabbdom/tovnode').default;

var newVNode = h('div', {style: {color: '#000'}}, [
  h('h1', 'Headline'),
  h('p', 'A paragraph'),
]);

patch(toVNode(document.querySelector('.container')), newVNode)
复制代码

很方便,定义一个节点以及一个更新时函数就能够正常使用了,下面咱们来看下具体这些方法都作了什么api

h的实现

function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  if (c !== undefined) {
    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) {
    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);
};

// addNs
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) {
      let childData = children[i].data;
      if (childData !== undefined) {
        addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
      }
    }
  }
}

// vnode
function vnode(sel: string | undefined,
               data: any | undefined,
               children: Array<VNode | string> | undefined,
               text: string | undefined,
               elm: Element | Text | undefined
              ): VNode {
  let key = data === undefined ? undefined : data.key;
  return {sel, data, children, text, elm, key};
}
复制代码

能够看到所作的无非就是对你的输入作一些判断(可变参数),以及对一些拥有本身特殊的命名空间(svg)的元素的处理数组

init函数实现

init接受插件和可选的domAPI属性,返回一个函数用于更新dombash

init(modules: Array<Partial<Module>>, domApi?: DOMAPI)
复制代码

第一个参数接受一系列插件用于更新dom

// Partial 将全部类型标记为可选属性
interface Module {
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}
复制代码

看一个插件的源码

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

export type Classes = Record<string, boolean>

function updateClass(oldVnode: VNode, vnode: VNode): void {
  var cur: any, name: string, elm: Element = vnode.elm as Element,
      oldClass = (oldVnode.data as VNodeData).class,
      klass = (vnode.data as VNodeData).class;

  if (!oldClass && !klass) return;
  if (oldClass === klass) return;
  oldClass = oldClass || {};
  klass = klass || {};

  for (name in oldClass) {
    if (!klass[name]) {
      elm.classList.remove(name);
    }
  }
  for (name in klass) {
    cur = klass[name];
    if (cur !== oldClass[name]) {
      (elm.classList as any)[cur ? 'add' : 'remove'](name);
    }
  }
}

export const classModule = {create: updateClass, update: updateClass} as Module;
export default classModule;

复制代码

插件是在patch函数运行时的提供的各个hook对dom进行实际操做的动做 那么插件是怎么装载进patch的呢?咱们再来看一下init函数具体操做了什么

const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number, j: number, cbs = ({} as ModuleHooks);

  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;

  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 Array<any>).push(hook);
      }
    }
  }
  // ...
  return function patch() {
    // ...
  }
}
复制代码

就是用闭包把这些方法存起来,在运行时再一一调用

再看patch函数(由init方法返回的用于更新dom的函数)

  • 若是判断是不一样的VNode则根据新的VNode建立DOM替换旧的VNode节点
  • 若是判断是同一个vNode则会运行patchNode的方法(对原有的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](); // 执行钩子: pre

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

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

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); // 执行钩子: post
    return vnode;
  };
复制代码

以后咱们再看下pathVnode进行了什么操做

  • 主要执行的是更新操做是由 update 这个hook来提供的,以后再对子节点进行更新或者增删等操做
  • update及上面init函数初始化时所传入的处理函数,在这一步对实际元素进行了处理
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
  i(oldVnode, vnode); // 执行钩子: prepatch(定义在VNode上)
}
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
  for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); // 执行钩子: update
  i = vnode.data.hook;
  if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}

// 
if (isUndef(vnode.text)) {
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
  } else if (isDef(ch)) {
    if (isDef(oldVnode.text)) api.setTextContent(elm, '');
    addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
  } else if (isDef(oldCh)) {
    removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
  } else if (isDef(oldVnode.text)) {
    api.setTextContent(elm, '');
  }
} else if (oldVnode.text !== vnode.text) {
  if (isDef(oldCh)) {
    removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
  }
  api.setTextContent(elm, vnode.text as string);
}
if (isDef(hook) && isDef(i = hook.postpatch)) { // 执行钩子: postpatch(定义在VNode上)
  i(oldVnode, vnode);
}
}
复制代码

咱们最后来看下snabbdom是怎么处理子元素的更新的,能够总结为:

  • 若是有某一个vNode不存在,则变动VNode的指向位置,缩小处理范围
  • 若是新旧VNode的两个子节点都存在且是同一个节点,就会递归调用patchVnode的方法
  • 若是当前操做的新VNode子节点等于旧VNode子节点,则表明子节点位置被移动了,会进行插入的操做
  • 若是以上状况都不符合,则会判断新VNode的子节点是否存在于旧VNode的未操做子节点中。若是不存在,则断定为新的节点,会新建一个DOM执行插入操做;若存在,sel相同,则执行更新操做后插入,若sel不一样则直接新建子节点
  • 退出上述循环后,若新的VNode或者旧的VNode有剩余的未操做的子节点,则会继续进行插入或者删除的节点操做
function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {
    let oldStartIdx = 0, 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: any;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; 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)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx]; // 以上两个则是对元素移动状况的处理
      }  else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) { // 判断新的vNode是否存在旧的vNode的中,执行新增或者移动的操做
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
          }
          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);
      }
    }
  }
复制代码

以上就是snabbdom在对节点进行更新时的主要操做,能够概括为

  • 对元素自己的更新行为是由init时传入的函数来进行操做,此处理只关心元素自己属性的更新
  • 元素的位置是以及是否进行新增或者更新的操做是有snabbdom来进行处理的,此处理只针对元素的位置和增删并不关心元素自己的更新

3、仿写

了解snabbdom的行为后,咱们能够进行简单(不考虑特殊状况,只简单实现功能)的仿写来练练手以及加深理解

1. 首先定义一下vNode的类型

const NODE_KEY = Symbol('vNode')

type Style = {
  [key: string]: string
}

export type vNodeModal = {
  tag: string
  class?: string
  id?: string
  style?: Style
  [NODE_KEY]: string
  elem?: Element
  children?: Array<vNodeModal | string>
}
复制代码

这里我用symbol来作惟一的标示方便准确判断是不是vNode以及vNode是否相同

export const isVNode = (elem: vNodeModal | Element) => Boolean(elem && elem[NODE_KEY])

export const isSameNode = (node: vNodeModal, otcNode: vNodeModal) =>  node[NODE_KEY] === otcNode[NODE_KEY]

复制代码

2. 定义构造函数

我把tag定义为的必填属性,key为的私有属性,由我来帮它建立

const constructVNode = function(data: Partial<vNodeModal> & { tag: string }) {
  return {
    ...data,
    [NODE_KEY]: uuid()
  }
}
复制代码

3. 定义更新函数

我把的更新处理函数称为plugin,好理解一些,因此plugin和这个简单的vNode库是毫无关系的,纯粹由外部提供

const init = function (plugins = []) {
  if (!plugins || !plugins.length) return null

  // 把hook存起来
  hooks.forEach(function(hook) {
    plugins.forEach(function(plugin) {
      if (plugin[hook]) {
        handler[hook] ? handler[hook].push(plugin[hook]) : handler[hook] = [plugin[hook]]
      }
    })
  })

  return function(ctrlNode: Element | vNodeModal, newVNode: vNodeModal) {
    let oldVNode = ctrlNode
    if (!isVNode(ctrlNode)) oldVNode = transformToVNode(ctrlNode as Element)
  
    if (handler.pre) {
      handler.pre.map((preHandle) => { preHandle(oldVNode, newVNode) })
    }

    updateNode(oldVNode as vNodeModal, newVNode)

    if (handler.finish) {
      handler.finish.map((finishHandle) => { finishHandle(oldVNode, newVNode) })
    }

    return newVNode
  }
}
复制代码

接下来是更新处理判断的函数

// 简单判断不是同一个vNode节点或者tag变动了就直接所有更新
const updateNode = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
  if (!isSameVNode(oldVNode as vNodeModal, newVNode) || isTagChange(oldVNode, newVNode)) {
    const newElement = createDOMByVNode(newVNode)
    oldVNode.elem.replaceWith(newElement)
  } else {
    updateVNodeByModal(oldVNode, newVNode)
  }
}

// 根据VNode去更新dom
const updateVNodeByModal = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
  if (handler.update.length) {
    handler.update.forEach((updateHandle) => { updateHandle(oldVNode, newVNode) })
  }
  
  // 更新完元素自己后对子元素进行处理
  const oCh = oldVNode.children || []
  const nCh = newVNode.children || []

  if (oCh.length && !nCh.length) {
    removeAllChild(oldVNode.elem)
  } else if (!oCh.length && nCh.length) {
    inertNode(newVNode.elem, nCh)
  } else if (oCh.length && nCh.length) {
    diff(oldVNode, newVNode)

    for(let i = 0; i < nCh.length; i++) {
      if (isVNode(nCh[i])) {
        const idx = oCh.findIndex((oChild) => isSameVNode(nCh[i], oChild))
        if (idx > - 1) updateNode(oCh[idx] as vNodeModal, nCh[i] as vNodeModal)
      }
    }
  }
}

// 对子元素的diff
const diff = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
  // 具体处理
  const oCh = oldVNode.children
  const nCh = newVNode.children
  const nLen = nCh.length

  let lastIdx = 0

  const getIndex = function(checkArray: Array<vNodeModal | string>, item: vNodeModal | string) {
    if (isVNode(item)) {
      return checkArray.findIndex(o => isSameVNode(o as vNodeModal, item as vNodeModal))
    } else {
      return checkArray.findIndex(o => o === item)
    }
  }

  // 参考react的diff策略,但字符串不考虑
  for (let i = 0; i < nLen; i++) {
    const oldIdx = getIndex(oCh, nCh[i])
    if (oldIdx > -1) {
      if (oldIdx < lastIdx) {
        if (typeof oCh[oldIdx] === 'string') {
          oldVNode.elem.childNodes[oldIdx].remove()
        }
        getElement(oCh[i]).after(getElement(oCh[oldIdx]))
      }
      lastIdx = Math.max(oldIdx, lastIdx)
    } else {
      const newElem = createDOMByVNode(nCh[i])
      if (i === 0) (oldVNode as vNodeModal).elem.parentElement.prepend(newElem)
      else {
        if (typeof nCh[i] === 'string') (oldVNode as vNodeModal).elem.childNodes[i].after(newElem)
        else getElement(nCh[i]).after(newElem)
      }
    }
  }

  for (let i = 0; i < oldVNode.children.length; i++) {
    const idx = getIndex(nCh, oCh[i])
    if (idx < 0) {
      if (typeof oCh[i] === 'string') {
        oldVNode.elem.childNodes[i].remove()
      } else {
        (oCh[i] as vNodeModal).elem.remove()
      }
    }
  }
}
复制代码

4. 编写插件

再来写一个用于更新class的插件

const getClassList = (className: string) => className ? className.split('.') : []

const updateClassName = function (oldVNode: vNodeModal, newVNode: vNodeModal) {
  const elem = newVNode.elem
  if (!elem) return
  const oldClassList = getClassList(oldVNode.class)
  const newClassList = getClassList(newVNode.class)
  if (!newClassList.length) return
  oldClassList.forEach((className) => {
    if (!newClassList.includes(className)) {
      elem.classList.remove(className)
    } else {
      newClassList.splice(newClassList.indexOf(className), 1)
    }
  })
  newClassList.forEach((className) => elem.classList.add(className))
}

const updateClassPlugin = {
  update: updateClassName
}
复制代码

5. 使用

使用的时候这么写

import init from './tools/init'
import transFromClass from './tools/plugins/class'

import './style.css'

const inp1 = document.querySelector('#first')

const newV = constructVNode({
  tag: 'div',
  class: 'haha.mama',
  id: 'no',
  children: [
    'lalala',
    constructVNode({
      tag: 'input',
      class: 'asdad',
      id: '123'
    })
  ]
})

// 插入子元素
const patch = init([transFromClass])

let newModal = patch(inp1, newV)

// 交换子元素位置
setTimeout(() => {
  const changPosModal = {
    ...newModal,
    children: [newModal.children[1], newV.children[0]]
  }
  
  newModal = patch(newModal, changPosModal)
}, 500)

// 修改子元素属性
setTimeout(() => {
  const newChildren0 = {
    ...newModal.children[0] as vNodeModal,
    class: 'newChildren0'
  }
  
  const changClassModal = {
    ...newModal,
    children: [newChildren0, newModal.children[1] + 'juejin']
  }


  newModal = patch(newModal, changClassModal)
}, 1000)

// 删除子元素
setTimeout(() => {
  const deleteChildrenModal = {
    ...newModal,
    children: []
  }

  newModal = patch(newModal, deleteChildrenModal)
}, 1500)

复制代码

最后看看结果:

  • 原HTML结构
  • 定义咱们的颜色,方便看
  • 运行,看结果

这样,就实现了一个很是简单vDOM的处理(缺失对边界的处理,特殊元素处理等)

snabbdom作的最主要的事情就是使dom的结构变得更加清晰容易掌控,在咱们更新dom元素时,帮助咱们进行了一系列操做优化处理,封装了实际操做逻辑。以及提供了一系列插件可供咱们使用。


这是本人的第一次写这样的文章,写得有很差的地方欢迎你们批评指证!😄

相关文章
相关标签/搜索