snabbdom 原始碼閱讀分析

迅雷前端發表於2018-09-07

作者: steins from 迅雷前端

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

隨著 React Vue 等框架的流行,Virtual DOM 也越來越火,snabbdom 是其中一種實現,而且 Vue 2.x 版本的 Virtual DOM 部分也是基於 snabbdom 進行修改的。snabbdom 這個庫核心程式碼只有 200 多行,非常適合想要深入瞭解 Virtual DOM 實現的讀者閱讀。如果您沒聽說過 snabbdom,可以先看看官方文件

為什麼選擇 snabbdom

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

什麼是 Virtual DOM

snabbdom 是 Virtual DOM 的一種實現,所以在此之前,你需要先知道什麼是 Virtual DOM。通俗的說,Virtual DOM 就是一個 js 物件,它是真實 DOM 的抽象,只保留一些有用的資訊,更輕量地描述 DOM 樹的結構。 比如在 snabbdom 中,是這樣來定義一個 VNode 的:

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 演算法,演算法的基本步驟如下:

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

接下來我們來分析這整個過程的實現。

原始碼分析

首先從一個簡單的例子入手,一步一步分析整個程式碼的執行過程,下面是官方的一個簡單示例:

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函式來生成。使用起來相當簡單,這也是本文接下來要分析的內容。

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 就結束了。

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,有興趣的可以自行閱讀。

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

snabbdom 原始碼閱讀分析

相關文章