源码级别人话说:Virtual DOM和DOM diff算法

3,587 阅读26分钟

摸鱼酱的文章声明:内容保证原创,纯技术干货分享交流,不打广告不吹牛逼。

前言:在我还没有理解虚拟dom概念和dom diff算法之前,经常会不经意间从各种渠道听到这两个词语。当时每每听到看到,尤其是群友吹水谈到这两个词语的时候,我都会感觉我的技术水平菜人一等,这就更不用说我面试的时候有多害怕面试官会提到这些了。我相信在看官当中,也会有不少当时的我。这篇文章的目的就是希望能够通过分享我对这两个概念的理解,帮助阁下(当时的我)建立或者加深对虚拟dom和dom diff算法的理解。

好的,下面进入正文,对于virtual dom和dom diff算法的系统化认识,我预先用脑图为阁下做了以下整理:

下面我会分成以下两个部分来展开探讨,以求能够帮助您建立自己对Virtual DOM和DOM diff算法的理解:

  • 理解Virtual DOM
  • Virtual DOM库,Snabbdom源码分析

一:理解Virtual DOM

1.Virtual DOM概念

说到Virtual Dom那肯定要从Dom说起,毕竟如果没有Dom,你Virtual个屁哦。所以在探讨Virtual Dom的概念之前,我们再回顾一波Dom。

MDN中对DOM的定义是:DOM(Document Object Model——文档对象模型)是用来呈现以及与任意 HTML 或 XML文档交互的API。DOM是载入到浏览器中的文档模型,以节点树的形式来表现文档,每个节点代表文档的构成部分(例如:页面元素、字符串或注释等等)。

我不知道上面是不是意指DOM具有两个概念,对于第一个概念,我把它理解成和BOM、ORM是一类的概念。对于第二部分,我就直接把它当作解释由DOM节点组成的DOM树来理解了。

对于Virtual DOM,我没有找到一种权威定义,网上的一种主流解释是:Virtual Dom(Document Object Model)是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫Virtual DOM。

我并不认可上述概念,我觉得那只是对Virtual DOM的一个现象解释,但是而现象不等于概念。既然没有权威定义,那么我也不在这里咬文嚼字给它下定义了,我对他的理解是很简单,就是Virtual的DOM。DOM一词无须再解释,下面分享一下我对于virtual这个词的理解。

对应于DOM第一部分概念:我认为virtual意指在实际的dom操作之前加一层虚拟DOM操作切面,在这个切面中可以拦截、改变将要发生的DOM操作。对应于DOM第二部分概念:我认为virtual意指在DOM树之前,有一颗可以一一映射成它的虚拟DOM树(由普通JS模拟的节点对象组成)

如果我的理解有问题或者阁下对Virtual DOM有什么高见,欢迎评论处指点一下,谢谢。

好的,有了以上认知之后,接下来我们演示一个demo,实际感受一下DOM渲染UI与Virtual DOM渲染UI两种方式的不同。

2.Virtual DOM渲染UI示例

假设我们的需求如下是实现一个如下html结构的web ui:

<div>
    hello
    <!-- this is a notes node -->
    <ul>
        <li id="1" class="li-1">first li</li>
        <li id="2" class="li-2">second li</li>
    </ul>
</div>

为了探讨DOM更新,我们让我们的示例中div的text(即hello部分)每一秒都会从[hello、visual、world]中随机选择一个显示,也就是说我们的探讨会涉及以下两个部分,即:

  • dom结构初始化
  • dom结构更新

好的,下面我们先用原生DOM来渲染上述UI结构,而后借由Virtual DOM库snabbdom来渲染上述UI结构。

原生DOM渲染UI示例:

在直接丢出一坨代码恶心阁下之前,我有必要再为阁下明确一点,下述这个示例演示目的是:实践如何通过原生DOM方式,把我们用JS生成的DOM结构初始化或更新渲染到已有的id为app的dom位置上。

