snabbdom 源碼閱讀分析

做者: steins from 迅雷前端html

原文地址:github.com/linrui1994/…前端

隨着 React Vue 等框架的流行,Virtual DOM 也愈來愈火,snabbdom 是其中一種實現,並且 Vue 2.x 版本的 Virtual DOM 部分也是基於 snabbdom 進行修改的。snabbdom 這個庫核心代碼只有 200 多行,很是適合想要深刻了解 Virtual DOM 實現的讀者閱讀。若是您沒據說過 snabbdom,能夠先看看官方文檔vue

爲何選擇 snabbdom

  • 核心代碼只有 200 行,豐富的測試用例
  • 強大的插件系統、hook 系統
  • vue 使用了 snabbdom,讀懂 snabbdom 對理解 vue 的實現有幫助

什麼是 Virtual DOM

snabbdom 是 Virtual DOM 的一種實現,因此在此以前,你須要先知道什麼是 Virtual DOM。通俗的說,Virtual DOM 就是一個 js 對象,它是真實 DOM 的抽象,只保留一些有用的信息,更輕量地描述 DOM 樹的結構。 好比在 snabbdom 中,是這樣來定義一個 VNode 的:node

export interface VNode {
  sel: string | undefined;
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  elm: Node | undefined;
  text: string | undefined;
  key: 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?: Array<any>; // for thunks
  [key: string]: any; // for any other 3rd party module
}
複製代碼

從上面的定義咱們能夠看到,咱們能夠用 js 對象來描述 dom 結構,那咱們是否是能夠對兩個狀態下的 js 對象進行對比,記錄出它們的差別,而後把它應用到真正的 dom 樹上呢?答案是能夠的,這即是 diff 算法,算法的基本步驟以下:git

  • 用 js 對象來描述 dom 樹結構,而後用這個 js 對象來建立一棵真正的 dom 樹,插入到文檔中
  • 當狀態更新時,將新的 js 對象和舊的 js 對象進行比較,獲得兩個對象之間的差別
  • 將差別應用到真正的 dom 上

接下來咱們來分析這整個過程的實現。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 container = document.getElementById('container');

var vnode = h('div#container.two.classes', { on: { click: someFn } }, [
  h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
  ' and this is just normal text',
  h('a', { props: { href: '/foo' } }, "I'll take you places!")
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

var newVnode = h('div#container.two.classes', { on: { click: anotherEventHandler } }, [
  h('span', { style: { fontWeight: 'normal', fontStyle: 'italic' } }, 'This is now italic type'),
  ' and this is still just normal text',
  h('a', { props: { href: '/bar' } }, "I'll take you places!")
]);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
複製代碼

首先 snabbdom 模塊提供一個 init 方法,它接收一個數組,數組中是各類 module,這樣的設計使得這個庫更具擴展性,咱們也能夠實現本身的 module,並且能夠根據本身的須要引入相應的 module,好比若是不須要寫入 class,那你能夠直接把 class 的模塊移除。 調用 init 方法會返回一個 patch 函數,這個函數接受兩個參數,第一個是舊的 vnode 節點或是 dom 節點,第二個參數是新的 vnode 節點,調用 patch 函數會對 dom 進行更新。vnode 能夠經過使用h函數來生成。使用起來至關簡單,這也是本文接下來要分析的內容。typescript

init 函數

export interface Module {
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}

export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  // cbs 用於收集 module 中的 hook
  let i: number,
    j: number,
    cbs = {} as ModuleHooks;

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

  // 收集 module 中的 hook
  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);
      }
    }
  }

  function emptyNodeAt(elm: Element) {
    // ...
  }

  function createRmCb(childElm: Node, listeners: number) {
    // ...
  }

  // 建立真正的 dom 節點
  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // ...
  }

  function addVnodes( parentElm: Node, before: Node | null, vnodes: Array<VNode>, startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue ) {
    // ...
  }

  // 調用 destory hook
  // 若是存在 children 遞歸調用
  function invokeDestroyHook(vnode: VNode) {
    // ...
  }

  function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void {
    // ...
  }

  function updateChildren(parentElm: Node, oldCh: Array<VNode>, newCh: Array<VNode>, insertedVnodeQueue: VNodeQueue) {
    // ...
  }

  function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // ...
  }

  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    // ...
  };
}
複製代碼

