一、什么是DOM?DOM的作用?

文档对象模型Document Object model,简称DOM)是针对HTMLXML文档的一个API(应用程序编程接口)。

也就是说:DOM是一个接口,一个API,我们可以通过DOM这个接口来对文档进行修改。

DOM会把文档表示为一棵树,这里以(HTML)为例:

HTML代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My title</title>
</head>
<body>
    <a href="https://www.baidu.com/">My link</a>
    <h1>My header</h1>
</body>
</html>

对应的DOM Tree:

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_vue

 在DOM中,文档是由节点构成的集合。(节点类型:元素节点(element)、文本节点(text)、属性节点(attribute))

元素节点:就是HTML中的标签,如:<html>、<head>、<body>、<title>、<a>、<h1>等

文本节点:如上图中的"My title"、"My link"等

 属性节点:如上图中的"href",以及我们在html中经常使用的"class"、"title"等

DOM的出现就是为了能够操作HTML中的元素:举个栗子

<p id="writing" title="文字">这是一段文字</p>
var wriText = document.getElementById("writing");
wriText.setAttribute("title","this is writing");

   getElementById() 就是DOM的一个方法;

   setAttribute() 方法添加指定的属性,并为其赋指定的值(若属性已存在,就是更改/设置值)。

DOM修改前:

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_vue_02

DOM修改后:

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_子节点_03

二、浏览器解析DOM的运行机制?


JS或JQ操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能,频繁操作还是会出现页面卡顿,影响用户体验。


Vue是数据驱动视图(数据的变化将引起视图的变化),但你发现某个数据改变时,视图是局部刷新而不是整个重新渲染,如何精准的找到数据对应的视图并进行更新呢?那就需要拿到数据改变前后的dom结构,找到差异点并进行更新!虚拟dom实质上是针对真实dom提炼出的简单对象。就像一个简单的div包含200多个属性,但真正需要的可能只有tagName,所以对真实dom直接操作将大大影响性能!

三、虚拟DOM是如何比较的?

虚拟dom的比较,就是找出新节点(vnode)和旧节点(oldVnode)之间的差异,然后对差异进行打补丁(patch)。大致流程如下:

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_元素节点_04

 

  • 新旧节点如果不相似,直接根据新节点创建dom;
  • 如果相似,先是对data比较,包括class、style、event、props、attrs等,有不同就调用对应的update函数,然后是对子节点的比较,子节点的比较用到了diff算法

Children Compare 过程中,如果找到了相似的childVnode,那它们将递归进入新的打补丁过程。

四、源码

patch()函数:

//patch函数接收两个参数oldCnode代表旧节点、vnode代表新节点
function patch (oldVnode, vnode) {
  var elm, parent;
  //判断新旧节点是否相似(就是比较tag和key等是否一致)
  if (sameVnode(oldVnode, vnode)) {
    // 相似就去打补丁(增删改)
    patchVnode(oldVnode, vnode);
  } else {
    // 不相似就整个覆盖
    // 当前oldVnode对应的真实元素节点
    elm = oldVnode.elm;
    // 父元素
    parent = api.parentNode(elm);
    // 根据Vnode生成新元素
    createElm(vnode);
    if (parent !== null) {
      // 将新元素添加进父元素
      api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
       // 移除以前的旧元素节点
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }
  return vnode.elm;
}
function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

对于新旧vnode不一致的处理方法很简单,就是根据vnode创建真实dom,代替oldVnode中的elm插入DOM文档。

对于新旧vnode一致的处理如下patchVnode()函数

function patchVnode (oldVnode, vnode) {
  // 新节点引用旧节点的dom
  let elm = vnode.elm = oldVnode.elm;
  const oldCh = oldVnode.children;
  const ch = vnode.children;

  // 调用update钩子,更新真实dom的各个属性
  if (vnode.data) {
    updateAttrs(oldVnode, vnode);
    updateClass(oldVnode, vnode);
    updateEventListeners(oldVnode, vnode);
    updateProps(oldVnode, vnode);
    updateStyle(oldVnode, vnode);
  }

  // 判断是否为文本节点
  if (vnode.text == undefined) {
    //新旧节点都有children,进入子节点的比较
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      //新节点有子节点
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) api.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
       //旧节点有子节点
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      api.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    api.setTextContent(elm, vnode.text)
  }
}

updateAttrs()方法如下:

function updateAttrs (oldVnode, vnode) {
  let key, cur, old
  const elm = vnode.elm
  const oldAttrs = oldVnode.data.attrs || {}
  const attrs = vnode.data.attrs || {}

  //遍历vnode属性,如果和oldVnode不一样就调用setAttribute()修改
  for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
      if (booleanAttrsDict[key] && cur == null) {
        elm.removeAttribute(key)
      } else {
        elm.setAttribute(key, cur)
      }
    }
  }
  // 遍历oldVnode属性,如果不在vnode属性中就调用removeAttribute()删除。
  for (key in oldAttrs) {
    if (!(key in attrs)) {
      elm.removeAttribute(key)
    }
  }
}

 

你会发现里面有个booleanAttrsDict[key]的判断,是用于判断在不在布尔类型属性字典中。

eg: <video autoplay></video>,想关闭自动播放,需要移除该属性。

所有数据比较完后,就到子节点的比较了。先判断当前vnode是否为文本节点,如果是文本节点就不用考虑子节点的比较;若是元素节点,就需要分三种情况考虑:

  • 新旧节点都有children,那就进入子节点的比较(diff算法)
  • 新节点有children,旧节点没有,那就循环创建dom节点;
  • 新节点没有children,旧节点有,那就循环删除dom节点。

后面两种情况都比较简单,我们直接对第一种情况,子节点的比较进行分析。

diff算法

子节点的比较实质上就是循环进行头尾节点比较。循环结束的标志就是:旧子节点数组或新子节点数组遍历完,(即 oldStartIdx > oldEndIdx || newStartIdx > newEndIdx)。大概看一下循环流程

  • 第一步 头头比较。若相似,旧头新头指针后移(即 oldStartIdx++ && newStartIdx++),真实dom不变,进入下一次循环;不相似,进入第二步。
  • 第二步 尾尾比较。若相似,旧尾新尾指针前移(即 oldEndIdx-- && newEndIdx--),真实dom不变,进入下一次循环;不相似,进入第三步。
  • 第三步 头尾比较。若相似,旧头指针后移,新尾指针前移(即 oldStartIdx++ && newEndIdx--),未确认dom序列中的头移到尾,进入下一次循环;不相似,进入第四步。
  • 第四步 尾头比较。若相似,旧尾指针前移,新头指针后移(即 oldEndIdx-- &&newStartIdx++),未确认dom序列中的尾移到头,进入下一次循环;不相似,进入第五步。
  • 第五步 若节点有key且在旧子节点数组中找到sameVnode(tag和key都一致),则将其dom移动到当前真实dom序列的头部,新头指针后移(即 newStartIdx++);否则,vnode对应的dom(vnode[newStartIdx].elm)插入当前真实dom序列的头部,新头指针后移(即newStartIdx++)。

看个案例:

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_子节点_05

Tree V2 虚拟化树形控件 创建节点 虚拟dom树原理_元素节点_06

但结束循环后,有两种情况需要考虑:

  • 新的字节点数组(newCh)被遍历完(newStartIdx > newEndIdx)。那就需要把多余的旧dom(oldStartIdx -> oldEndIdx)都删除,上述例子中就是c,d
  • 旧的字节点数组(oldCh)被遍历完(oldStartIdx > oldEndIdx)。那就需要把多余的新dom(newStartIdx -> newEndIdx)都添加。