「源码剖析」如何实现一个虚拟DOM算法

前端简报

共 4689字,需浏览 10分钟

 ·

2021-02-01 22:09

点击上方“前端简报”,选择“设为星标

第一时间关注技术干货!


上篇文章《虚拟DOM如何进化为真实DOM》中讲到了如何通过虚拟DOM树转化为真实DOM渲染到页面中。但是在渲染的过程中,我们直接将新的虚拟DOM树转化成真实DOM替换掉旧的DOM结构。当真实的DOM中的状态或者内容发生变化的时候,重新渲染新的虚拟DOM树再替换掉旧的,这样的话会显得很无力。
设想一种情景,当我们对整个DOM结构中只是修改了一个小的数据甚至是一个标点符号的时候或者数据量很大的时候,我们要把原来旧的DOM结构全部替换掉,这样的话对计算机而言太浪费性能了。

故我们希望是在更新的时候通过新渲染的虚拟DOM树旧的虚拟DOM树进行对比,记录这两颗树的差异。记录下来的不同就是我们需要对页面真正的DOM操作,然后把它们渲染在真正的DOM结构上,页面就对应的变化了。这样就实现了:看似视图全部结构得到了最新的渲染,但是最后操作DOM结构的时候只是改变了与原结构不同的地方。

即虚拟DOM的diff算法的主体思路是:

1.将虚拟DOM结构转化为真实的DOM结构替换到旧的DOM(第一次旧的为undefined),渲染到页面中。

2.当状态变化的时候,新渲染一颗虚拟DOM树和原来旧的虚拟DOM树对比,对比之后记录下差异。

3.将最终由差异的部分转化成真实DOM结构渲染到页面上。



实现

在旧的虚拟节点和新的虚拟节点的对比过程中会出现以下几种情况,下面我们以Vue为例看Vue2.0是Diff算法是怎么实现的:

比较两个元素的标签

如果标签不一样的话直接替换掉,例如:div变成p

div->p

<<<<<<<HEAD
<p>
前端简报p>

=========

<div>前端简报div>
>>>>>>>>

判断虚拟节点的tag属性是否相等,如果不相等将新的虚拟DOM树转化为真实DOM结构把原来节点替换掉
 if (oldVnode.tag != vnode.tag) {
   return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
 }
效果图:

比较两个元素的文本

当标签一样的时候比较文本是否一样。如果文本不一样的话那么直接替换掉文本内容。


<<<<<<<HEAD
<div>
前端div>

=========
<div>简报div>
>>>>>>>>

两个节点的tag都是div,故比较孩子虚拟DOM树的是否一样,孩子的tagundefined说明是文本节点,此时比较本文内容text是否一致即可

if (!oldVnode.tag) {
    //文本的对比
    if (oldVnode.text != vnode.text) {
      return (oldVnode.el.textContent = vnode.text);
    }
  }

效果图:

比较标签属性

如果两个标签一样那么比较标签的属性,当属性更新的时候通过新旧属性的对比会出现下面几种情况:

1、属性对比

如果旧的虚拟节点有,新的虚拟节点没有那么需要删除旧的虚拟节点上的属性。

let newProps = vnode.data || {}; //新的属性
let el = vnode.el;
//老的有 新的没有 需要删除属性
for (let key in oldProps) {
  if (!newProps[key]) {
    el.removeAttribute(key); //移除真实dom的属性
  }
}

反过来,如果旧的虚拟节点没有,新的虚拟节点有那么直接设置新的属性即可

//新的有 那就直接用新的去更新即可
for (let key in newProps) {
    el.setAttribute(key, newProps[key]);
}

对应的源码地址:src\platforms\web\runtime\modules\attrs.js

2、样式处理

如果老的样式中存在新的样式没有那么删除老的样式。

- style={color:red}
style={background:red}
let newStyle = newProps.style || {};
let oldStyle = oldProps.style || {};
//老的样式中有的 新的没有  删除老的样式
for (let key in oldStyle) {
  if (!newStyle[key]) {
    el.style[key] = "";
  }
}

相反如果老的样式没有,新的样式存在那么直接更新新的样式即可

