VDOM算法筆記

VDOM

vVirtal DOM主要包括如下三個方面html

  1. 使用 js 數據對象 表示 DOM 結構 -> VNode
  2. 比較新舊兩棵 虛擬 DOM 樹的差別 -> diff
  3. 將差別應用到真實的 DOM 樹上 -> patch

snabbdom

snabbdom是一個優雅精簡的vdom庫,適合學習vdom思想和算法。下面的一切內容都是基於snabbdom.js的源碼。node

h函數

h 函數的主要功能是根據傳入的參數,返回一個VNode對象。git

根據snabbdom.js的h函數源碼來分析: snabbdom中對h函數作了重載,這是ts的特性。使得h函數能夠處理的狀況更加清晰,分爲如下四種:github

  1. 一個參數,選擇器sel
  2. 兩個參數,選擇器sel和數據data
  3. 兩個參數,選擇器sel和子節點數組children
  4. 三個參數,選擇器,數據data,子節點數組children

根據下面的源碼分析能夠看出,除了這四種狀況之外,對於SVG元素作了額外的處理,也就是添加了namespace。 最終都是調用vnode產生了一個VDOM節點。算法

/** * 重載h函數 * 根據選擇器 ,數據 ,建立 vnode */
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;
/** * h 函數比較簡單,主要是提供一個方便的工具函數,方便建立 vnode 對象 * @param sel 選擇器 * @param b 數據 * @param c 子節點 * @returns {{sel, data, children, text, elm}} */
export function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  // 若是存在子節點
  // 三個參數的狀況 sel , data , children | text
  if (c !== undefined) {
    // 那麼h的第二項就是data
    data = b;
    // 若是c是數組,那麼存在子element節點
    if (is.array(c)) { children = c; }
    //不然爲子text節點
    else if (is.primitive(c)) { text = c; }
    // 說明c是一個子元素
    else if (c && c.sel) { children = [c]; }
    //若是c不存在,只存在b,那麼說明須要渲染的vdom不存在data部分,只存在子節點部分
  } else if (b !== undefined) {
    // 兩個參數的狀況 : sel , children | text
    // 兩個參數的狀況 : sel , data
    // 子元素數組
    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) {
       // 若是children是文本或數字 ,則建立文本節點
  //{sel: sel, data: data, children: children, text: text, elm: elm, key: key};
  //文本節點sel和data屬性都是undefined
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }
  // 針對svg的node進行特別的處理
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
      // 增長 namespace
    addNS(data, children, sel);
  }
  // 返回一個正常的vnode對象
  return vnode(sel, data, children, text, undefined);
};
export default h;
複製代碼

vnode函數

vnode函數 很是簡單。僅僅是根據輸入參數返回了一個VNode類型的對象segmentfault

// 根據傳入的 屬性 ,返回一個 vnode 對象
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
    };
}
export default vnode;
複製代碼

下面是VNode的源碼:api

/** * 定義VNode類型 */
export interface VNode {
    // 選擇器
    sel: string | undefined;
    // 數據,主要包括屬性、樣式、數據、綁定時間等
    data: VNodeData | undefined;
    // 子節點
    children: Array<VNode | string> | undefined;
    // 關聯的原生節點
    elm: Node | undefined;
    // 文本
    text: string | undefined;
    // key , 惟一值,爲了優化性能
    key: Key | undefined;
}
複製代碼

另外還有一個比較重要的類型VNodeData.數組

/** * VNodeData節點所有都是可選屬性,也可動態添加任意類型的屬性 */
export interface VNodeData {
  // vnode上的其餘屬性
   // 屬性 能直訪問和接用 
  props?: Props;
  // vnode上面的瀏覽器原生屬性,能夠使用setAttribute設置的
  attrs?: Attrs;
  //樣式類,class屬性集合
  class?: Classes;
  // style屬性集合
  style?: VNodeStyle;
  // vnode上面掛載的數據集合
  dataset?: Dataset;
  // 監聽事件集合
  on?: On;
  // 
  hero?: Hero;
  // 額外附加的數據
  attachData?: AttachData;
  // 鉤子函數集合,執行到不一樣的階段調用不一樣的鉤子函數
  hook?: Hooks;
  //
  key?: Key;
  // 命名空間 SVGs 命名空間,主要用於SVG
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: Array<any>; // for thunks
  //其它額外的屬性
  [key: string]: any; // for any other 3rd party module
}
複製代碼

正式開始

一切的一切都要從這個snabbdom.ts中的這個init方法開始。瀏覽器

Virtual Dom 樹對比的策略

  1. 同級對比

按照層序的方式遍歷比較dom

對比的時候,只針對同級的嚴肅進行對比,減小算法複雜度。

  1. 就近複用

爲了儘量不發生 DOM 的移動,會就近複用相同的 DOM 節點,複用的依據是判斷是不是同類型的 dom 元素