我再来缕一下如下示例的编写思路,以便帮助阁下理解,思路如下:

  • 1.封装一个createDivNode函数,用于以原生dom方式创建整个DIV节点
  • 2.封装一个render函数,用于初始化渲染UI结构或者更新UI结构
  • 3.调用render函数初始化div的DOM结构
  • 4.调用render函数更新div的DOM结构

看示例的时候,建议阁下联系思考一下vue和react框架提供的render函数。

    <div id="app"></div>	<!-- div的DOM结构渲染位置 -->
    <script>
      let app = document.getElementById("app");
      // 原生dom api创建整个DIV节点
      function createDivNode() {
        const divNode = document.createElement("div");
        const textNode = document.createTextNode(["hello", "visual", "dom"][Date.now()%3]);
        divNode.appendChild(textNode);
        const notesNode = document.createComment("this is a notes node");
        divNode.appendChild(notesNode);

        function createLiNode(props, text) {
          const li = document.createElement("li");
          for (key in props) {
            const attr = document.createAttribute(key);
            attr.value = props[key];
            li.setAttributeNode(attr);
          }
          const textNode = document.createTextNode(text);
          li.appendChild(textNode);
          return li;
        }
        divNode.appendChild(createLiNode({ id: 1, class: "li-1" }, "first li"));
        divNode.appendChild(
          createLiNode({ id: 2, class: "li-2" }, "second li")
        );
        return divNode;
      }
      
      // 渲染dom
      function render(action) {
        function replaceDom() {
          const newDiv = createDivNode();
          app.parentNode.appendChild(newDiv);
          app.parentNode.removeChild(app);
          app = newDiv;
        }
        switch (action) {
          case "init":
            replaceDom();
            break;
          case "update":
            // 更新时直接替换整个div的DOM结构
            replaceDom();
            break;
        }
      }
	
      // 初始化DOM结构
      render("init");

      // 更新DOM结构
      setInterval(() => {
        render("update");
      }, 1000);
    </script>

snabbdom渲染UI示例:

同样,我有必要再为阁下明确一点,下述这个示例演示目的是:实践如何通过虚拟DOM方式,把我们用JS生成的DOM结构初始化或更新渲染到已有的id为app的dom位置上。

接下来是如下示例的编写思路:

  • 1.封装一个createDivNode函数,用于以virtual dom方式创建整个DIV节点
  • 2.封装一个render函数,用于初始化渲染UI结构或者更新UI结构
  • 3.调用render函数初始化div的DOM结构
  • 4.调用render函数更新div的DOM结构

看示例的时候,建议阁下联系思考一下vue和react框架提供的render函数。

   <div id="app"></div>	<!-- div的DOM结构渲染位置 -->
   <script src="https://cdn.bootcss.com/snabbdom/0.7.4/snabbdom.js"></script>
   <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>
   <script>
     let app = document.getElementById("app");
     const patch = snabbdom.init([]);
     const h = snabbdom.h;
     
     // virtual dom创建整个DIV节点
     function createDivVNode() {
       function createLiVNode(sel, text) {
         return h(sel, text);
       }
		
       // 调用snabbdom的h函数创建Viratul Node对象
       const vnode = h("div", [
         ["hello", "visual", "dom"][Date.now()%3],
         h("!", "this is a notes node"),
         createLiVNode("li#1.li-1", "first li"),
         createLiVNode("li#2.li-2", "second li"),
       ]);

       return vnode;
     }

     // 渲染DOM结构
     function render(action) {
       switch (action) {
         case "init":
           // 调用sanbbdom的patch函数渲染div的虚拟DOM结构到app位置上
           app = patch(app, createDivVNode());
           break;
         case "update":
           // 调用sanbbdom的patch函数更新div的虚拟DOM结构到app位置上
           app = patch(app, createDivVNode());
           break;
       }
     }
	
     // 初始化DOM结构
     render("init");
	
     // 更新DOM结构
     setInterval(() => {
       render("update");
     }, 1000);
   </script>

