如何搞懂虛擬 DOM?看看這篇文章吧!

2022-07-21 14:01:51
React 和 Vue 中都有虛擬 DOM,那麼我們應該如何理解和掌握虛擬 DOM 的精髓呢?下面本篇文章就來帶大家深入瞭解一下虛擬 DOM,希望對大家有所幫助!

如何理解和掌握虛擬 DOM 的精髓?我推薦大家學習 Snabbdom 這個專案。

Snabbdom 是一個虛擬 DOM 實現庫,推薦的原因一是程式碼比較少,核心程式碼只有幾百行;二是 Vue 就是借鑑此專案的思路來實現虛擬 DOM 的;三是這個專案的設計/實現和擴充套件思路值得參考。

snabb /snab/,瑞典語,意思是快速的。

調整好舒服的坐姿,打起精神我們要開始啦~ 要學習虛擬 DOM,我們得先知道 DOM 的基礎知識和用 JS 直接操作 DOM 的痛點在哪裡。

DOM 的作用和型別結構

DOM(Document Object Model)是一種檔案物件模型,用一個物件樹的結構來表示一個 HTML/XML 檔案,樹的每個分支的終點都是一個節點(node),每個節點都包含著物件。DOM API 的方法讓你可以用特定方式操作這個樹,用這些方法你可以改變檔案的結構、樣式或者內容。

DOM 樹中的所有節點首先都是一個 NodeNode 是一個基礎類別。ElementTextComment 都繼承於它。
換句話說,ElementTextComment 是三種特殊的 Node,它們分別叫做 ELEMENT_NODE,
TEXT_NODECOMMENT_NODE,代表的是元素節點(HTML 標籤)、文位元組點和註釋節點。其中 Element 還有一個子類是 HTMLElement,那 HTMLElementElement 有什麼區別呢?HTMLElement 代表 HTML 中的元素,如:<span><img> 等,而有些元素並不是 HTML 標準的,比如 <svg>。可以用下面的方法來判斷這個元素是不是 HTMLElement

document.getElementById('myIMG') instanceof HTMLElement;

為什麼需要虛擬 DOM?

瀏覽器建立 DOM 是很「昂貴」的。來一個經典範例,我們可以通過 document.createElement('p') 建立一個簡單的 p 元素,將屬性都列印出來康康:

可以看到列印出來的屬性非常多,當頻繁地去更新複雜的 DOM 樹時,會產生效能問題。虛擬 DOM 就是用一個原生的 JS 物件去描述一個 DOM 節點,所以建立一個 JS 物件比建立一個 DOM 物件的代價要小很多。

VNode

VNode 就是 Snabbdom 中描述虛擬 DOM 的一個物件結構,內容如下:

type Key = string | number | symbol;

interface VNode {
  // CSS 選擇器,比如:'p#container'。
  sel: string | undefined;
  
  // 通過 modules 操作 CSS classes、attributes 等。
  data: VNodeData | undefined; 
  
   // 虛擬子節點陣列,陣列元素也可以是 string。
  children: Array<VNode | string> | undefined;
  
  // 指向建立的真實 DOM 物件。
  elm: Node | undefined;
  
  /**
   * text 屬性有兩種情況:
   * 1. 沒有設定 sel 選擇器,說明這個節點本身是一個文位元組點。
   * 2. 設定了 sel,說明這個節點的內容是一個文位元組點。
   */
  text: string | undefined;
  
  // 用於給已存在的 DOM 提供標識,在同級元素之間必須唯一,有效避免不必要地重建操作。
  key: Key | undefined;
}

// vnode.data 上的一些設定,class 或者生命週期函數勾點等等。
interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: any[]; // for thunks
  is?: string; // for custom elements v1
  [key: string]: any; // for any other 3rd party module
}

例如這樣定義一個 vnode 的物件:

const vnode = h(
  'p#container',
  { class: { active: true } },
  [
    h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
    ' and this is just normal text'
]);

我們通過 h(sel, b, c) 函數來建立 vnode 物件。h() 程式碼實現中主要是判斷了 b 和 c 引數是否存在,並處理成 data 和 children,children 最終會是陣列的形式。最後通過 vnode() 函數返回上面定義的 VNode 型別格式。

Snabbdom 的執行流程

先來一張執行流程的簡單範例圖,先有個大概的流程概念:

diff 處理是用來計算新老節點之間差異的處理過程。