上面是 init 方法的一些源碼,爲了閱讀方便,暫時先把一些方法的具體實現給註釋掉,等有用到的時候再具體分析。 經過參數能夠知道,這裏有接受一個 modules 數組,另外有一個可選的參數 domApi,若是沒傳遞會使用瀏覽器中和 dom 相關的 api,具體能夠看這裏,這樣的設計也頗有好處,它可讓用戶自定義平臺相關的 api,好比能夠看看weex 的相關實現 。首先這裏會對 module 中的 hook 進行收集,保存到 cbs 中。而後定義了各類函數,這裏能夠先無論,接着就是返回一個 patch 函數了,這裏也先不分析它的具體邏輯。這樣 init 就結束了。api

h 函數

根據例子的流程,接下來看看h方法的實現數組

export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export 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;
    }
  }
  // 若是存在 children,將不是 vnode 的項轉成 vnode
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }
  // svg 元素添加 namespace
  if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' && (sel.length === 3 || sel[3] === '.' || sel[3] === '#')) {
    addNS(data, children, sel);
  }
  // 返回 vnode
  return vnode(sel, data, children, text, undefined);
}

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);
      }
    }
  }
}

export 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: sel,
    data: data,
    children: children,
    text: text,
    elm: elm,
    key: key
  };
}
複製代碼

由於 h 函數後兩個參數是可選的,並且有各類傳遞方式,因此這裏首先會對參數進行格式化,而後對 children 屬性作處理,將可能不是 vnode 的項轉成 vnode,若是是 svg 元素,會作一個特殊處理,最後返回一個 vnode 對象。

patch 函數

patch 函數是 snabbdom 的核心,調用 init 會返回這個函數,用來作 dom 相關的更新,接下來看看它的具體實現。

function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node;
  const insertedVnodeQueue: VNodeQueue = [];
  // 調用 module 中的 pre hook
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

  // 若是傳入的是 Element 轉成空的 vnode
  if (!isVnode(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode);
  }

  // sameVnode 時 (sel 和 key相同) 調用 patchVnode
  if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode, insertedVnodeQueue);
  } else {
    elm = oldVnode.elm as Node;
    parent = api.parentNode(elm);

    // 建立新的 dom 節點 vnode.elm
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      // 插入 dom
      api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
      // 移除舊 dom
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }

  // 調用元素上的 insert hook,注意 insert hook 在 module 上不支持
  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
  }

  // 調用 module post hook
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
  return vnode;
}

function emptyNodeAt(elm: Element) {
  const id = elm.id ? '#' + elm.id : '';
  const c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
  return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
}

// key 和 selector 相同
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
複製代碼

首先會調用 modulepre hook,你可能會有疑惑,爲何沒有調用來自各個元素的 pre hook,這是由於元素上不支持 pre hook,也有一些 hook 不支持在 module 中,具體能夠查看這裏的文檔。而後會判斷傳入的第一個參數是否爲 vnode 類型,若是不是,會調用 emptyNodeAt 而後將其轉換成一個 vnodeemptyNodeAt 的具體實現也很簡單,注意這裏只是保留了 classstyle,這個和 toVnode 的實現有些區別,由於這裏並不須要保存不少信息,好比 prop attribute 等。接着調用 sameVnode 來判斷是否爲相同的 vnode 節點,具體實現也很簡單,這裏只是判斷了 keysel 是否相同。若是相同,調用 patchVnode,若是不相同,會調用 createElm 來建立一個新的 dom 節點,而後若是存在父節點,便將其插入到 dom 上,而後移除舊的 dom 節點來完成更新。最後調用元素上的 insert hookmodule 上的 post hook。 這裏的重點是 patchVnodecreateElm 函數,咱們先看 createElm 函數,看看是如何來建立 dom 節點的。

createElm 函數

// 建立真正的 dom 節點
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  let i: any, data = vnode.data;

  // 調用元素的 init hook
  if (data !== undefined) {
    if (isDef(i = data.hook) && isDef(i = i.init)) {
      i(vnode);
      data = vnode.data;
    }
  }
  let children = vnode.children, sel = vnode.sel;
  // 註釋節點
  if (sel === '!') {
    if (isUndef(vnode.text)) {
      vnode.text = '';
    }
    // 建立註釋節點
    vnode.elm = api.createComment(vnode.text as string);
  } 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 as VNodeData).ns) ? api.createElementNS(i, tag)
                                                                             : api.createElement(tag);
    if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
    if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));

    // 調用 module 中的 create hook
    for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);

    // 掛載子節點
    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)) {
      api.appendChild(elm, api.createTextNode(vnode.text));
    }
    i = (vnode.data as VNodeData).hook; // Reuse variable
    // 調用 vnode 上的 hook
    if (isDef(i)) {
      // 調用 create hook
      if (i.create) i.create(emptyNode, vnode);
      // insert hook 存儲起來 等 dom 插入後纔會調用,這裏用個數組來保存能避免調用時再次對 vnode 樹作遍歷
      if (i.insert) insertedVnodeQueue.push(vnode);
    }
  } else {
    // 文本節點
    vnode.elm = api.createTextNode(vnode.text as string);
  }
  return vnode.elm;
}
複製代碼