for (let key in newProps) {
  if (key == "style") {
    for (let styleName in newProps.style) {
      el.style[styleName] = newProps.style[styleName];
    }
  } 
}

对应的源码地址:src\platforms\web\runtime\modules\style.js

3、类名处理

对于类名处理我们使用新节点的类名

- class="title ant-title"
+ class="title ant-mian-title"


for (let key in newProps) {
 if (key == "class") {
    el.className = newProps.class;
}

对应的源码地址src\platforms\web\runtime\modules\class.js

比较儿子

在比较儿子的过程中可以分为以下几种情况:

1、老节点有儿子,新节点没有儿子删除老节点的儿子即可

if (isDef(oldCh)) {
  removeVnodes(oldCh, 0, oldCh.length - 1)

=========================================
if (oldChildren.length > 0) {
     el.innerHTML = "";
}

2、老节点没有儿子,新节点有儿子遍历children转化为真实的DOM结构添加到页面中

if (isDef(ch)) {
  if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
  addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)

===============================================================
if (newChildren.length > 0) {
  for (let i = 0; i < newChildren.length; i++) {
     let child = newChildren[i];
     el.appendChild(createElm(child));
  }
}

3、老节点有儿子,新节点有儿子

当老节点的儿子和新节点的儿子都存在并且不相等的时候,这种情况比较复杂也是diff算法的核心

在vue2.0中比较老节点和新节点区别的时候采用了双指针的方式,通过同时向同一个方向循环老节点和新节点,只要有一个节点循环完成就结束循环。如果是老节点先结束,那么将新节点剩余的元素添加到渲染列表;如果是新节点先结束,那么将旧节点剩余的元素删除即可。

定义开头指针其中包括老节点开始位置结束位置新节点开始位置结束位置

  let oldStartIndex = 0//老的索引
  let oldStartVnode = oldChildren[0]; //老的索引指向的节点
  let oldEndIndex = oldChildren.length - 1;
  let oldEndVnode = oldChildren[oldEndIndex];

  let newStartIndex = 0//新的索引
  let newStartVnode = newChildren[0]; //新的索引指向的节点
  let newEndIndex = newChildren.length - 1;
  let newEndVnode = newChildren[newEndIndex];

通过判断两个节点的keytag是否相等来确定同一元素

function sameVnode (a, b{
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
       ...
      ) || (
        ...
      )
    )
  )
}

正序排列

如果多余的节点的右边的话,那么从左往右依次判断老的开始节点和新的开始节点是否是同一节点,如果是同一节点调用patchVode方法去递归子节点,将老节点和新节点的下标加1向右移动,直到下标大于children的长度。

if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  oldStartVnode = oldCh[++oldStartIdx]
  newStartVnode = newCh[++newStartIdx]

效果图:

如果是新节点多余添加到渲染视图,如上图从左到右对比时,g节点的下一个elnullinsertBefore相当于appendChild方法向后插入;如果是从右向左,g节点的下一个ela,那么采用insertBefore相当于向a前面插入节点。

if (oldStartIndex > oldEndIndex) {
     for (let i = newStartIndex; i <= newEndIndex; i++) {
      let ele =
        newChildren[newEndIndex + 1] == null
          ? null
          : newChildren[newEndIndex + 1].el;
      parent.insertBefore(createElm(newChildren[i]), ele);
    }
}

如果是老节点多余,那么说明这些节点是不需要的,删除掉即可,如果在删除的过程中出现null,说明这个节点已经处理过了跳过即可。

if(newStartIdx > newEndIdx){
  for (let i = oldStartIndex; i <= oldEndIndex; i++) {
     let child = oldChildren[i];
     if(child!= undefined){
       parent.removeChild(child.el);
     }
  }
}

如果多余的节点在左边,从新老节点的结束节点开始下标依次减1

if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
}


反转排列

如果遇到新老节点反转的情况,通过老节点的开始节点和新节点的结束节点作对比或者老节点和结束节点和新节点的开始节点作对比。

如果老节点的开始节点和新节点的结束节点是同一节点,那么将老的开始节点插入到老的结束节点的下一个节点之前,然后依次分别向右向左移动节点对应的下标,获取对应的值继续遍历。

if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

