浏览器中的Dom更新

  在浏览器中渲染引擎将 node 节点添加到 另外节点中时会触发样式计算、布局、绘制、栅格化、合成等任务,这一过程称为重排。

  除了重排之外,还有可能引起重绘或者合成操作,也就是“牵一发而动全身”。

  另外,对于 DOM 的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。

  因此,对于 DOM 的操作时刻都需要非常小心谨慎。

  对于一些复杂的页面或者目前使用非常多的单页应用来说,其 DOM 结构是非常复杂的,而且还需要不断地去修改 DOM 树,每次操作 DOM 渲染引擎都需要进行重排、重绘或者合成等操作,因为 DOM 结构复杂,所生成的页面结构也会很复杂,对于这些复杂的页面,执行一次重排或者重绘操作都是非常耗时的,这就给浏览器带来了真正的性能问题。

  这解决办法就是虚拟Dom。

加入虚拟dom之后的浏览器更新

  1. 将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上;
  2. 变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了。
  3. 在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。

  如下图:

1、创建阶段

  首先依据 JSX 和基础数据创建出来虚拟 DOM(并缓存起来),它反映了初始的真实的 DOM 树的结构。

  然后由虚拟 DOM 树创建出真实 DOM 树,真实的 DOM 树生成完后,再触发渲染流水线往屏幕输出页面。

2、更新阶段

  如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟 DOM 树;

  然后比较(原虚拟Dom树和新虚拟Dom树,此时用到了Diff算法)两个树,找出变化的地方,并把变化的地方一次性更新到真实的 DOM 树上;

  最后渲染引擎更新渲染流水线,并生成新的页面。

框架中的Dom更新

如上图,可以把虚拟 DOM 看成是 MVC 的视图部分,其控制器和模型可以是redux也可以是Vuex。其具体实现过程如下:

  1. 图中的控制器是用来监控 DOM 的变化,一旦 DOM 发生变化,控制器便会通知模型,让其更新数据;
  2. 模型数据更新好之后,控制器会通知视图,通知其模型的数据发生了变化;
  3. 视图接收到更新消息之后,会根据模型所提供的数据来生成新的虚拟 DOM;
  4. 新的虚拟 DOM 生成好之后,就需要与之前的虚拟 DOM 进行比较,找出变化的节点(使用了Diff算法);
  5. 比较出变化的节点之后,React 将变化的虚拟节点应用到 DOM 上,这样就会触发 DOM 节点的更新;
  6. DOM 节点的变化又会触发后续一系列渲染流水线的变化,从而实现页面的更新。

真实Dom转化为虚拟Dom

具体实现思路:

// 将真实DOM转化为虚拟DOM
// <div />  => {tag:'div'}   元素转化
// 文本节点 => {tag:undefined,value:'文本节点'}   文本节点转化
// <div title="1" class="c"  />  => { tag:'div',data:{ title:'1',class:"c" } }   多属性转化
// <div><div /></div> => {tag:'div',children:[{ tag:'div' }]} 
// 用构造函数来 进行以上转换

        其中进行元素节点和文本节点以及多属性节点的区分是利用了node节点的nodeType属性:

  • 如果节点是一个元素节点,nodeType 属性返回 1。
  • 如果节点是属性节点, nodeType 属性返回 2。
  • 如果节点是一个文本节点,nodeType 属性返回 3。
  • 如果节点是一个注释节点,nodeType 属性返回 8。

        该属性是只读的。

        大概实现代码:

class VNode {
    // 构造函数
    constructor( tag,data,value,type ){
        // tag:用来表述 标签  
        // data:用来描述属性  
        // value:用来描述文本 
        // type:用来描述类型
        this.tag = tag && tag.toLowerCase();//文本节点时 tagName是undefined
        this.data = data;
        this.value = value;
        this.type = type;
        this.children = [];

    }
    appendChild(vnode){
        this.children.push(vnode);
    }
}
/**
    利用递归 来遍历DOM元素 生成虚拟DOM
    Vue中的源码使用栈结构,使用栈存储父元素来实现递归生成
*/
function getVNode(node){
    let nodeType = node.nodeType;
    let _vnode = null;
    if(nodeType === 1){
        // 元素
        let nodeName = node.nodeName;//元素名,什么标签?
        let attrs = node.attributes;//属性伪数组元素上的属性
        let _attrObj = {};
    
        //attrs[i] 属性节点(nodeType == 2) 是对象                    
        for(let i=0;i<attrs.length;i++){
             //attrs[i].nodeName:属性名 attrs[i].nodeValue:属性值
            _attrObj[attrs[i].nodeName] = attrs[i].nodeValue;               
        }
        //标签名(DIV UI LI...)、所有属性对象、value值(只有文本标签有)、type类型(是元素还是文本)
        _vnode = new VNode( nodeName,_attrObj,undefined,nodeType);
        // 考虑node的子元素
        let childNodes = node.childNodes;
        for(let i = 0;i<childNodes.length;i++){
            _vnode.appendChild(getVNode(childNodes[i]));//递归
        }
    }else if(nodeType === 3){
        // 文本节点
       _vnode = new VNode(undefined,undefined,node.nodeValue,nodeType);//无标签名、无属性、有value、有type
    }
    return _vnode;
}

 // 读取根节点下dom数据,或者改成指定dom下数据
