首頁  >  文章  >  web前端  >  如何搞懂虛擬 DOM?來看看這篇文章吧!

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

青灯夜游
青灯夜游轉載
2022-07-21 10:46:531956瀏覽

React 和 Vue 中都有虛擬 DOM,那我們該如何理解和掌握虛擬 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);
    },
  },
});

全部的生命周期函数声明如下:

名称 触发节点 回调参数
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 元素已从 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?來看看這篇文章吧!的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除