通过这个示例的实践之后,下面我们再理解一下虚拟DOM。

如此用心传道的博主,不值得你点赞关注一波?

3.理解虚拟dom

1)虚拟dom渲染UI的过程

通过上面的实践,相信阁下已经领悟了虚拟dom初始化渲染UI和更新渲染UI的做法。总结来说,包括现如今的vue和react做法,通常是遵循以下步骤完成最终的UI渲染:

理解以下步骤时,建议阁下联系思考一下vue和react框架的render函数。

  • 用户传入jsx语法或template语法的dom结构(上述示例省略了这一步)
  • 框架解析或直接指定得到dom节点描述(上述示例就是直接指定的)
  • 根据dom节点描述生成虚拟dom节点(snabbodom的h函数)
  • 把虚拟dom节点渲染到指定原生dom节点app处(snabbodom的patch函数)

简明步骤即为:jsx/template -> dom description -> vNode -> app。

温馨提示:这一段过程的符号描述离开了上述解释后会产生歧义哦。

前三个步骤其实都比较好理解,所以在结束Virtual DOM理论探讨,进入snabbdom源码分析之前,我想再好好我对第四个过程中涉及到的理论理解,即虚拟dom节点是如何渲染成最终的UI节点的

2)虚拟dom节点渲染UI原理

虚拟dom的概念解释前面已经探讨过了,下面我们通过对比dom节点来理解一下虚拟dom节点的概念。

DOM Node与Virtual DOM Node:

DOM节点是DOM树的组成单位,查阅MDN文档中的节点类型后得知,DOM节点可分为九种类型,而其中以下四种类型是我们学习虚拟DOM所需要关注的,即:

  • 注释节点:Node.COMMENT_NODE
  • 文本节点:Node.TEXT_NODE
  • 属性节点:Node.ATTRIBUTE_NODE
  • 元素节点 :Node.DOCUMENT_NODE

对于Virtual DOM Node,它则是Virtual Dom中用来描述Dom节点对象的普通JS对象,也即通过一定手段,可以把一个虚拟DOM节点转为一个真实DOM节点。

总结来说,Dom的Node节点可以被渲染为web视图(Node -> UI),Virtual Dom的Vnode可以被解析为Dom的Node节点(VNode -> Node),叠加一下就是Vnode可以被渲染成web视图(VNode -> Node -> UI),这就是Virtual Dom Node渲染UI的原理

这里说到Node和VNode两种渲染UI的方式,下面我们对比一下二者的差别。

Node -> UI 与 VNode -> Node -> UI:

从面向切面的编程思想来看,VNode -> Node -> UI就是比 Node -> UI多了一个前置切面。通常来说,渲染同样的UI,多了一层切面必然会加长JS执行的时间

但是为什么普遍认为前端项目大了复杂了之后,使用Virtual Dom取代Dom操作能够提高UI渲染速度优化用户体验呢?

我觉得仅对于初始化渲染而言,使用Virtual Dom必然会导致更慢,因为你始终要产生UI界面所需要的全部Node节点并渲染,多的这个切面并不能减少某些节点的增删或者修改

那么virtual dom加快渲染速度的的所有价值也就只能体现在UI根节点修改时的UI渲染速度加快了。事实上,也不总是变快。这取决于在虚拟dom节点在更新渲染UI时,这个虚拟切面附加的js运行时间,与减少渲染解析后的Node的时间之间的比较,即renderTime( VNode -> Node ) ↑ vs renderTime( Node -> UI ) ↓。

renderTime( VNode -> Node ) ↑ vs renderTime( Node -> UI ) ↓:

VNode -> Node这个白加的切面需要js解析运行时间会导致UI渲染时间变长就不需要解释了。

这里探讨一下为什么经过virtual dom的VNode -> Node这个切面后,Node -> UI的时间会缩短