再來看一段 Snabbdom 執行的範例程式碼:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from 'snabbdom';

const patch = init([
  // 通過傳入模組初始化 patch 函數
  classModule, // 開啟 classes 功能
  propsModule, // 支援傳入 props
  styleModule, // 支援內聯樣式同時支援動畫
  eventListenersModule, // 新增事件監聽
]);

// <p id="container"></p>
const container = document.getElementById('container');

const vnode = h(
  'p#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(container, vnode);

const newVnode = h(
  'p#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!"),
  ]
);

// 再次呼叫 patch(),將舊節點更新為新節點。
patch(vnode, newVnode);

從流程示意圖和範例程式碼可以看出,Snabbdom 的執行流程描述如下:

  • 首先呼叫 init() 進行初始化,初始化時需要設定需要使用的模組。比如 classModule 模組用來使用物件的形式來設定元素的 class 屬性;eventListenersModule 模組用來設定事件監聽器等等。init() 呼叫後會返回 patch() 函數。

  • 通過 h() 函數建立初始化 vnode 物件,呼叫 patch() 函數去更新,最後通過 createElm() 建立真正的 DOM 物件。

  • 當需要更新時,建立一個新的 vnode 物件,呼叫 patch() 函數去更新,經過 patchVnode()updateChildren() 完成本節點和子節點的差異更新。

    Snabbdom 是通過模組這種設計來擴充套件相關屬性的更新而不是全部寫到核心程式碼中。那這是如何設計與實現的?接下來就先來康康這個設計的核心內容,Hooks——生命週期函數。

Hooks

Snabbdom 提供了一系列豐富的生命週期函數也就是勾點函數,這些生命週期函數適用在模組中或者可以直接定義在 vnode 上。比如我們可以在 vnode 上這樣定義勾點的執行:

h('p.row', {
  key: 'myRow',
  hook: {
    insert: (vnode) => {
      console.log(vnode.elm.offsetHeight);
    },
  },
});

全部的生命週期函數宣告如下:

名稱觸發節點回撥引數
prepatch 開始執行none
initvnode 被新增vnode
create一個基於 vnode 的 DOM 元素被建立emptyVnode, vnode
insert元素被插入到 DOMvnode
prepatch元素即將 patcholdVnode, vnode
update元素已更新oldVnode, vnode
postpatch元素已被 patcholdVnode, vnode
destroy元素被直接或間接得移除vnode
remove元素已從 DOM 中移除vnode, removeCallback
post已完成 patch 過程none

其中適用於模組的是:pre, create,update, destroy, remove, post。適用於 vnode 宣告的是:init, create, insert, prepatch, update,postpatch, destroy, remove

我們來康康是如何實現的,比如我們以 classModule 模組為例,康康它的宣告:

import { VNode, VNodeData } from "../vnode";
import { Module } from "./module";

export type Classes = Record<string, boolean>;

function updateClass(oldVnode: VNode, vnode: VNode): void {
  // 這裡是更新 class 屬性的細節,先不管。
  // ...
}

export const classModule: Module = { create: updateClass, update: updateClass };

可以看到最後匯出的模組定義是一個物件,物件的 key 就是勾點函數的名稱,模組物件 Module 的定義如下:

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

export type Module = Partial<{
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}>;

TS 中 Partial 表示物件中每個 key 的屬性都是可以為空的,也就是說模組定義中你關心哪個勾點,就定義哪個勾點就好了。勾點的定義有了,在流程中是怎麼執行的呢?接著我們來看 init() 函數:

// 模組中可能定義的勾點有哪些。
const hooks: Array<keyof Module> = [
  "create",
  "update",
  "remove",
  "destroy",
  "pre",
  "post",
];

export function init(
  modules: Array<Partial<Module>>,
  domApi?: DOMAPI,
  options?: Options
) {
  // 模組中定義的勾點函數最後會存在這裡。
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: [],
  };

  // ...

  // 遍歷模組中定義的勾點,並存起來。
  for (const hook of hooks) {
    for (const module of modules) {
      const currentHook = module[hook];
      if (currentHook !== undefined) {
        (cbs[hook] as any[]).push(currentHook);
      }
    }
  }
  
  // ...
}

可以看到 init() 在執行時先遍歷各個模組,然後把勾點函數存到了 cbs 這個物件中。執行的時候可以康康 patch() 函數裡面:

