淺析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元素時,幫助咱們進行了一系列操做優化處理,封裝了實際操做邏輯。以及提供了一系列插件可供咱們使用。


這是本人的第一次寫這樣的文章,寫得有很差的地方歡迎你們批評指證!😄

相關文章
相關標籤/搜索