首先我们得先知道一点,性能,一个UI节点变化会涉及到以下三个过程:

  • 创建整个新节点
  • 渲染整个新节点
  • 删除整个老节点

这个过程意味着,浏览器会完整的执行完对整个新dom节点的解析、重排重绘过程,这里的性能消耗是巨大的。为什么这里的消耗是巨大的,在本文中我就不多说了,因为我已经看到很多人的文章对这一点的讲解都比我好。

使用virtual dom时,在经过VNode -> Node的这个切面对比新旧节点后,程序会最大限度的寻找并复用老节点中现有的dom元素,不变的不变,变化的移动或更新,缺少的补上,多余的删除。这样就能避免创建新节点和删除老节点的时间,同时可以最大限度的减少浏览器对新dom结构的子节点解析以及重排重绘时间。

好的,如果您认真看到这里,相信你就已经达到理解虚拟dom的目的了。此外,有一些关于virtual dom面试题的答案也可以很自然的理解了,再也不用背那些面试题更不怕被揭穿了。

为了更加理解深入的虚拟DOM,下面我们就实际分析一个成熟的virtual dom库snabbdom源码。

二:Virtual DOM库,Snabbdom源码分析

经过上面对虚拟dom树渲染UI例子的实践,我们得知了snabbdom的h函数用于把节点描述转为虚拟dom对象,在UI需要初始化渲染或者更新渲染时,通过snabbdom的patch函数把新的虚拟dom节点渲染到对应的老的虚拟dom节点或者原生dom节点位置上

总结来说,经过上面的探讨,我们可以把初始化渲染的过程抽象成dom description -> virtual dom tree -> dom tree -> ui。把更新渲染的过程抽象成 new virtual dom tree -> dom diff -> update dom tree。

有了以上认知之后,我们就可以带着以下几个问题去Snabbdom源码中找答案了。问题如下:

  • snabbodom的h函数如何以dom节点描述作为参数输入创建得到虚拟dom节点对象(dom description -> virtual dom tree)?
  • snabbodom的patch函数如何把新的虚拟dom对象渲染在老的虚拟dom或原生dom对象位置上(virtual dom tree -> UI)?
    • snabbodom的createEle函数如何把Virtual Node对象转为Dom Node对象(virtual dom tree -> dom tree)?
    • snabbdom的patchVnode函数和updateChildren函数如何通过DOM diff算法实现Virtual DOM树的差异更新(new virtual dom tree -> dom diff -> update old dom tree)?

理解问题从来都是比掌握答案更重要,所以如果您有兴趣继续看我下面的源码分析讲解,我希望您能在这里多停留一些时间。

Stop. Stop. Stop.

OK,下面进入第一个问题,snabbodom的h函数如何完成过程dom description -> virtual dom tree?

1.h函数实现dom description -> virtual dom tree

在源码中,snabbdom通过typescript的函数重载技术,为使用者定义了四种调用h函数的方式(第五个h函数才是具体实现),为了让阁下更容易get到要点,我们收缩代码以图片方式展示。

总的来说,对于snabbdom的h函数输入部分,它的做法是把形参dom描述分成了以下三个部分:

温馨提示:它怎么分的其实并不重要,重要的是你对于dom描述本身的理解,明白到底怎么样的信息结构可以把一个实际的dom对象进行完整描述。

  • sel:dom的id和class属性值
  • data:dom的属性节点信息
  • children:子节点

对于h函数输出的虚拟节点对象,snabbdom的做法则是用以下结构来描述:

export function vnode (sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined): VNode {
  const key = data === undefined ? undefined : data.key
  return { sel, data, children, text, elm, key }
}

这个结构的认识我认为是理解snabbom实现虚拟dom而言最重要的一点,它是snabbdom库的核心数据结构(众所周知,一个算法的性能会和所用的数据结构强关联)。所以,建议阁下在这里也停留吸收一下。