let root = document.querySelector("#root");

let vroot = getVNode ( root );//虚拟DOM
console.log(vroot);

虚拟Dom转化为真实Dom

        无论是什么类型的节点,只有三种类型的节点会被创建并插入到的Dom中:元素节点、注释节点、和文本节点:

// {tag:'div'} => <div />   元素转化
// {tag:undefined,value:'文本节点'} => 文本节点    文本节点转化
// { tag:'div',data:{ title:'1',class:"c" } } => <div title="1" class="c"  />     多属性转化
// {tag:'div',children:[{ tag:'div' }]} => <div><div /></div>

具体实现思路:

        vnode数据类型:

vnode = {
    tag:'div', // 以div为例
    // attrs中包含插在节点上的属性,就比如类或者样式,内联样式等
    attrs:{
        class:'a'            
    }
    children:[], // children之中可能是另外的vnode
}
  • 将字符串转化为文本节点;
  • 将数字转化为字符串再转化为文本节点;
  • 将多属性节点转换为文本节点,子节点再延续上面的过程;

        大概实现代码如下:

//Virtual DOM => DOM
function render(vnode, container) {
  container.appendChild(_render(vnode));
}

function _render(vnode) {
  // 如果是数字类型转化为字符串,然后转到生成文本节点
  if (typeof vnode === 'number') {
    vnode = String(vnode);
  }
  // 字符串类型直接就是文本节点
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode);
  }
  // 普通DOM
  const dom = document.createElement(vnode.tag);
  if (vnode.attrs) {
    // 遍历属性
    Object.keys(vnode.attrs).forEach(key => {
      const value = vnode.attrs[key];
      dom.setAttribute(key, value);
    })
  }
  // 子数组进行递归操作
  vnode.children.forEach(child => render(child, dom));
  return dom;
}

使用到的document方法解析:

        1.DOM appendChild() :

        appendChild() 方法向节点添加最后一个子节点。

        也可以使用 appendChild() 方法从一个元素向另一个元素中移动元素。

        2.DOM createElement():

        createElement() 方法通过指定名称创建一个元素:

        3.DOM setAttribute():

        setAttribute() 方法添加指定的属性,并为其赋指定的值。

        如果这个指定的属性已存在,则仅设置/更改值。

DOM-Diff算法

        DOM-Diff就是一种比较算法,比较两个虚拟DOM的区别,也就是比较两个对象的区别。

        也有真实DOM与虚拟DOM的比较,不过这里先只讨论虚拟DOM之间的比较。

DOM-Diff(传统)算法

        处理方案: 循环递归每一个节点,如下图:

        左侧树a节点依次进行如下对比:

        a->e、a->d、a->b、a->c、a->a

        之后左侧树其它节点b、c、d、e亦是与右侧树每个节点对比, 算法复杂度能达到O(n^2)

        查找完差异后还需计算最小转换方式,最终达到的算法复杂度是O(n^3)。

        将两颗树中所有的节点一一对比需要O(n²)的复杂度,在对比过程中发现旧节点在新的树中未找到,那么就需要把旧节点删除,删除一棵树的一个节点(找到一个合适的节点放到被删除的位置)的时间复杂度为O(n),同理添加新节点的复杂度也是O(n),合起来diff两个树的复杂度就是O(n³)

DOM-Diff(优化后)算法

        vue和react的虚拟DOM的diff算法大致相同,其核心是基于两个简单的假设:

  1. 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构
  2. 同一层级的一组节点,他们可以通过唯一的id进行区分

        考虑到很少进行跨层移动,所以用平层对比,时间复杂度从O(n^3)缩短为O(n),在对比过程中直接对真实dom更新。变更一般有三种: 文本 ,节点属性,节点变更,增删节点(绑定key值的作用)

