解密虛擬 DOM——snabbdom 核心原始碼解讀

Russ_Zhong發表於2019-04-27

本文原始碼地址:github.com/zhongdeming…

對很多人而言,虛擬 DOM 都是一個很高大上而且遠不可及的專有名詞,以前我也這麼認為,後來在學習 Vue 原始碼的時候發現 Vue 的虛擬 DOM 方案衍生於本文要講的 snabbdom 工具,經過閱讀原始碼之後才發現,虛擬 DOM 原來就是這麼回事,並沒有想象中那麼難以理解嘛~

這篇文章呢,就單獨從 snabbdom 這個庫講起,不涉及其他任何框架,單獨從這個庫的原始碼來聊一聊虛擬 DOM。

在講 snabbdom 之前,需要先學習 TypeScript 知識,以及 snabbdom 的基本使用方法。

一、snabbdom 核心概念

在學習 snabbdom 原始碼之前,最好先學會用 snabbdom,至少要掌握 snabbdom 的核心概念,這是閱讀框架原始碼之前基本都要做的準備工作。

以下內容可以直接到 snabbdom 官方文件瞭解。

snabbdom 的一些優點

snabbdom 主要具有一下優點:

  • 核心部分的原始碼只有兩百多行(其實不止),容易讀懂。
  • 通過 modules 可以很容易地擴充套件。
  • 鉤子函式很豐富,使用者可以通過鉤子函式直接干涉 Vnode 到 DOM 掛載到最終銷燬的全過程。
  • 效能很棒。
  • 容易整合。

modules 的一些優點

  • 通過 h 函式,可以很容易地建立 Vnode。
  • 通過 h 函式可以建立 SVG 元素。
  • 事件處理能力強大。
  • 可以通過 Thunks 優化 DOM Diff 和事件。

第三方支援很多的優點

通過一些第三方的外掛,可以很容易地支援 JSX、服務端 HTML 輸出等等……

核心 API

較為核心的 API 其實就四個:initpatchhtovnode,通過這四個 API 就可以玩轉虛擬 DOM 啦!

下面簡單介紹一下這四個核心函式:

  • init:這是 snabbdom 暴露出來的一個核心函式,通過它我們才能開始使用許多重要的功能。該函式接受一個陣列作為引數,陣列內都是 module,通過 init 註冊了一系列要使用的 module 之後,它會給我們返回一個 patch 函式。

  • patch: 該函式是我們掛載或者更新 vnode 的重要途徑。它接受兩個引數,第一個引數可以是 HTML 元素或者 vnode,第二個元素只能是 vnode。通過 patch 函式,可以對第一個 vnode 進行更新,或者把 vnode 掛載/更新到 DOM 元素上。

  • tovnode: 用於把真實的 DOM 轉化為 vnode,適合把 SSR 生成的 DOM 轉化成 vnode,然後進行 DOM 操作。

  • h: 該函式用於建立 vnode,在許多地方都能見到它的身影。它接受三個引數:

    @param {string} selector|tag 標籤名或者選擇器
    @param {object} data 資料物件,結構在後面講
    @param {vNode[]|string} children 子節點,可以是文字節點
    複製程式碼

Module 模組

Module 是 snabbdom 的一個核心概念,snabbdom 的核心主幹程式碼只實現了元素、id、class(不包含動態賦值)、元素內容(包括文字節點在內的子節點)這四個方面;而其他諸如 style 樣式、class 動態賦值、attr 屬性等功能都是通過 Module 擴充套件的,它們寫成了 snabbdom 的內部預設 Module,在需要的時候引用就行了。

那麼 Module 究竟是什麼呢?

snabbdom 的官方文件已經講得很清楚了,Module 的本質是一個物件,物件的鍵由一些鉤子(Hooks)的名稱組成,鍵值都是函式,這些函式能夠在特定的 vnode/DOM 生命週期觸發,並接受規定的引數,能夠對週期中的 vnode/DOM 進行操作。

由於 snabbdom 使用 TypeScript 編寫,所以在之後看程式碼的時候,我們可以非常清楚地看到 Module 的組成結構。

內建 Module 有如下幾種:

  • class:動態控制元素的 class。
  • props:設定 DOM 的一些屬性(properties)。
  • attributes:同樣用於設定 DOM 屬性,但是是 attributes,而且 properties。
  • style:設定 DOM 的樣式。
  • dataset:設定自定義屬性。
  • customProperties:CSS 的變數,使用方法參考官方文件。
  • delayedProperties:延遲的 CSS 樣式,可用於建立動畫之類。