export function init(
  modules: Array<Partial<Module>>,
  domApi?: DOMAPI,
  options?: Options
) {
  // ...
  
  return function patch(
  oldVnode: VNode | Element | DocumentFragment,
   vnode: VNode
  ): VNode {
    // ...
    
    // patch 開始了,執行 pre 勾點。
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    
    // ...
  }
}

這裡以 pre 這個勾點舉例,pre 勾點的執行時機是在 patch 開始執行時。可以看到 patch() 函數在執行的開始處去迴圈呼叫了 cbs 中儲存的 pre 相關勾點。其他生命週期函數的呼叫也跟這個類似,大家可以在原始碼中其他地方看到對應生命週期函數呼叫的地方。

這裡的設計思路是觀察者模式。Snabbdom 把非核心功能分佈在模組中來實現,結合生命週期的定義,模組可以定義它自己感興趣的勾點,然後 init() 執行時處理成 cbs 物件就是註冊這些勾點;當執行時間到來時,呼叫這些勾點來通知模組處理。這樣就把核心程式碼和模組程式碼分離了出來,從這裡我們可以看出觀察者模式是一種程式碼解耦的常用模式。

patch()

接下來我們來康康核心函數 patch(),這個函數是在 init() 呼叫後返回的,作用是執行 VNode 的掛載和更新,簽名如下:

function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode {
    // 為簡單起見先不關注 DocumentFragment。
    // ...
}

oldVnode 引數是舊的 VNode 或 DOM 元素或檔案片段,vnode 引數是更新後的物件。這裡我直接貼出整理的流程描述:

  • 呼叫模組上註冊的 pre 勾點。

  • 如果 oldVnodeElement,則將其轉換為空的 vnode 物件,屬性裡面記錄了 elm

    這裡判斷是不是 Element 是判斷 (oldVnode as any).nodeType === 1 是完成的,nodeType === 1 表明是一個 ELEMENT_NODE,定義在 這裡。

  • 然後判斷 oldVnodevnode 是不是相同的,這裡會呼叫 sameVnode() 來判斷:

    function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
      // 同樣的 key。
      const isSameKey = vnode1.key === vnode2.key;
      
      // Web component,自定義元素標籤名,看這裡:
      // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement
      const isSameIs = vnode1.data?.is === vnode2.data?.is;
      
      // 同樣的選擇器。
      const isSameSel = vnode1.sel === vnode2.sel;
    
      // 三者都相同即是相同的。
      return isSameSel && isSameKey && isSameIs;
    }
    • 如果相同,則呼叫 patchVnode() 做 diff 更新。
    • 如果不同,則呼叫 createElm() 建立新的 DOM 節點;建立完畢後插入 DOM 節點並刪除舊的 DOM 節點。
  • 呼叫上述操作中涉及的 vnode 物件中註冊的 insert 勾點佇列, patchVnode() createElm() 都可能會有新節點插入 。至於為什麼這樣做,在 createElm() 中會說到。

  • 最後呼叫模組上註冊的 post 勾點。

流程基本就是相同的 vnode 就做 diff,不同的就建立新的刪除舊的。接下來先看下 createElm() 是如何建立 DOM 節點的。

createElm()

createElm() 是根據 vnode 的設定來建立 DOM 節點。流程如下:

  • 呼叫 vnode 物件上可能存在的 init 勾點。

  • 然後分一下幾種情況來處理:

    • 如果 vnode.sel === '!',這是 Snabbdom 用來刪除原節點的方法,這樣會新插入一個註釋節點。因為在 createElm() 後會刪除老節點,所以這樣設定就可以達到解除安裝的目的。

    • 如果 vnode.sel 選擇器定義是存在的:

      • 解析選擇器,得到 idtagclass

      • 呼叫 document.createElement()document.createElementNS 建立 DOM 節點,並記錄到 vnode.elm 中,並根據上一步的結果來設定 idtagclass

      • 呼叫模組上的 create 勾點。

      • 處理 children 子節點陣列:

        • 如果 children 是陣列,則遞迴呼叫 createElm() 建立子節點後,呼叫 appendChild 掛載到 vnode.elm 下。

        • 如果 children 不是陣列但 vnode.text 存在,說明這個元素的內容是個文字,這個時候呼叫 createTextNode 建立文位元組點並掛載到 vnode.elm 下。

      • 呼叫 vnode 上的 create 勾點。並將 vnode 上的 insert 勾點加入到 insert 勾點佇列。

    • 剩下的情況就是 vnode.sel 不存在,說明節點本身是文字,那就呼叫 createTextNode 建立文位元組點並記錄到 vnode.elm

  • 最後返回 vnode.elm