DOM-Diff三种优化策略

  • web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。
  • 拥有相同类型的两个组件将会生成相似的树形结构,拥有不同类型的两个组件将会生成不同树形结构。
  • 对于同一层级的一组自节点,他们可以通过唯一id进行区分。

        也就是:

        比较只会在同层级进行, 不会跨层级比较。

        具体表现如下:

  • 只比较平级

  • 不会跨级比较

  • 同一级的变化节点,如果节点相同只是位置交换,则会复用。(通过key来实现)

React优化Diff算法

        基于以上优化的diff三点策略,react分别进行以下算法优化

  • tree diff
  • component diff
  • element diff

tree diff

        react对树的算法进行了分层比较。react 通过 updateDepth对Virtual Dom树进行层级控制,只会对相同颜色框内的节点进行比较,即同一个父节点下的所有子节点。当发现节点不存在,则该节点和其子节点都会被删除。这样是需要遍历一次dom树,就完成了整个dom树的对比

        分层比较 :

        如果是跨层级的移动操作,如图

        当根结点发现A消失了,会删除掉A以及他的子节点。当发现D上多了一个A节点,会创建A(包括其子节点)节点作为子节点

        所以:当进行跨层级的移动操作,react并不是简单的进行移动,而是进行了删除和创建的操作,这会影响到react性能。所以要尽量避免跨层级的操作。(例如:控制display来达到显示和隐藏,而不是真的添加和删除dom。

component diff

  • 如果是组件类相同(class)的组件,则直接对比virtual Dom tree
  • 如果组件类不同(结构相似)的组件,则判断为 dirty component(脏组件),整个替换掉组件以及组件下的所有子组件
  • 如果组件类相同,但是可能virtual DOM 没有变化,这种情况下我们可以使用shouldComponentUpdate() 来判断是否需要进行diff

        如果组件D和组件G,如果组件类不同,但是结构类似。这种情况下,因为组件类不同,所以react会删除D,创建G。所以我们可以使用shouldComponentUpdate()返回false不进行diff。

        针对react15, 16出了新的生命周期

        所以:component diff 主要是使用shouldComponentUpdate() 来进行优化

element diff

        element diff 涉及三种操作:

  • 插入
  • 移动
  • 删除

        不使用key的情况:

        不使用key的话,react对新老集合对比,发现新集合中B不等于老集合中的A,于是删除了A,创建了B,依此类推直到删除了老集合中的D,创建了C于新集合。

这样会产生渲染性能瓶颈,于是react允许添加key进行区分。

        使用key的情况:

        react首先对新集合进行遍历,通过唯一key来判断老集合中是否存在相同的节点,如果没有的话创建,如果有的话进行移动操作

移动优化

        在移动前,会将节点在新集合中的位置(_mountIndex)和在老集合中位置(lastIndex)进行比较,如果if (child._mountIndex < lastIndex) 进行移动操作,否则不进行移动操作。这是一种顺序移动优化。只有在 新集合的位置 小于 在老集合中的位置 才进行移动。

        如果遍历的过程中,发现在新集合中没有,但是在老集合中的节点,会进行删除操作

        所以:element diff 通过唯一key 进行diff 优化。

总结:

  • react中尽量减少跨层级的操作。
  • 可以使用shouldComponentUpdate() 来避免react重复渲染。
  • 尽量添加唯一key,以减少不必要的重渲染

Vue2.x优化Diff

        vue2.0加入了virtual dom,和react拥有相同的 diff 优化原则

        差异就在于, diff的过程就是调用patch函数,就像打补丁一样修改真实dom。也就是通过js层面的计算,根据两个虚拟对象创建出差异的补丁对象patch,用来描述改变了哪些内容,然后用特定的操作解析patch对象,更新dom完成页面的重新渲染。

        使用的主要方法:

  • patchVnode
  • updateChildren

        updateChildren是vue diff的核心

        过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。

        Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比React的Diff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅。

Vue 3.x

        Vue3.x借鉴了 ivi算法和 inferno算法。在创建VNode时就确定其类型,以及在mount/patch的过程中采用位运算来判断一个VNode的类型,在这个基础之上再配合核心的Diff算法,使得性能上较Vue2.x有了提升。

        该算法中还运用了动态规划的思想求解最长递归子序列。

参考及复制文章链接

        引用不分前后

Logo

前往低代码交流专区

更多推荐