在理解了更为重要的函数输入输出之后,对于函数体部分(第五个h函数),下面是我对它内部核心逻辑的总结:

  • 首先,解析参数输入实现函数重载
  • 其次,进行输入预处理,先是子节点的输入分为两种类型(为的是加快效率),即单文本children节点和children节点数组,而后为sag添加命名空间。
  • 最后,转给vnode函数取出data中的key后构造得到一个vNode节点对象。

这里需要注意的是,用户所意创建的dom节点是分类型的,即以下四种,即注释节点、文本节点、元素节点和属性节点。但是h函数创建的virtual node却是不区分这些节点类型的,这是不是会造成信息丢失呢?没关系,virtual node会携带区分节点类型的信息,以备后续能够根据虚拟node中存储的信息来还原用户希望创建类型下的dom node

经过以上步骤,我们就从snabbdom源码中找到了其h函数创建Virtual Node的答案。下面我们进入第二个部分,snabbodom的patch函数如何完成过程virtual dom tree -> UI

2.patch函数实现virtual dom tree -> UI

从上述示例中我们可以看到,snabbdom它并没有像h函数一样直接提供patch函数。这是因为snabbodm是一个平台性质的库,它需要提供钩子机制来保证扩展性,并提供模块的概念来注册这些钩子。所以为了注册这些钩子,我们需要通过init函数注册模块,而后才能拿到一个满足我们定制要求的patch函数,实现虚拟dom树的渲染。总而言之,通过init函数的调用,我们就可以拿到渲染UI的patch函数。

好的,下面我们剖析patch函数。

我们前面说到,snabbdom的patch函数用于把新的虚拟dom节点渲染到对应的老的虚拟dom节点或者原生dom节点位置上。详细点说就是,patch函数接收一个老的虚拟node对象或一个原生dom对象和一个新的虚拟node对象,之后用新的虚拟node对象的信息去更新老的虚拟node对象或原生dom对象,更新完成后返回新的虚拟node对象以作为下一次更新的老的虚拟node对象。

带着以上的理解,我们看一下收缩后的patch函数:

我们先看一下它的输入部分,形参oldVnode的类型为VNode或Element(原生dom类型)。这样设计的目的是为了兼容第一次更新,在这时,老的节点对象是dom元素(如示例中的id为app的div元素)而不是虚拟node节点。

对于它的输出部分,返回新的虚拟node对象,这没什么好说的。

下面我们重点分析一下它的函数体部分,函数比较复杂,我们用面向过程的编程思想来总结一下它用vnode去更新oldVnode的步骤,步骤如下:

  • step1:判断老节点是否是是一个dom节点,如果是就转换成虚拟dom节点
  • step2:判断新老节点是否相同(根据sel和key属性),如果相同就用新节点更新老节点,如果不同就用新节点替换老节点。

主要逻辑就是以上几个步骤,细枝末节的东西我们就不展开探讨。对于step1,也就是一个把虚拟dom节点转换成dom节点的过程,具体的转换逻辑我会在后面二点creteEle函数探讨中分析说明。