如果老节点的结束节点和新节点的开始节点是同一节点吗,那么将老节点的结束节点插入到老节点的开始节点前面,然后依次分别向左向右移动节点对应的下标,获取对应的值继续遍历。

if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
  patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]


毫无关系排列

如果在对比的过程中儿子之间没有任何的关系,通过从新节点的开始节点开始依次和老节点的所有节点作对比,如果没有相同的就创建新的节点插入的老节点的开始节点之前,如果在循环的过程中找到了相同的元素,那么直接复用老元素,将和新节点相同的老节点插入到老节点的开始节点之前,为了防止数组的塌陷问题,将移走的老节点的位置设为undefined,最后将多余的老节点全部删除即可。

设置缓存组使用老节点的key和下标做一个映射表,新节点的key去老的映射表里筛选,如果没有筛选到,那么就不复用直接创建新节点插入到老节点的开始节点之前。
function createKeyToOldIdx (children{
  let i, key
  const map = {}
   children.forEach((item, index) => {
      if (isDef(item.key)) {
        map[item.key] = index; //{a:0,b:1,c:2,d:3,e:4,f:5,g:6}
      }
  return map
}

如果在老节点中找到,那么移动老节点到老节点开始节点之前

let map = createKeyToOldIdx(oldChildren);
 //儿子之间没有关系
let moveIndex = map[newStartVnode.key];  //拿到开头的虚拟节点的key去老的里面找

if(moveIndex == undefined){
  parent.insertBefore(createElm(newStartVnode),oldStartVnode.el);
}else{
  let moveVNode = oldChildren[moveIndex];  //这个老的虚拟节点需要移动
  oldChildren[moveIndex] = null;
  parent.insertBefore(moveVNode.el,oldStartVnode.el);
  patch(moveVNode,newStartVnode)  //比较属性和儿子
}
newStartVnode = newChildren[++newStartIndex]  //用新的不停的去老的里面找

在移动的过程中开始指针和结束指针可能存在指向null的情况,如果指向null的话那么无法在进行比较,可以直接跳过,指向下一个元素即可。

if (isUndef(oldStartVnode)) {
  oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
else if (isUndef(oldEndVnode)) {
  oldEndVnode = oldCh[--oldEndIdx]
}

源码地址:src/core/vdom/patch.js


为什么要使用key?

人丑话不多先看图

有key

没有key

如上图所示,第一个图为有key的情况,第二个图为没有key的情况,可以很明显的看到所展示内容如果有key的话,复用了key为A,B,C,D的4个节点,结果只是将新创建的E节点插入到C节点的前面完成渲染。如果没有key的话,那么创建了E,C,D三个节点,降低了复用率,性能方面肯定没有有key 的情况高。


为什么不能用index作为key呢?

平时开发过程中,如果只是通过页面静态渲染是可以使用index作为key的,如果在页面上有复杂的逻辑变化,那么使用index作为key相当于没有key。

<li index=0>Ali>      <li index=0>Cli>
<li index=1>Bli>      <li index=1>Bli>
<li index=2>Cli>      <li index=2>Ali>

如上代码所示,将下标为02AC变换位置之后需要重新创建节点A和C,此时C的下标为0A的下标为2。而以id或者唯一标识作为key的话,相当于是将A和C元素的位置进行平移。平移的性能比创建节点的性能高。

在使用index作为key的时候还会产生意想不到的问题,假如我们把B节点删除,我们最开始取值为B,现在取值变成了C。



总结





 Vue2.0的diff算法pathVode方法的基本思路可以总结为以下几点:

1.判断oldVode和newVode是否是同一对象,如果是的话直接return。
2.是定义真实DOM为el。
3.如果oldVode和newVode都有文本节点并且不相等,那么将old的文本节点设置为newVode的文本节点。
4.如果oldVode有子节点newVode没有,那么删掉子节点。
5.如果oldVode没有子节点newVode有。那么将子节点转化为真实DOM添加到el中。
6.如果都有子节点,那么执行updateChildren函数比较子节点

以上就是Diff算法的整个过程,它对整个Vue渲染过程的性能有着至关重要的作用。



END



精选推荐


虚拟DOM如何进化为真实DOM

前端简报

28 Jan 2021

浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报