/** * * @param modules * @param domApi * @returns 返回 patch 方法 */
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number, j: number, cbs = ({} as ModuleHooks);

  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
  // 循環 hooks , 將每一個 modules 下的 hook 方法提取出來存到 cbs 裏面
 // 返回結果 eg : cbs['create'] = [modules[0]['create'],modules[1]['create'],...];
  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) {
    ...
  }

  // 將 vnode 轉換成真正的 DOM 元素
  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    ...
  }

  // 添加 Vnodes 到 真實 DOM 中
  function addVnodes(parentElm: Node, before: Node | null, vnodes: Array<VNode>, startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
    ...
  }

  function invokeDestroyHook(vnode: VNode) {
    let i: any, j: number, data = vnode.data;
    ...
  }

  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) {
   ...省略函數體
  }
  // 返回patch 方法
  /** * 觸發 pre 鉤子 * 若是老節點非 vnode, 則新建立空的 vnode * 新舊節點爲 sameVnode 的話,則調用 patchVnode 更新 vnode , 不然建立新節點 * 觸發收集到的新元素 insert 鉤子 * 觸發 post 鉤子 * @param oldVnode * @param vnode * @returns vnode */
  function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    //收集新插入到的元素
    const insertedVnodeQueue: VNodeQueue = [];
    //先調用pre回調
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    // 若是老節點非 vnode , 則建立一個空的 vnode
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    }
    // 若是是同個節點,則進行修補
    if (sameVnode(oldVnode, vnode)) {
      // 進入patch流程
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      // 不一樣 Vnode 節點則新建
      // as 是告訴類型檢查器,次數oldVnode.elm的類型應該是Node類型
      elm = oldVnode.elm as Node;
      //取到父節點node.parentNode屬性
      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]);
    }
    // 調用post的鉤子
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };
  return patch;
}
複製代碼

從上面的代碼裏看init方法除了提取了create鉤子之外就是聲明瞭幾個重要的函數,而且返回了一個函數 patch。

patch

patch函數只接受兩個參數,patch(oldVnode: VNode | Element, vnode: VNode),第一個參數oldNode能夠使VNode或者Element類型,第二個參數爲VNode類型。

聲明瞭一個insertedVnodeQueue,用來收集須要插入的元素隊列。 步驟以下:

  1. 若是oldVnode不是VNode類型,那麼調用emptyNodeAt建立一個空的VNode

  2. 若是oldNode和vnode是同一個節點,那麼直接進入patchVNode流程 patchVNode流程後面再詳細介紹

  3. 若是 不是同一個節點則先獲取oldVnode.elm的父DOM元素。將新元素插入到oldVnode.elm的下一個兄弟節點以前,而後移除oldVnode。其效果等同於使用新建立的元素替換了舊元素。

  4. 遍歷insertedVnodeQueue隊列,調用insert鉤子

  5. 調用post鉤子

  6. 返回vnode節點.

patchVnode

接下來的重點在於patchVnode。 前面定義的VNode結構類型,中包含了children和text兩個字段,這是爲了將元素子節點和文本分開處理

patchVnode函數只接受三個參數,patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue), 第一個參數oldNode是VNode類型, 第二個參數爲VNode類型。 第三個參數是插入的VNode隊列

patchVnode的主要邏輯以下:

  1. 調用oldNode和vnode的prepatch鉤子
  2. 若是oldNode和vnode引用的地址相同,說明是同一個對象,直接返回
  3. 若是vnode.text屬性爲undefined,說明子節點是元素,假設舊的子節點元素用oldCh表示,新的子元素節點用ch表示,此時存在四種狀況:
  • oldCh和ch都存在且不相等,那麼進入 updateChildren 流程,這個流程很重要很複雜,後面再說。
  • 僅ch存在,若是舊的文本信息存在先清除文本信息,而後添加新節點
  • 僅oldCh存在,說明新節點子元素爲空,此時應該移除全部舊的子元素。
  • oldCh和ch都不存在,此時oldNode多是文本節點,贏改將其內容置空
  1. 若是vnode.text屬性不是undefined,說明新節點是文本節點,此時若是oldNode是元素節點,此時應該移除全部元素,而後設置文本內容
  2. 處理完畢,觸發post鉤子函數

updateChildren

上文中關鍵的地方在於 updateChildren,這個過程處理新舊子元素數組的對比。 這裏就是diff算法的核心邏輯了。其實也很簡單。邏輯以下:

  1. 有線處理特殊場景,先對比兩端,也就是
- (1)舊 vnode 頭 vs 新 vnode 頭(順序)
- (2)舊 vnode 尾 vs 新 vnode 尾(順序)
- (3)舊 vnode 頭 vs 新 vnode 尾(倒序)
- (4)舊 vnode 尾 vs 新 vnode 頭(倒序)
複製代碼
  1. 首尾不同的狀況,尋找 key 相同的節點,找不到則新建元素
  2. 若是找到 key,可是,元素選擇器變化了,也新建元素
  3. 若是找到 key,而且元素選擇沒變, 則移動元素
  4. 兩個列表對比完以後,清理多餘的元素,新增添加的元素

updateChild過程(1)

updateChild過程(2)

後面的源碼由於太長了就不貼了,有興趣的話就看這裏,歡迎你們批評指正。

參考

snabbdom源碼解析

相關文章
相關標籤/搜索