下面我们重点解释一下step2过程,先说简单的,解释一下新老节点不同时具体如何替换,逻辑简单,先上源码:

   else {  // step3.2:如果不同就用新节点“替换”老节点
      elm = oldVnode.elm!;
      parent = api.parentNode(elm) as Node;
      createElm(vnode, insertedVnodeQueue);
      if (parent !== null) {
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
    }

通过以上源代码我们就能够很清晰的知道他是如何替换的了,逻辑上也就是调用createEle函数传入新虚拟节点得到dom节点(具体源码实现后面会分析),然后把它插入到老虚拟节点对应的dom节点的后面,最后删除老dom节点,virtual dom初始化渲染的逻辑应该就属于这部分

对于step2中,在新老节点相同时,如何用新节点更新老节点的问题,它则是snabbdom实现virtual dom最核心的逻辑,我们先上源码:

if (sameVnode(oldVnode, vnode)) { // step3.1:如果相同就用新节点更新老节点
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
}

从上述源代码中我们可以看到在新老节点相同时,snabbdom通过patchNode函数来实现用新虚拟节点更新老虚拟节点对应的dom节点,这是virtual dom更新渲染的逻辑

探讨到这里,我们已经把初始化渲染的原理说通了,而对于更新渲染的原理我们则还需要探讨一下patchNode函数。在那之前,我们先讲解一个前面多次涉及到的问题,就是snabbdom如何通过createEle函数如何完成过程virtual dom tree -> dom tree。

好的,下面进入createEle函数如何完成过程virtual dom tree -> dom tree的探讨。

3.createEle函数实现virtual dom tree -> dom tree

createEle函数以虚拟node对象作为输入,经过一定的逻辑转换后返回输出一个dom节点。在这个过程中,我们还需要关注前面说到的一点,就是通过h函数创建虚拟节点时把dom节点类型丢失了,我们必须得创建dom tree时必须还原这些类型,即文本节点、注释节点、属性节点和元素节点。

好的,经过以上提醒之后,我们看一下createEle函数收缩后的源代码结构:

函数输入虚拟DOM节点,输出DOM节点,这一点没什么好说的,下面我们剖析一下它的函数体部分,探究一下它如何根据vnode对象信息来来还原创建多种类型的dom节点

总的来说,源码中得做法是对于需要创建的四种类型的dom节点,使用vnode对象的选择器属性sel来区分,具体规则如下:

  • 没有sel属性值:视为文本节点的创建
  • sel属性值为感叹号!:视为注释节点的创建
  • sel属性值有且不为!:视为元素的创建,在元素创建的同时不但会创建元素节点还会根据sel属性值来创建id和class属性节点。而其它属性节点需要引入钩子模块,在执行钩子模块的钩子时创建。

判断为文本和注释节点之后的DOM节点对象的创建很容易,没什么好说的。下面我们探讨一下相对较难的元素节点的创建,其难点在于元素节点是一个容器节点所以必然涉及到递归创建元素,上源代码:

温馨提示:不要被下面一坨代码吓到了,我们的目的很简单,找到容器节点的递归调用逻辑。

else if (sel !== undefined) { // type2、3:元素节点和id、class属性节点的创建(其它的属性节点在钩子模块的钩子中创建)
      // 假设sel = "#ii.c1.c2"
      const hashIdx = sel.indexOf("#"); // hashIdx = 0
      const dotIdx = sel.indexOf(".", hashIdx); // dotIdx = 3
      const hash = hashIdx > 0 ? hashIdx : sel.length;  // hash = 9
      const dot = dotIdx > 0 ? dotIdx : sel.length; // dot = 3
      const tag =
        hashIdx !== -1 || dotIdx !== -1
          ? sel.slice(0, Math.min(hash, dot))
          : sel;  // tag = "#ii"
      const elm = (vnode.elm =
        isDef(data) && isDef((i = data.ns))
          ? api.createElementNS(i, tag)
          : api.createElement(tag));  // elm = 一个带id属性的空节点
      if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot)); // 为elm设置属性:id = "ii"
      if (dotIdx > 0)
        elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));  // 为elm设置属性:class = "c1 c2"
      // 总结1:经过以上操作,sel属性解析完成,创建了一个带id属性和class属性的空元素节点
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      if (is.array(children)) { // 递归创建元素子节点并挂载在该元素上
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      const hook = vnode.data!.hook;
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode);
        if (hook.insert) {
          insertedVnodeQueue.push(vnode);
        }
      }
    }

在上述辣么长的源代码中,除了钩子代码之外也就只要做了以下两件事情:

  • 解析sel属性创建一个带id和class属性节点的空容器元素节点
  • 解析children属性,遍历递归创建每一个children虚拟节点,重复判断和创建过程

完整的virtual dom tree到dom tree的过程我们探讨完了,回顾一下核心逻辑,解析每一个virtual node把它转为dom node,只是还原过程中多了一些类型判断逻辑罢了!