Hooks 鉤子

snabbdom 提供了豐富的生命週期鉤子:

鉤子名稱 觸發時機 Arguments to callback
pre patch 開始之前。 none
init 已經建立了一個 vnode。 vnode
create 已經基於 vnode 建立了一個 DOM,但尚未掛載。 emptyVnode, vnode
insert 建立的 DOM 被掛載了。 vnode
prepatch 一個元素即將被 patch。 oldVnode, vnode
update 元素正在被更新。 oldVnode, vnode
postpatch 元素已經 patch 完畢。 oldVnode, vnode
destroy 一個元素被直接或間接地移除了。間接移除的情況是指被移除元素的子元素。 vnode
remove 一個元素被直接移除了(解除安裝)。 vnode, removeCallback
post patch 結束。 none

如何使用鉤子呢?

在建立 vnode 的時候,把定義的鉤子函式傳遞給 data.hook 就 OK 了;當然還可以在自定義 Module 中使用鉤子,同理定義鉤子函式並賦值給 Module 物件就可以了。

注意

Module 中只能使用以下幾種鉤子:precreateupdatedestroyremovepost

而在 vnode 建立中定義的鉤子只能是以下幾種:initcreateinsertprepatchupdatepostpatchdestroyremove。為什麼 prepost 不能使用呢?因為這兩個鉤子不在 vnode 的生命週期之中,在 vnode 建立之前,pre 已經執行完畢,在 vnode 解除安裝完畢之後,post 鉤子才開始執行。

EventListener

snabbdom 提供 DOM 事件處理功能,建立 vnode 時,定義好 data.on 即可。比如:

h(
	'div',
    {
        on: {
            click: function() { /*...*/}
        }
    }
)
複製程式碼

如上,就定義了一個 click 事件處理函式。

那麼如果我們要預先傳入一些自定義的引數那該怎麼做呢?此時我們應該通過陣列定義 handler:

h(
	'div',
    {
        on: {
            click: [
                function(data) {/*...*/},
                data
            ]
        }
    }
)
複製程式碼

那我們的事件物件如何獲取呢?這一點 snabbdom 已經考慮好了,event 物件和 vnode 物件會附加在我們的自定義引數後傳入到 handler。

Thunk

根據官方文件的說明,Thunk 是一種優化策略,可以防止建立重複的 vnode,然後對實際未發生變化的 vnode 做替換或者 patch,造成不必要的效能損耗。在後面的原始碼分析中,再做詳細說明吧。

二、原始碼目錄結構

在首先檢視原始碼之前,先分析一下原始碼的目錄結構,好有的放矢的進行閱讀,下面是 src 目錄下的檔案結構:

.
├── helpers
│   └── attachto.ts
├── hooks.ts // 定義了鉤子函式的型別
├── htmldomapi.ts	// 定義了一系列 DOM 操作的 API
├── h.ts	// 主要定義了 h 函式
├── is.ts	// 主要定義了一個型別判斷輔助函式
├── modules	// 定義內建 module 的目錄
│   ├── attributes.ts
│   ├── class.ts
│   ├── dataset.ts
│   ├── eventlisteners.ts
│   ├── hero.ts
│   ├── module.ts
│   ├── props.ts
│   └── style.ts
├── snabbdom.bundle.ts // 匯出 h 函式和 patch 函式(註冊了所有內建模組)。
├── snabbdom.ts // 匯出 init,允許自定義註冊模組
├── thunk.ts	// 定義了 thunk
├── tovnode.ts	// 定義了 tovnode 函式
└── vnode.ts	// 定義了 vnode 型別

2 directories, 18 files
複製程式碼

所以看完之後,我們應該有了一個大致的概念,要較好的瞭解 vnode,我們可以先從 vnode 下手,結合文件的介紹,可以詳細瞭解虛擬 DOM 的結構。

此外還可以從我們使用 snabbdom 的入口處入手,即 snabbdom.ts。

三、虛擬 DOM 結構

這一小節先了解 vnode 的結構是怎麼樣的,由於 snabbdom 使用 TypeScript 編寫,所以關於變數的結構可以一目瞭然,開啟 vnode.ts,可以看到關於 vnode 的定義:

export interface VNode {
  sel: string | undefined;
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  elm: Node | undefined;
  text: string | undefined;
  key: Key | undefined;
}
複製程式碼

可以看到 vnode 的結構其實比較簡單,只有 6 個屬性。關於這六個屬性,官網已經做了介紹:

  • sel:是一種 CSS 選擇器,vnode 掛載為 DOM 時,會基於這個屬性構造 HTML 元素。
  • data:構造 vnode 的資料屬性,在構造 DOM 時會用到裡面的資料,data 的結構在 vnode.ts 中可以找到定義,稍後作介紹。
  • children:這是一個 vnode 陣列,在 vnode 掛載為 DOM 時,其 children 內的所有 vnode 會被構造為 HTML 元素,進一步掛載到上一級節點下。
  • elm:這是根據當前 vnode 構造的 DOM 元素。
  • text: 當前 vnode 的文字節點內容。
  • key:snabbdom 用 keysel 來區分不同的 vnode,如果兩個 vnode 的 selkey 屬性都相等,那麼可以認為兩個 vnode 完全相等,他們之間的更新需要進一步比對。

往下翻可以看到 VNodeData 的型別定義:

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
}
複製程式碼

可以看出來這些屬性基本上都是在 Module 中所使用的,用於對 DOM 的一些資料、屬性進行定義,後面再進行介紹。

四、Hooks 結構

開啟 hooks.ts,可以看到原始碼如下:

import {VNode} from './vnode';

export type PreHook = () => any;
export type InitHook = (vNode: VNode) => any;
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any;
export type InsertHook = (vNode: VNode) => any;
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any;
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type DestroyHook = (vNode: VNode) => any;
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any;
export type PostHook = () => any;

export interface Hooks {
  pre?: PreHook;
  init?: InitHook;
  create?: CreateHook;
  insert?: InsertHook;
  prepatch?: PrePatchHook;
  update?: UpdateHook;
  postpatch?: PostPatchHook;
  destroy?: DestroyHook;
  remove?: RemoveHook;
  post?: PostHook;
}
複製程式碼

這些程式碼定義了所有鉤子函式的結構型別(接受的引數、返回的引數),然後定義了 Hooks 型別,這與我們前面介紹的鉤子型別和所接受的引數是一致的。

五、Module 結構

開啟 module.ts,看到原始碼如下:

import {PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook} from '../hooks';

export interface Module {
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}
複製程式碼

可以看到,該模組先引用了上一節程式碼定義的一系列鉤子的型別,然後用這些型別進一步定義了 Module。能夠看出來 module 實際上就是幾種鉤子函式組成的一個物件,用於干涉 DOM 的構造。

六、h 函式

h 函式是一個大名鼎鼎的函式,在各個框架中都有這個函式的身影。它的願意是 hyperscript,意思是創造 HyperTextJavaScript,當然包括創造 HTMLJavaScript。在 snabbdom 中也不例外,h 函式旨在接受一系列引數,然後構造對應的 vnode,其返回的 vnode 最終會被渲染成 HTML 元素。

看看原始碼:


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; }
  }
  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);
};
export default h;
複製程式碼

可以看到前面很大一段都是函式過載,所以不用太關注,只用關注到最後一行:

return vnode(sel, data, children, text, undefined);
複製程式碼

在適配好引數之後,h函式呼叫了 vnode 函式,實現了 vnode 的建立,而 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};
}
複製程式碼

它來自於 vnode.ts

總之我們知道 h 函式接受相應的引數,返回一個 vnode 就行了。

七、snabbdom.ts

在講 snabbdom.ts 之前,本來應該先了解 htmldomapi.ts 的,但是這個模組全都是對於 HTML 元素 API 的封裝,沒有講解的必要,所以閱讀本章之前,讀者自行閱讀 htmldomapi.ts 原始碼即可。

這是整個專案的核心所在,也是定義入口函式的重要檔案,這個檔案大概有接近 400 行,主要定義了一些工具函式以及一個入口函式。

開啟 snabbdom.ts ,最早看到的就是一些簡單的型別定義,我們也先來了解一下:

function isUndef(s: any): boolean { return s === undefined; } // 判斷 s 是否為 undefined。

// 判斷 s 是否已定義(不為 undefined)。
function isDef(s: any): boolean { return s !== undefined; }

// 一個 VNodeQueue 佇列,實際上是 vnode 陣列,代表要掛載的 vnode。
type VNodeQueue = Array<VNode>;