這裏的邏輯也很清晰,首先會調用元素的 init hook,接着這裏會存在三種狀況:

  • 若是當前元素是註釋節點,會調用 createComment 來建立一個註釋節點,而後掛載到 vnode.elm
  • 若是不存在選擇器,只是單純的文本,調用 createTextNode 來建立文本,而後掛載到 vnode.elm
  • 若是存在選擇器,會對這個選擇器作解析,獲得 tagidclass,而後調用 createElementcreateElementNS 來生成節點,並掛載到 vnode.elm。接着調用 module 上的 create hook,若是存在 children,遍歷全部子節點並遞歸調用 createElm 建立 dom,經過 appendChild 掛載到當前的 elm 上,不存在 children 但存在 text,便使用 createTextNode 來建立文本。最後調用調用元素上的 create hook 和保存存在 insert hookvnode,由於 insert hook 須要等 dom 真正掛載到 document 上纔會調用,這裏用個數組來保存能夠避免真正須要調用時須要對 vnode 樹作遍歷。

接着咱們來看看 snabbdom 是如何作 vnodediff 的,這部分是 Virtual DOM 的核心。

patchVnode 函數

這個函數作的事情是對傳入的兩個 vnodediff,若是存在更新,將其反饋到 dom 上。

function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  let i: any, hook: any;
  // 調用 prepatch hook
  if (isDef((i = vnode.data)) && isDef((hook = i.hook)) && isDef((i = hook.prepatch))) {
    i(oldVnode, 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) {
    // 調用 module 上的 update hook
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    i = vnode.data.hook;
    // 調用 vnode 上的 update hook
    if (isDef(i) && isDef((i = i.update))) i(oldVnode, vnode);
  }
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 新舊節點均存在 children,且不同時,對 children 進行 diff
      // thunk 中會作相關優化和這個相關
      if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
    } else if (isDef(ch)) {
      // 舊節點不存在 children 新節點有 children
      // 舊節點存在 text 置空
      if (isDef(oldVnode.text)) api.setTextContent(elm, '');
      // 加入新的 vnode
      addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      // 新節點不存在 children 舊節點存在 children 移除舊節點的 children
      removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
    } else if (isDef(oldVnode.text)) {
      // 舊節點存在 text 置空
      api.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    // 更新 text
    api.setTextContent(elm, vnode.text as string);
  }
  // 調用 postpatch hook
  if (isDef(hook) && isDef((i = hook.postpatch))) {
    i(oldVnode, vnode);
  }
}
複製代碼

首先調用 vnode 上的 prepatch hook,若是當前的兩個 vnode 徹底相同,直接返回。接着調用 modulevnode 上的 update hook。而後會分爲如下幾種狀況作處理:

  • 均存在 children 且不相同,調用 updateChildren
  • vnode 存在 children,舊 vnode 不存在 children,若是舊 vnode 存在 text 先清空,而後調用 addVnodes
  • vnode 不存在 children,舊 vnode 存在 children,調用 removeVnodes 移除 children
  • 均不存在 children,新 vnode 不存在 text,移除舊 vnodetext
  • 均存在 text,更新 text

最後調用 postpatch hook。整個過程很清晰,咱們須要關注的是 updateChildren addVnodes removeVnodes