好的,下面我们就进入最后一个也是最重要的部分,探讨virtual dom更新渲染的原理,也即探讨过程new virtual dom tree -> dom diff -> update old dom tree?

4.实现dom的差异更新new virtual dom tree -> dom diff -> update old dom tree

实现Virtual DOM更新也就是以生成新虚拟node对应的dom结构为目标,通过最大限度的从现有的dom树中寻找并复用已有的dom节点的方式减少dom节点的创建和渲染,加快UI渲染速度。

从算法角度来说,就是实现树节点的差异更新,也就是diff算法。

对于diff算法,我暂时没有研究,搜索资料得知:要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)。

我们不管TM的O(n^3)是咋整出来的(因为我也不知道,被自己菜哭o(╥﹏╥)o),我们说说Dom diff算法以及它的时间复杂度O(n)是怎么来的。

1) DOM diff算法及其时间复杂度分析

为了避免上来就丢源码恶心阁下,我强迫自己写下了下面这么一大段话,如此良心,不值得你的点赞关注吗?

Dom树也是树结构,但是相对于普通的树,Dom树的更新通常会有一些特征,也就是我们很少很少会把一个父节点移动/更新到某一个子节点。所以我们在dom树比较的时候,为了加快比较速度,可以把两颗树结构之间的比较简化为一层一层的两颗树上的相同节点下的有序线性结构之间的比较,如下图:

好的,经过以上分析之后,我们把新老virtual dom树结构的比较更新简化为了两颗树上的相同节点下的有序线性结构之间的比较,核心也就是树递归 + 两个有序线性结构之间的比较。

树递归没什么好说的,我们讨论一下两个有序线性结构之间如何比较。

首先明确一下这两个有序线性结构分别为啥,一个是老dom树节点的子节点数组(实际程序比较时取对应virtual dom树节点的子结点数组),另一个是新虚拟dom树节点的子节点数组。

明确一下比较目的是啥,比较目的就是以新虚拟dom树节点的子节点数组的顺序和元素为准,对老dom树节点的节点进行移动、更新、添加、删除。

分析清楚比较双方和比较目的之后,我们思考一下应该如何比较。我想到的最直接简单的思路就是拿到新虚拟节点数组的所有键分别去老虚拟节点数组找相等的键,找到了就复用(一次),没找到就删除。这个思路很简单很好理解也很好实现,老节点只能被复用一次加一个标记的方式实现,其它也就是遍历比较罢了。但是这样做的算法时间复杂度会是O(n^2),不是我们追求的。

现在主流的virtual dom库如Snabbdom,它们的dom diff算法并不是都做到了O(n),我们来探究它是如何做到的。

Snabbdom的updateChildren方法在进行新老节点的子数组比较时,以新节点的子节点数组为准进行遍历(复杂度O(n)),有序的匹配老节点的子节点数组中节点(hash匹配,复杂度O(1)),匹配成功则dom复用并进行移动和更新(更新时进入到子节点的patchNode方法),同时老节点的子节点数组索引后移一位。匹配失败则新建dom元素到对应位置上

在这个过程中,为了减少dom移动的次数,对四种匹配情况进行特殊处理,即新开始节点与老开始节点匹配,新结束节点与老结束节点匹配,新开始节点与老结束节点匹配,老结束节点与新结束节点匹配,这四种情况一旦匹配成功就可以避免进入dom移动的过程,提高运行速度。

最坏情况下,新节点的所有子节点都要遍历(复杂度O(n)) ,由于匹配老节点dom树时都是同层节点的hash匹配(复杂度O(1)),所以总复杂度也就是 O(n) * O(1) = O(n),这就是主流dom diff算法的时间复杂度为O(n)的由来。

其实在得到hash结构的过程中需要遍历老dom树,花费O(n)的时间复杂度