整個過程可以看出 createElm() 是根據 sel 選擇器的不同設定來選擇如何建立 DOM 節點。這裡有個細節是補一下: patch() 中提到的 insert 勾點佇列。需要這個 insert 勾點佇列的原因是需要等到 DOM 真正被插入後才執行,而且也要等到所有子孫節點都插入完成,這樣我們可以在 insert 中去計算元素的大小位置資訊才是準確的。結合上面建立子節點的過程,createElm() 建立子節點是遞迴呼叫,所以佇列會先記錄子節點,再記錄自身。這樣在 patch() 的結尾執行這個佇列時就可以保證這個順序。

patchVnode()

接下來我們來看 Snabbdom 如何用 patchVnode() 來做 diff 的,這是虛擬 DOM 的核心。patchVnode() 的處理流程如下:

  • 首先執行 vnode 上 prepatch 勾點。

  • 如果 oldVnode 和 vnode 是同一個物件參照,則不處理直接返回。

  • 呼叫模組和 vnode 上的 update 勾點。

  • 如果沒有定義 vnode.text,則處理 children 的幾種情況:

    • 如果 oldVnode.childrenvnode.children 均存在並且不相同。則呼叫 updateChildren 去更新。

    • vnode.children 存在而 oldVnode.children 不存在。如果 oldVnode.text 存在則先清空,然後呼叫 addVnodes 去新增新的 vnode.children

    • vnode.children 不存在而 oldVnode.children 存在。呼叫 removeVnodes 移除 oldVnode.children

    • 如果 oldVnode.childrenvnode.children 均不存在。如果 oldVnode.text 存在則清空。

  • 如果有定義 vnode.text並且與 oldVnode.text 不同。如果 oldVnode.children 存在則呼叫 removeVnodes 清除。然後通過 textContent 來設定文字內容。

  • 最後執行 vnode 上的 postpatch 勾點。

從過程可以看出,diff 中對於自身節點的相關屬性的改變比如 classstyle 之類的是依靠模組去更新的,這裡不過多展開了大家有需要可以去看下模組相關的程式碼。diff 的主要核心處理是集中在 children 上,接下來康康 diff 處理 children 的幾個相關函數。

addVnodes()

這個很簡單,先呼叫 createElm() 建立,然後插入到對應的 parent 中。

removeVnodes()

移除的時候會先呼叫 destoryremove 勾點,這裡重點講講這兩個勾點的呼叫邏輯和區別。

  • destory,首先呼叫這個勾點。邏輯是先呼叫 vnode 物件上的這個勾點,再呼叫模組上的。然後對 vnode.children 也按照這個順序遞迴呼叫這個勾點。
  • remove,這個 hook 只有在當前元素從它的父級中刪除才會觸發,被移除的元素中的子元素則不會觸發,並且模組和 vnode 物件上的這個勾點都會呼叫,順序是先呼叫模組上的再呼叫 vnode 上的。而且比較特殊的是等待所有的 remove 都會呼叫後,元素才會真正被移除,這樣做可以實現一些延遲刪除的需求。

以上可以看出這兩個勾點呼叫邏輯不同的地方,特別是 remove 只在直接脫離父級的元素上才會被呼叫。

updateChildren()