// 一個空的 vnode,用於傳遞給 craete 鉤子(檢視第一節)。
const emptyNode = vnode('', {}, [], undefined, undefined);

// 判斷兩個 vnode 是否重複,依據是 key 和 sel。
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

// 判斷是否是 vnode。
function isVnode(vnode: any): vnode is VNode {
  return vnode.sel !== undefined;
}

// 一個物件,用於對映 childen 陣列中 vnode 的 key 和其 index 索引。
type KeyToIndexMap = {[key: string]: number};

// T 是一個物件,其中的每一個鍵都被對映到 ArraysOf 型別,鍵值是 T 鍵值的陣列集合。
type ArraysOf<T> = {
  [K in keyof T]: (T[K])[];
}

// 參照上面的註釋。
type ModuleHooks = ArraysOf<Module>;
複製程式碼

看完了基本型別的定義,可以繼續看 init 函式:

export 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);
      }
    }
  }
	
  // 這中間定義了一大堆工具函式,稍後做選擇性分析……此處省略。
 
  // init 函式返回的 patch 函式,用於掛載或者更新 DOM。
  return 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]();
    
    if (!isVnode(oldVnode)) {
      // 如果不是 VNode,那此時以舊的 DOM 為模板構造一個空的 VNode。
      oldVnode = emptyNodeAt(oldVnode);
    }

    if (sameVnode(oldVnode, vnode)) {
      // 如果 oldVnode 和 vnode 是同一個 vnode(相同的 key 和相同的選擇器),那麼更新 oldVnode。
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      // 如果 vnode 不同於 oldVnode,那麼直接替換掉 oldVnode 對應的 DOM。
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm); // oldVnode 對應 DOM 的父節點。

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        // 如果 oldVnode 的對應 DOM 有父節點,並且有同級節點,那就在其同級節點之後插入 vnode 的對應 DOM。
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        // 在把 vnode 的對應 DOM 插入到 oldVnode 的父節點內後,移除 oldVnode 的對應 DOM,完成替換。
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      // 執行 insert 鉤子。因為 module 不包括 insert 鉤子,所以不必執行 cbs...
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    // 執行 post 鉤子,代表 patch 操作完成。
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    // 最終返回 vnode。
    return vnode;
  };
}
複製程式碼

可以看到 init 函式其實不僅可以接受一個 module 陣列作為引數,還可以接受一個 domApi 作為引數,這在官方文件上是沒有說明的。可以理解為 snabbdom 允許我們自定義 dom 的一些操作函式,在這個過程中對 DOM 的構造進行干預,只需要我們傳遞的 domApi 的結構符合預定義就可以了,此處不再細表。

然後可以看到的就是兩個巢狀著的迴圈,大致意思是遍歷 hooks 和 modules,構造一個 ModuleHooks 型別的 cbs 變數,那這是什麼意思呢?

hooks 定義如下:

const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
複製程式碼

那就是把每個 module 中對應的鉤子函式整理到 cbs 鉤子名稱對應的陣列中去,比如:

const module1 = {
    create() { /*...*/ },
    update() { /*...*/ }
};
const module2 = {
    create() { /*...*/ },
    update() { /*...*/ }
};
// 經過整理之後……
// cbs 如下:
{
    create: [create1, create2],
    update: [update1, update2]
}
複製程式碼

這種結構類似於釋出——訂閱模式的事件中心,以事件名作為鍵,鍵值是事件處理函式組成的陣列,在事件發生時,陣列中的函式會依次執行,與此處一致。

在處理好 hooks 之後,init 內部定義了一系列工具函式,此處暫不講解,先往後看。

init 處理到最後返回的使我們預期的 patch 函式,該函式是我們使用 snabbdom 的重要入口,其具體定義如下:

// init 函式返回的 patch 函式,用於掛載或者更新 DOM。
return 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]();
  
  if (!isVnode(oldVnode)) {
    // 如果不是 VNode,那此時以舊的 DOM 為模板構造一個空的 VNode。
    oldVnode = emptyNodeAt(oldVnode);
  }

  if (sameVnode(oldVnode, vnode)) {
    // 如果 oldVnode 和 vnode 是同一個 vnode(相同的 key 和相同的選擇器),那麼更新 oldVnode。
    patchVnode(oldVnode, vnode, insertedVnodeQueue);
  } else {
    // 如果 vnode 不同於 oldVnode,那麼直接替換掉 oldVnode 對應的 DOM。
    elm = oldVnode.elm as Node;
    parent = api.parentNode(elm); // oldVnode 對應 DOM 的父節點。

    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      // 如果 oldVnode 的對應 DOM 有父節點,並且有同級節點,那就在其同級節點之後插入 vnode 的對應 DOM。
      api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
      // 在把 vnode 的對應 DOM 插入到 oldVnode 的父節點內後,移除 oldVnode 的對應 DOM,完成替換。
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }

  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    // 執行 insert 鉤子。因為 module 不包括 insert 鉤子,所以不必執行 cbs...
    (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
  }
  // 執行 post 鉤子,代表 patch 操作完成。
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
  // 最終返回 vnode。
  return vnode;
};
複製程式碼

可以看到在 patch 執行的一開始,就遍歷了 cbs 中的所有 pre 鉤子,也就是所有 module 中定義的 pre 函式。執行完了 pre 鉤子,代表 patch 過程已經開始了。

接下來首先判斷 oldVnode 是不是 vnode 型別,如果不是,就代表 oldVnode 是一個 HTML 元素,那我們就要把他轉化為一個 vnode,方便後面的更新,更新完畢之後再進行掛載。轉化為 vnode 的方式很簡單,直接將其 DOM 結構掛載到 vnode 的 elm 屬性,然後構造好 sel 即可。

隨後,通過 sameVnode 判斷是否是同一個 “vnode”。如果不是,那麼就可以直接把兩個 vnode 代表的 DOM 元素進行直接替換;如果是“同一個” vnode,那麼就需要進行下一步對比,看看到底有哪些地方需要更新,可以看做是一個 DOM Diff 過程。所以這裡出現了 snabbdom 的一個小訣竅,通過 sel 和 key 區分 vnode,不相同的 vnode 可以直接替換,不進行下一步的替換。這樣做在很大程度上避免了一些沒有必要的比較,節約了效能。

完成上面的步驟之後,就已經把 vnode 掛載到 DOM 上了,完成這個步驟之後,需要執行 vnode 的 insert 鉤子,告訴所有的模組:一個 DOM 已經掛載了!

最後,執行所有的 post 鉤子並返回 vnode,通知所有模組整個 patch 過程已經結束啦!

不難發現重點在於當 oldVnode 和 vnode 是同一個 vnode 時如何進行更新。這就自然而然的涉及到了 patchVnode 函式,該函式結構如下:

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)) {
    // 如果 vnode.data.hook.prepatch 不為空,則執行 prepatch 鉤子。
    i(oldVnode, vnode);
  }
  const elm = vnode.elm = (oldVnode.elm as Node);
  let oldCh = oldVnode.children;
  let ch = vnode.children;
  // 如果兩個 vnode 是真正意義上的相等,那完全就不用更新了。
  if (oldVnode === vnode) return;
  if (vnode.data !== undefined) {
    // 如果 vnode 的 data 不為空,那麼執行 update。
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    i = vnode.data.hook;
    // 執行 vnode.data.hook.update 鉤子。
    if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
  }
  if (isUndef(vnode.text)) {
    // 如果 vnode.text 未定義。
    if (isDef(oldCh) && isDef(ch)) {
      // 如果都有 children,那就更新 children。
      if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
    } else if (isDef(ch)) {
      // 如果 oldVnode 是文字節點,而更新後 vnode 包含 children;
      // 那就先移除 oldVnode 的文字節點,然後新增 vnode。
      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)) {
      // 如果 oldVnode 有 children,而新的 vnode 只有文字節點;
      // 那就移除 vnode 即可。
      removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
    } else if (isDef(oldVnode.text)) {
      // 如果更新前後,vnode 都沒有 children,那麼就新增空的文字節點,因為大前提是 vnode.text === undefined。
      api.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    // 定義了 vnode.text,並且 vnode 的 text 屬性不同於 oldVnode 的 text 屬性。
    if (isDef(oldCh)) {
      // 如果 oldVnode 具有 children 屬性(具有 vnode),那麼移除所有 vnode。
      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 鉤子函式。
    i(oldVnode, vnode);
  }
}
複製程式碼