updateChildren

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;

  // 遍歷 oldCh newCh,對節點進行比較和更新
  // 每輪比較最多處理一個節點,算法複雜度 O(n)
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 若是進行比較的 4 個節點中存在空節點,爲空的節點下標向中間推動,繼續下個循環
    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];
      // 新舊開始節點相同,直接調用 patchVnode 進行更新,下標向中間推動
    } 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];
      // 舊開始節點等於新的節點節點,說明節點向右移動了,調用 patchVnode 進行更新
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
      // 舊開始節點等於新的結束節點,說明節點向右移動了
      // 具體移動到哪,由於新節點處於末尾,因此添加到舊結束節點(會隨着 updateChildren 左移)的後面
      // 注意這裏須要移動 dom,由於節點右移了,而爲何是插入 oldEndVnode 的後面呢?
      // 能夠分爲兩個狀況來理解:
      // 1. 當循環剛開始,下標都尚未移動,那移動到 oldEndVnode 的後面就至關因而最後面,是合理的
      // 2. 循環已經執行過一部分了,由於每次比較結束後,下標都會向中間靠攏,並且每次都會處理一個節點,
      // 這時下標左右兩邊已經處理完成,能夠把下標開始到結束區域當成是並未開始循環的一個總體,
      // 因此插入到 oldEndVnode 後面是合理的(在當前循環來講,也至關因而最後面,同 1)
      api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
      // 舊的結束節點等於新的開始節點,說明節點是向左移動了,邏輯同上
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
      api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
      // 若是以上 4 種狀況都不匹配,可能存在下面 2 種狀況
      // 1. 這個節點是新建立的
      // 2. 這個節點在原來的位置是處於中間的(oldStartIdx 和 endStartIdx之間)
    } else {
      // 若是 oldKeyToIdx 不存在,建立 key 到 index 的映射
      // 並且也存在各類細微的優化,只會建立一次,而且已經完成的部分不須要映射
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      // 拿到在 oldCh 下對應的下標
      idxInOld = oldKeyToIdx[newStartVnode.key as string];
      // 若是下標不存在,說明這個節點是新建立的
      if (isUndef(idxInOld)) {
        // New element
        // 插入到 oldStartVnode 的前面(對於當前循環來講,至關於最前面)
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 若是是已經存在的節點 找到須要移動位置的節點
        elmToMove = oldCh[idxInOld];
        // 雖然 key 相同了,可是 seletor 不相同,須要調用 createElm 來建立新的 dom 節點
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        } else {
          // 不然調用 patchVnode 對舊 vnode 作更新
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          // 在 oldCh 中將當前已經處理的 vnode 置空,等下次循環到這個下標的時候直接跳過
          oldCh[idxInOld] = undefined as any;
          // 插入到 oldStartVnode 的前面(對於當前循環來講,至關於最前面)
          api.insertBefore(parentElm, elmToMove.elm as Node, oldStartVnode.elm as Node);
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
  }
  // 循環結束後,可能會存在兩種狀況
  // 1. oldCh 已經所有處理完成,而 newCh 還有新的節點,須要對剩下的每一個項都建立新的 dom
  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);
      // 2. newCh 已經所有處理完成,而 oldCh 還有舊的節點,須要將多餘的節點移除
    } else {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}
複製代碼

整個過程簡單來講,對兩個數組進行對比,找到相同的部分進行復用,並更新。整個邏輯可能看起來有點懵,能夠結合下面這個例子理解下:

  1. 假設舊節點順序爲[A, B, C, D],新節點爲[B, A, C, D, E]

snabbdom-1

  1. 第一輪比較:開始結束節點兩兩並不相等,因而看 newStartVnode 在舊節點中是否存在,最後找到了在第二個位置,調用 patchVnode 進行更新,將 oldCh[1] 至空,將 dom 插入到 oldStartVnode 前面,newStartIdx 向中間移動,狀態更新以下

snabbdom-2

  1. 第二輪比較:oldStartVnode 和 newStartVnode 相等,直接 patchVnode,newStartIdx 和 oldStartIdx 向中間移動,狀態更新以下

snabbdom-3

  1. 第三輪比較:oldStartVnode 爲空,oldStartIdx 向中間移動,進入下輪比較,狀態更新以下

snabbdom-4

  1. 第四輪比較:oldStartVnode 和 newStartVnode 相等,直接 patchVnode,newStartIdx 和 oldStartIdx 向中間移動,狀態更新以下

snabbdom-5

  1. oldStartVnode 和 newStartVnode 相等,直接 patchVnode,newStartIdx 和 oldStartIdx 向中間移動,狀態更新以下

snabbdom-6

  1. oldStartIdx 已經大於 oldEndIdx,循環結束,因爲是舊節點先結束循環並且還有沒處理的新節點,調用 addVnodes 處理剩下的新節點

addVnodes 和 removeVnodes 函數

function addVnodes(parentElm: Node, before: Node | null, vnodes: Array<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);
    }
  }
}

function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void {
  for (; startIdx <= endIdx; ++startIdx) {
    let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 調用 destory hook
        invokeDestroyHook(ch);
        // 計算須要調用 removecallback 的次數 只有所有調用了纔會移除 dom
        listeners = cbs.remove.length + 1;
        rm = createRmCb(ch.elm as Node, listeners);
        // 調用 module 中是 remove hook
        for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
        // 調用 vnode 的 remove hook
        if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
          i(ch, rm);
        } else {
          rm();
        }
      } else { // Text node
        api.removeChild(parentElm, ch.elm as Node);
      }
    }
  }
}