updateChildren() 是用來處理子節點 diff 的,也是 Snabbdom 中比較複雜的一個函數。總的思想是對 oldChnewCh 各設定頭、尾一共四個指標,這四個指標分別是 oldStartIdxoldEndIdxnewStartIdxnewEndIdx。然後在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) 迴圈中對兩個陣列進行對比,找到相同的部分進行復用更新,並且每次比較處理最多移動一對指標。詳細的遍歷過程按以下順序處理:

  • 如果這四個指標有任何一個指向的 vnode == null,則這個指標往中間移動,比如:start++ 或 end--,null 的產生在後面情況有說明。

  • 如果新舊開始節點相同,也就是 sameVnode(oldStartVnode, newStartVnode) 返回 true,則用 patchVnode() 執行 diff,並且兩個開始節點都向中間前進一步。

  • 如果新舊結束節點相同,也採用 patchVnode() 處理,兩個結束節點向中間後退一步。

  • 如果舊開始節點與新結束節點相同,先用 patchVnode() 處理更新。然後需要移動 oldStart 對應的 DOM 節點,移動的策略是移動到 oldEndVnode 對應 DOM 節點的下一個兄弟節點之前。為什麼是這樣移動呢?首先,oldStart 與 newEnd 相同,說明在當前迴圈處理中,老陣列的開始節點是往右移動了;因為每次的處理都是首尾指標往中間移動,我們是把老陣列更新成新的,這個時候 oldEnd 可能還沒處理,但這個時候 oldStart 已確定在新陣列的當前處理中是最後一個了,所以移動到 oldEnd 的下一個兄弟節點之前是合理的。移動完畢後,oldStart++,newEnd--,分別向各自的陣列中間移動一步。

  • 如果舊結束節點與新開始節點相同,也是先用 patchVnode() 處理更新,然後把 oldEnd 對應的 DOM 節點移動 oldStartVnode 對應的 DOM 節點之前,移動理由同上一步一樣。移動完畢後,oldEnd--,newStart++。

  • 如果以上情況都不是,則通過 newStartVnode 的 key 去找在 oldChildren 的下標 idx,根據下標是否存在有兩種不同的處理邏輯:

    • 如果下標不存在,說明 newStartVnode 是新建立的。通過 createElm() 建立新的 DOM,並插入到 oldStartVnode 對應的 DOM 之前。

    • 如果下標存在,也要分兩種情況處理:

      • 如果兩個 vnode 的 sel 不同,也還是當做新建立的,通過 createElm() 建立新的 DOM,並插入到 oldStartVnode 對應的 DOM 之前。

      • 如果 sel 是相同的,則通過 patchVnode() 處理更新,並把 oldChildren 對應下標的 vnode 設定為 undefined,這也是前面雙指標遍歷中為什麼會出現 == null 的原因。然後把更新完畢後的節點插入到 oldStartVnode 對應的 DOM 之前。

    • 以上操作完後,newStart++。

遍歷結束後,還有兩種情況要處理。一種是 oldCh 已經全部處理完成,而 newCh 中還有新的節點,需要對 newCh 剩下的每個都建立新的 DOM;另一種是 newCh 全部處理完成,而 oldCh 中還有舊的節點,需要將多餘的節點移除。這兩種情況的處理在 如下:

  function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) { 
    // 雙指標遍歷過程。
    // ...
      
    // newCh 中還有新的節點需要建立。
    if (newStartIdx <= newEndIdx) {
      // 需要插入到最後一個處理好的 newEndIdx 之前。
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
      
    // oldCh 中還有舊的節點要移除。
    if (oldStartIdx <= oldEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

我們用一個實際例子來看一下 updateChildren() 的處理過程:

  • 初始狀態如下,舊子節點陣列為 [A, B, C],新節點陣列為 [B, A, C, D]:

  • 第一輪比較,開始和結束節點都不一樣,於是看 newStartVnode 在舊節點中是否存在,找到了在 oldCh[1] 這個位置,那麼先執行 patchVnode() 進行更新,然後把 oldCh[1] = undefined,並把 DOM 插入到 oldStartVnode 之前,newStartIdx 向後移動一步,處理完後狀態如下:

  • 第二輪比較,oldStartVnodenewStartVnode 相同,執行 patchVnode() 更新,oldStartIdxnewStartIdx 向中間移動,處理完後狀態如下:

  • 第三輪比較,oldStartVnode == nulloldStartIdx 向中間移動,狀態更新如下:

  • 第四輪比較,oldStartVnodenewStartVnode 相同,執行 patchVnode() 更新,oldStartIdxnewStartIdx 向中間移動,處理完後狀態如下:

  • 此時 oldStartIdx 大於 oldEndIdx,迴圈結束。此時 newCh 中還有沒處理完的新節點,需要呼叫 addVnodes() 插入,最終狀態如下:

總結

到這裡虛擬 DOM 的核心內容已經梳理完畢,Snabbdom 的設計和實現原理我覺得挺好的,大家有空可以去康康原始碼的細節再細品下,其中的思想很值得學習。

更多程式設計相關知識,請存取:!!

以上就是如何搞懂虛擬 DOM?看看這篇文章吧!的詳細內容,更多請關注TW511.COM其它相關文章!