对于dom diff算法具体的执行过程仍有疑惑的同学,建议配合这篇Dom diff算法视频讲解来理解。

良心博主,点赞关注,么么哒!

下面我们进入snabbdom的源码分析,其中涉及到patchNode函数和updateChildren函数。目的是为了帮助阁下加深对DOM diff理解的同时,检验我上面对于DOM diff的理解。

2)patchNode函数源码分析

patchNode函数负责在新旧节点相同后,把新虚拟节点渲染在旧虚拟节点对应的dom节点位置上,它的源码收缩图如下: 在这里插入图片描述

对于patchNode函数的输入输出一目了然,我们看看它的内部逻辑主要是对两种情况进行分类处理(vnode对象的children和text不可能同时存在),它们分别是:

  • type1:新节点没有文本节点时的更新,也就是有可能说有children或者没有children(没有children并不是意味着dom没有子结点,可能有一个文本节点)
  • type2:新节点只有一个文本节点时的更新

type2内的逻辑比较简单,我们看看type1的源码:

if (isUndef(vnode.text)) {  // type1:新节点没有文本节点时的更新,也就是有可能有children或者没有children(没有children对dom来说并不是意味着没有子结点)
      if (isDef(oldCh) && isDef(ch)) {  // type1.1:新节点有children,老节点也有children
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } else if (isDef(ch)) { // type1.2新节点有children,老节点没有children
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");  // 
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {  // type1.3新节点没有children,老节点有children
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {// type1.3新节点没有children,老节点没有children(有可能有text)
        api.setTextContent(elm, "");
      }
    }

从源码中看到,在type1情况下,还分为以下四种type来进行逻辑处理:

  • type1.1:新节点有children,老节点也有children
  • type1.2:新节点有children,老节点没有children
  • type1.3:新节点没有children,老节点有children
  • type1.4:新节点没有children,老节点没有children(有可能有text)

type234一看代码就知道它的逻辑,这里我们说说type1.1,此时新老节点都有children数组,只有这种情况下才会进入updateChildren函数(Dom diff算法核心)比较更新,也就是我们上面所说的,新旧节点的子结点数组进行有序线性结构比较。

好的,现在我们进入updateChilren函数的探讨。

3)updateChildren函数源码分析

updateChildren函数负责在新旧节点相同后,把新虚拟节点的子节点与老虚拟节点的子结点数组进行比较后差异更新,它的源码收缩图如下: !

算法思路以及四种特殊情况上面有做过探讨,我们看看通用情况的处理源码:

else {  // 5.普适情况
        // 下面用新开始节点的key来匹配所有剩余老节点的key以找到dom。问题:为什么不直接用sel?sel相同并不意味着vnode相同(需要key和sel都相同)
        if (oldKeyToIdx === undefined) {  // 找到所有剩下(被上述四种特殊情况过滤后)的老节点的key id映射对象
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string];	// hash匹配,时间复杂度O(1)
        if (isUndef(idxInOld)) {  // type1.没有对应的老节点(key不同),需要创建dom对象并插入到老开始节点之前(因为是要以新节点数组的顺序为老节点数组的序)
          // New element
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } else {
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {   // type1.没有对应的老节点(key相同但sel不同),需要创建dom对象并插入到老开始节点之前。(不设置key有可能会导致渲染错误的答案)
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {// type2.有对应的老节点,更新该老dom对象并插入到老开始节点之前
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }

通过这段源码发现,snabbdom的做法和我们上面说的一样,用新节点数组中的节点一一去hash匹配老节点数组中的节点,有则移动或更新,无则创建并插入。

总结来说,snabbdom中的dom diff算法核心就是两个有序线性结构之间的比较。比较思路也就是把老dom线性结构变为hash结构,然后遍历新dom的线性结构去匹配hash,实现O(n)的复杂度。同时,为了减少比较后dom结构的移动,对新老dom线性结构的头尾分别进行优先匹配处理。

行文不易,点赞关注,么么哒!(^▽^)