// 調用 destory hook
// 若是存在 children 遞歸調用
function invokeDestroyHook(vnode: VNode) {
  let i: any, j: number, data = vnode.data;
  if (data !== undefined) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
    if (vnode.children !== undefined) {
      for (j = 0; j < vnode.children.length; ++j) {
        i = vnode.children[j];
        if (i != null && typeof i !== "string") {
          invokeDestroyHook(i);
        }
      }
    }
  }
}

// 只有當全部的 remove hook 都調用了 remove callback 纔會移除 dom
function createRmCb(childElm: Node, listeners: number) {
  return function rmCb() {
    if (--listeners === 0) {
      const parent = api.parentNode(childElm);
      api.removeChild(parent, childElm);
    }
  };
}
複製代碼

這兩個函數主要用來添加 vnode 和移除 vnode,代碼邏輯基本都能看懂。

thunk 函數

通常咱們的應用是根據 js 狀態來更新的,好比下面這個例子

function renderNumber(num) {
  return h('span', num);
}
複製代碼

這裏意味着若是 num 沒有改變的話,那對 vnode 進行 patch 就是沒有意義的, 對於這種狀況,snabbdom 提供了一種優化手段,也就是 thunk,該函數一樣返回一個 vnode 節點,可是在 patchVnode 開始時,會對參數進行一次比較,若是相同,將結束對比,這個有點相似於 ReactpureComponentpureComponent 的實現上會作一次淺比較 shadowEqual,結合 immutable 數據進行使用效果更加。上面的例子能夠變成這樣。

function renderNumber(num) {
  return h('span', num);
}

function render(num) {
  return thunk('div', renderNumber, [num]);
}

var vnode = patch(container, render(1))
// 因爲num 相同,renderNumber 不會執行
patch(vnode, render(1))
複製代碼

它的具體實現以下:

export interface ThunkFn {
  (sel: string, fn: Function, args: Array<any>): Thunk;
  (sel: string, key: any, fn: Function, args: Array<any>): Thunk;
}

// 使用 h 函數返回 vnode,爲其添加 init 和 prepatch 鉤子
export const thunk = function thunk(sel: string, key?: any, fn?: any, args?: any): VNode {
  if (args === undefined) {
    args = fn;
    fn = key;
    key = undefined;
  }
  return h(sel, {
    key: key,
    hook: {init: init, prepatch: prepatch},
    fn: fn,
    args: args
  });
} as ThunkFn;

// 將 vnode 上的數據拷貝到 thunk 上,在 patchVnode 中會進行判斷,若是相同會結束 patchVnode
// 並將 thunk 的 fn 和 args 屬性保存到 vnode 上,在 prepatch 時須要進行比較
function copyToThunk(vnode: VNode, thunk: VNode): void {
  thunk.elm = vnode.elm;
  (vnode.data as VNodeData).fn = (thunk.data as VNodeData).fn;
  (vnode.data as VNodeData).args = (thunk.data as VNodeData).args;
  thunk.data = vnode.data;
  thunk.children = vnode.children;
  thunk.text = vnode.text;
  thunk.elm = vnode.elm;
}

function init(thunk: VNode): void {
  const cur = thunk.data as VNodeData;
  const vnode = (cur.fn as any).apply(undefined, cur.args);
  copyToThunk(vnode, thunk);
}

function prepatch(oldVnode: VNode, thunk: VNode): void {
  let i: number, old = oldVnode.data as VNodeData, cur = thunk.data as VNodeData;
  const oldArgs = old.args, args = cur.args;
  if (old.fn !== cur.fn || (oldArgs as any).length !== (args as any).length) {
    // 若是 fn 不一樣或 args 長度不一樣,說明發生了變化,調用 fn 生成新的 vnode 並返回
    copyToThunk((cur.fn as any).apply(undefined, args), thunk);
    return;
  }
  for (i = 0; i < (args as any).length; ++i) {
    if ((oldArgs as any)[i] !== (args as any)[i]) {
      // 若是每一個參數發生變化,邏輯同上
      copyToThunk((cur.fn as any).apply(undefined, args), thunk);
      return;
    }
  }
  copyToThunk(oldVnode, thunk);
}
複製代碼

能夠回顧下 patchVnode 的實現,在 prepatch 後,會對 vnode 的數據作比較,好比當 children 相同、text 相同都會結束 patchVnode

結語

到這裏 snabbdom 的核心源碼已經閱讀完畢,剩下的還有一些內置的 module,有興趣的能夠自行閱讀。

掃一掃關注迅雷前端公衆號

相關文章
相關標籤/搜索