学习diff算法与虚拟DOM

160 阅读3分钟

一、diff算法的简史

  • diff 算法并不是近年才有的,早在多年以前就已经有人在研究 diff 算法了,最早复杂度基本是 O(m^3n^3)然后优化了 30 多年,终于在 2011 年把复杂度降低到 O(n^3) 这里的 n 指的是节点总数
  •  所以 1000 个节点,要进行 1亿次操作!
  • 当今的 diff 算法主要指 react 横空出世之后提出的近代同层比较的 diff 算法,因为是同层嘛,复杂度就到了 O(n)

二、为什么需要 diff?

  • 本质上就是为了性能
  • 直接操作dom容易慢;DOM对象本身也是一个js对象,所以严格来说,并不是操作这个对象慢,而是说操作了这个对象后,会触发一些浏览器行为,比如布局(layout)和绘制(paint)
  • 使用  “数据模型 -> virtual dom -> 视图(DOM)”思路去提升性

三、diff算法如何进行比较

  • 计算机世界里,比较两棵树的异同,其实可以抽象的理解为,将一颗树转成另一颗树的过程,这里的复杂度需要衡量的是‘’操作数“

  • 假设,将字符串 'hello' -> 'hallo' 需要几步?一眼望去就知道答案是一步,这个时候脑子里的思考过程其实就是编辑距离算法的模型了

  • 抽象一点,对字符串的操作不外乎三种,「替换」「插入」「删除」,执行这三种操作后到达目的的最小操作数,就是最短编辑距离,这里的复杂度就是我们需要考虑的,这就是‘’操作数‘’

  • 树的最短编辑距离算法复杂度是 O(n^2),其实就是实现的时候,需要双层 for 循环去计算,这里复杂度就是 O(n^2) 了,然后,因为 diff 还要做一次 patch,(找到差异后还要计算最小转换方式),这个时候还要在之前遍历的基础上再遍历一次,所以累计起来就是 O(n^3)

  •   const arr = [a, b, c] 
      const newArr = [b, d, e, f] 
      // 两个数组进行同层比较
      // [a, b] [b, d] [c, e] [null, f]// 具体到虚拟DOM
       for (let i = 0, len = oldNodes.length; i < len; i++) {   
          if (oldNodes[i].type !== newNodes[i].type) {  
             replace()    
           }  else if (oldNodes[i].children && oldNodes[i].children.length) { 
           // 如果没有这一层,假设 type 全不相同,那么就是 O(n),最坏复杂度 O(nm)    
           } 
       }
    

四、降低复杂度

  • 将复杂度降下来,其实就是在算法复杂度、虚拟 dom 渲染机制、性能中找了一个平衡,react 采用了启发式的算法,做了如下最优假设:
  1.  如果节点类型相同,那么以该节点为根节点的 tree 结构,大概率是相同的,所以如果类型不同,可以直接「删除」原节点,「插入」新节点
  2. 跨层级移动子 tree 结构的情况比较少见,或者可以培养用户使用习惯来规避这种情况,遇到这种情况同样是采用先「删除」再「插入」的方式,这样就避免了跨层级移动
  3. 同一层级的子元素,可以通过 key 来缓存实例,然后根据算法采取「插入」「删除」「移动」的操作,尽量复用,减少性能开销
  4. 完全相同的节点,其虚拟 dom 也是完全一致的
  • 基于这些假设,可以将 diff 抽象成只需要做同层比较的算法,这样复杂度就直线降低了

  • 针对于子元素的key。如果没有指定 key 的话,react 是会给 warning 的,同时默认使用下标 index 作为 key,但是这其实是不建议的。

  • key 的特殊 attribute 主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes

  • 如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。

  •  举个例子,假设原来有 [1, 2, 3] 三个子节点渲染了,假设我们这么操作了一波,将顺序打乱变成 [3, 1, 2],并且删除了最后一个,变成 [3, 1];最优的 diff 思路应该是复用 3, 1组件,移动一下位置,去掉 2 组件,这样整体是开销最小的,如果有 key 的话,这波操作水到渠成,如果没有 key 的话,那么就要多一些操作了:

  1. 判断哪些可以复用,有 key 只需要从映射中检查3, 1在不在,没有 key 的话,可能就执行替换了,肯定比「复用」「移动」开销大了
  2.  删除了哪一个?新增了哪一个?有 key 的话是很好判断,之前的映射没有的 key,比如变成 [3, 1, 4]那这个 4 很容易判断出应该是新建的,删除也同理。但是没有 key 的话就麻烦一些了。
  • 那既然 key 很重要,那么就有两种操作是误区了
  1.  第一是使用随机数,这种导致的问题肯定就是每次 key 都不一样,那这样就不是复用了,都是新建了。
  2. 第二种是使用数组下标,就会带来一些问题,看看这个 codesandbox.io/s/ancient-m…
  • 我们来分析一下:[1, 2, 3] 这是原来的渲染节点,页面展示出 1, 2, 3,然后我们 splice(0, 1) 删除第一个元素后,理想情况是变成 [2, 3],但是因为使用了下标为 key,对比前后两次 keys,[0, 1, 2] -> [0, 1] 因为 vue 的机制,sameNode 判断一波后,误认为是 2 被删除了!

    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) // 当标签是的时候,type必须相同 ) }

  • react ,例子:codesandbox.io/s/fervent-n…

  • react正确的删除了第一个元素,但是其他的元素都被重新渲染力,使用性能的开销来换取正确的解。

五、虚拟DOM

1. 什么是虚拟 DOM

{  
 type: 'div',  
 props: {   
  children: [] 
  },   
 el: xxxx
}

2. 怎么创建虚拟 DOM

 //  h 、createElement... 
 function h(type, props){   
      return { type, props } 
   }

3. 使用

 // JSX: 
 <div>   
   <ul className='padding-20'>  
    <li key='li-01'>this is li 01</li>   
   </ul> 
 </div>  
// 经过一些工具转一下: 
  createElement('div', {  
    children: [     
      createElement('ul', { className: 'padding-20' },    
      createElement('li', { key: 'li-01'}, 'this is li 01')) 
     ] 
 })

4.虚拟DOM的数据结构有了,那就是渲染了 (mount/render)

 //f(vnode) -> view  
   f(vode) {   
    document.createElement();  
     ....   
    parent.insert()  
      . insertBefore 
   } 
   export const render = (vnode, parent) => {  } 
   <div id='app'></div>

5.diff 相关了(patch)

f(oldVnodeTree, newVnodeTree, parent) -> 调度? -> view