• 技术文章 >web前端 >js教程

    如何搞懂虚拟 DOM?看看这篇文章吧!

    青灯夜游青灯夜游2022-07-21 20:21:34转载189
    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 的运行流程描述如下:

    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 参数是更新后的对象。这里我直接贴出整理的流程描述:

    流程基本就是相同的 vnode 就做 diff,不同的就创建新的删除旧的。接下来先看下 createElm() 是如何创建 DOM 节点的。

    createElm()

    createElm() 是根据 vnode 的配置来创建 DOM 节点。流程如下:

    整个过程可以看出 createElm() 是根据 sel 选择器的不同设置来选择如何创建 DOM 节点。这里有个细节是补一下: patch() 中提到的 insert 钩子队列。需要这个 insert 钩子队列的原因是需要等到 DOM 真正被插入后才执行,而且也要等到所有子孙节点都插入完成,这样我们可以在 insert 中去计算元素的大小位置信息才是准确的。结合上面创建子节点的过程,createElm() 创建子节点是递归调用,所以队列会先记录子节点,再记录自身。这样在 patch() 的结尾执行这个队列时就可以保证这个顺序。

    patchVnode()

    接下来我们来看 Snabbdom 如何用 patchVnode() 来做 diff 的,这是虚拟 DOM 的核心。patchVnode() 的处理流程如下:

    从过程可以看出,diff 中对于自身节点的相关属性的改变比如 classstyle 之类的是依靠模块去更新的,这里不过多展开了大家有需要可以去看下模块相关的代码。diff 的主要核心处理是集中在 children 上,接下来康康 diff 处理 children 的几个相关函数。

    addVnodes()

    这个很简单,先调用 createElm() 创建,然后插入到对应的 parent 中。

    removeVnodes()

    移除的时候会先调用 destoryremove 钩子,这里重点讲讲这两个钩子的调用逻辑和区别。

    以上可以看出这两个钩子调用逻辑不同的地方,特别是 remove 只在直接脱离父级的元素上才会被调用。

    updateChildren()

    updateChildren() 是用来处理子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思想是对 oldChnewCh 各设置头、尾一共四个指针,这四个指针分别是 oldStartIdxoldEndIdxnewStartIdxnewEndIdx。然后在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) 循环中对两个数组进行对比,找到相同的部分进行复用更新,并且每次比较处理最多移动一对指针。详细的遍历过程按以下顺序处理:

    遍历结束后,还有两种情况要处理。一种是 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() 的处理过程:

    总结

    到这里虚拟 DOM 的核心内容已经梳理完毕,Snabbdom 的设计和实现原理我觉得挺好的,大家有空可以去康康源码的细节再细品下,其中的思想很值得学习。

    更多编程相关知识,请访问:编程视频!!

    以上就是如何搞懂虚拟 DOM?看看这篇文章吧!的详细内容,更多请关注php中文网其它相关文章!

    声明:本文转载于:segmentfault,如有侵犯,请联系admin@php.cn删除
    上一篇:深入聊聊Node 异步和事件循环的底层实现和执行机制 下一篇:万字图解JavaScript笔记总结
    VIP课程(WEB全栈开发)

    相关文章推荐

    • 【活动】充值PHP中文网VIP即送云服务器• 虚拟dom原理流程的分析与实现• React中虚拟dom与diff算法的讲解(附代码)• Vue中虚拟dom比较原理的介绍(示例讲解)• 虚拟DOM怎么实现?(代码示例)• 一文彻底的弄懂Vue中的虚拟DOM和 Diff 算法• react虚拟dom有什么用处
    1/1

    PHP中文网