該函式是用於更新 vnode 的主要函式,所以 vnode 的主要生命週期都在這個函式內完成。首先執行的鉤子就是 prepatch,表示元素即將被 patch。然後會判斷 vnode 是否包含 data 屬性,如果包含則說明需要先更新 data,這時候會呼叫所有的 update 鉤子(包括模組內的和 vnode 自帶的 update 鉤子),在 update 鉤子內完成 data 的合併更新。在 children 更新之後,還會呼叫 postpatch 鉤子,表示 patch 過程已經執行完畢。

接下來從 text 入手,這一大塊的註釋都在程式碼裡面寫得很清楚了,這裡不再贅述。重點在於 oldVnode 和 vnode 都有 children 屬性的時候,如何更新 children?接下來看 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;

  // 從兩端開始開始遍歷 children。
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    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];
    } else if (sameVnode(oldStartVnode, newStartVnode)) { // 如果是同一個 vnode。
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue); // 更新舊的 vnode。
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) { // 同上,但是是從尾部開始的。
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      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 (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];
    } else {
      if (oldKeyToIdx === undefined) {
        // 創造一個 hash 結構,用鍵對映索引。
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      idxInOld = oldKeyToIdx[newStartVnode.key as string]; // 通過 key 來獲取對應索引。
      if (isUndef(idxInOld)) { // New element
        // 如果找不到索引,那就是新元素。
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 找到對應的 child vnode。
        elmToMove = oldCh[idxInOld];
        if (elmToMove.sel !== newStartVnode.sel) {
          // 如果新舊 vnode 的選擇器不能對應,那就直接插入到舊 vnode 之前。
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        } else {
          // 選擇器匹配上了,可以直接更新。
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          oldCh[idxInOld] = undefined as any; // 已更新的舊 vnode 賦值為 undefined。
          api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
  }
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    // 沒匹配上的多餘的就直接插入到 DOM 咯。
    if (oldStartIdx > oldEndIdx) {
      // newCh 裡面有新的 vnode,直接插入到 DOM。
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else {
      // newCh 裡面的 vnode 比 oldCh 裡面的少,說明有元素被刪除了。
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}
複製程式碼

updateVnode 函式在一開始就從 children 陣列的首尾兩端開始遍歷。可以看到在遍歷開始的時候會有一堆的 null 判斷,為什麼呢?因為後面會把已經更新的 vnode children 賦值為 undefined。

判斷完 null 之後,會比較新舊 children 內的節點是否“相同”(排列組合共有四種比較方式),如果相同,那就繼續呼叫 patchNode 更新節點,更新完之後就可以插入 DOM 了;如果四中情況都匹配不到,那麼就通過之前建立的 key 與索引之間的對映來尋找新舊 children 陣列中對應 child vnode 的索引,找到之後再進行具體操作。關於具體的操作,程式碼中已經註釋了~

對於遍歷之後多餘的 vnode,再分情況進行比較;如果 oldCh 多於 newCh,那說明該操作刪除了部分 DOM。如果 oldCh 少於 newCh,那說明有新增的 DOM。

關於 updateChildren 函式的講述,這篇文章的講述更為詳細:vue的Virtual Dom實現- snabbdom解密 ,大家可以去讀一下~

講完最重要的這個函式,整個核心部分基本上是弄完了,不難發現 snabbdom 的祕訣就在於使用:

  • 使用虛擬 DOM 模擬真實 DOM,JavaScript 記憶體操作效能大大優於 DOM 操作,所以效能比較好。
  • Diff 演算法比較好,只比較同級 vnode,不會迴圈遍歷去比較,而且採用 key 和 sel 標記 vnode,大大優化比較速度。這一做法類似於 Immutable,使用 hash 比較代替物件的迴圈遞迴比較,大大降低時間複雜度。

最後還有一個小問題,這個貫穿許多函式的 insertedVnodeQueue 陣列是幹嘛的?它只在 createElm 函式中進行 push 操作,然後在最後的 insert 鉤子中進行遍歷。仔細一想就可以發現,這個插入 vnode 佇列存起來的是一個 children 的左右子 children,看下面一段程式碼:

h(
	'div',
    {},
    [
        h(/*...*/),
        h(/*...*/),
        h(/*...*/)
    ]
)
複製程式碼

可以看到 div 下面包含了三個 children,那麼當這個 div 元素被插入到 DOM 時,它的三個子 children 也會觸發 insert 事件,所以在插入 vnode 時,會遍歷其所有 children,然後每個 vnode 都會放入到佇列中,在插入之後再統一執行 insert 鉤子。

以上,就寫這麼多吧~多的也沒時間寫了。

八、參考文章

相關文章